Compare commits

...

86 Commits

Author SHA1 Message Date
45415da2e2 fix(tauri): bypass WebView CORS for packaged R2/S3 apps
Preserve aws4fetch Request headers in the shell fetch interceptor, and
proxy cross-origin HTTP through a native reqwest command so Windows/Android
builds work even when remote CORS only allows web origins.
2026-07-19 10:11:50 +08:00
github-actions[bot]
ef97b99299 chore: cleanup build X4BTsYv3O2 2026-07-18 13:30:36 +00:00
444abb39e6 chore: upload logo.png for build X4BTsYv3O2 2026-07-18 21:23:02 +08:00
7c3c1b38fa chore: upload version.txt for build X4BTsYv3O2 2026-07-18 21:23:02 +08:00
57b1b57ddc chore: upload site.zip for build X4BTsYv3O2 2026-07-18 21:23:00 +08:00
github-actions[bot]
668ad68def chore: cleanup build X883begTyJ 2026-07-12 10:01:50 +00:00
3b332cb9de chore: upload logo.png for build X883begTyJ 2026-07-12 17:54:31 +08:00
1c31694900 chore: upload version.txt for build X883begTyJ 2026-07-12 17:54:30 +08:00
1a2f6b5b8c chore: upload site.zip for build X883begTyJ 2026-07-12 17:54:29 +08:00
github-actions[bot]
4a98ae263a chore: cleanup build R2ppnODBNe 2026-07-04 14:25:11 +00:00
745c32d6a2 chore: upload logo.png for build R2ppnODBNe 2026-07-04 22:20:08 +08:00
454d60a791 chore: upload version.txt for build R2ppnODBNe 2026-07-04 22:20:06 +08:00
6aee0a37b2 chore: upload site.zip for build R2ppnODBNe 2026-07-04 22:20:04 +08:00
github-actions[bot]
10fdb0e8ae chore: cleanup build TvE7ui81pG 2026-07-03 13:25:08 +00:00
00f014f447 chore: upload logo.png for build TvE7ui81pG 2026-07-03 21:20:10 +08:00
46736b2771 chore: upload version.txt for build TvE7ui81pG 2026-07-03 21:20:09 +08:00
117869483b chore: upload site.zip for build TvE7ui81pG 2026-07-03 21:20:08 +08:00
github-actions[bot]
f78ea08b4f chore: cleanup build NWQr97MqIW 2026-07-03 12:35:03 +00:00
d862a6c515 chore: upload logo.png for build NWQr97MqIW 2026-07-03 20:30:28 +08:00
7b9cb8360f chore: upload version.txt for build NWQr97MqIW 2026-07-03 20:30:28 +08:00
038e3c3cb3 chore: upload site.zip for build NWQr97MqIW 2026-07-03 20:30:27 +08:00
e7e6f4b84f fix(android): enable camera permission for WebView getUserMedia
Declare CAMERA in AndroidManifest so RustWebChromeClient can show the system permission dialog. Inject Permissions-Policy meta for camera access in the Tauri shell.
2026-07-03 20:28:27 +08:00
github-actions[bot]
e6741f7039 chore: cleanup build NhwFTosc3j 2026-07-03 12:20:58 +00:00
e21e02321b chore: upload logo.png for build NhwFTosc3j 2026-07-03 20:15:59 +08:00
dbc9d04ed4 chore: upload version.txt for build NhwFTosc3j 2026-07-03 20:15:58 +08:00
5bbe23496f chore: upload site.zip for build NhwFTosc3j 2026-07-03 20:15:57 +08:00
0c48dc8897 fix(android): cross-origin Bearer auth in Tauri shell
Inject a Tauri shell script during CI prep to disable service workers and attach Bearer tokens on cross-origin API requests. Enable Android third-party cookies in MainActivity and eval the same script from Rust on page load.
2026-07-03 20:10:45 +08:00
github-actions[bot]
6ed4ca576d chore: cleanup build LKfLydwq5n 2026-07-03 11:50:48 +00:00
acadfa2ab7 chore: upload logo.png for build LKfLydwq5n 2026-07-03 19:46:12 +08:00
e95d1ebfcf chore: upload version.txt for build LKfLydwq5n 2026-07-03 19:46:12 +08:00
c39ecad01b chore: upload site.zip for build LKfLydwq5n 2026-07-03 19:46:11 +08:00
github-actions[bot]
04cadb47f8 chore: cleanup build rlFZgIuQa1 2026-07-03 09:06:06 +00:00
07ae454661 chore: upload logo.png for build rlFZgIuQa1 2026-07-03 17:00:40 +08:00
1095e702fb chore: upload version.txt for build rlFZgIuQa1 2026-07-03 17:00:38 +08:00
7ab5157fb0 chore: upload site.zip for build rlFZgIuQa1 2026-07-03 17:00:36 +08:00
85b9210221 fix: pass app_version to CI and ship Windows NSIS installer only
Add app_version to workflow_dispatch inputs and worker dispatch payload. Collect only setup.exe from bundle/nsis, prefer installer URLs in release lookup, and use git-tag-safe job IDs.
2026-07-03 16:55:41 +08:00
e3d3eb7006 docs: add MIT LICENSE and .dev.vars.example referenced by README
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 16:55:40 +08:00
2d42c7ef71 feat: desktop build support and build list UI improvements
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 16:55:40 +08:00
112337fd9e chore: upload logo.png for build 321e_TEtP- 2026-07-03 16:34:27 +08:00
ed88c223a0 chore: upload version.txt for build 321e_TEtP- 2026-07-03 16:34:25 +08:00
5a6bacf54f chore: upload site.zip for build 321e_TEtP- 2026-07-03 16:34:24 +08:00
9575e9129c chore: upload favicon.ico for build DpGVZlP-Fq 2026-07-03 16:27:00 +08:00
40516af937 chore: upload version.txt for build DpGVZlP-Fq 2026-07-03 16:26:59 +08:00
de5e54215b chore: upload site.zip for build DpGVZlP-Fq 2026-07-03 16:26:57 +08:00
215c6d49ce chore: upload version.txt for build opbpiRjcUO 2026-07-03 16:24:15 +08:00
bf1495c105 chore: upload site.zip for build opbpiRjcUO 2026-07-03 16:24:13 +08:00
github-actions[bot]
5a18719ac8 chore: cleanup build S7RlSKReoQ 2026-07-03 07:22:21 +00:00
def91f2052 chore: upload logo.png for build S7RlSKReoQ 2026-07-03 15:17:25 +08:00
acb424ca27 chore: upload version.txt for build S7RlSKReoQ 2026-07-03 15:17:24 +08:00
4e851f2e39 chore: upload site.zip for build S7RlSKReoQ 2026-07-03 15:17:24 +08:00
github-actions[bot]
00387f2599 chore: cleanup build aLsGL-0e0O 2026-07-03 07:04:43 +00:00
20071f361d chore: upload logo.png for build aLsGL-0e0O 2026-07-03 14:59:55 +08:00
5a1571141c chore: upload version.txt for build aLsGL-0e0O 2026-07-03 14:59:54 +08:00
c14fd4a408 chore: upload site.zip for build aLsGL-0e0O 2026-07-03 14:59:53 +08:00
github-actions[bot]
f44fd932bc chore: cleanup build KwsSx9ylIh 2026-07-01 08:46:12 +00:00
480f355f19 chore: upload logo.png for build KwsSx9ylIh 2026-07-01 16:38:07 +08:00
e50ef8f482 chore: upload version.txt for build KwsSx9ylIh 2026-07-01 16:38:06 +08:00
cc13833d0b chore: upload site.zip for build KwsSx9ylIh 2026-07-01 16:38:04 +08:00
github-actions[bot]
4c5cf0ac35 chore: cleanup build bCN2ovXhxf 2026-06-08 03:30:26 +00:00
4bb6192312 chore: upload logo.png for build bCN2ovXhxf 2026-06-08 11:24:48 +08:00
99c8d86fc8 chore: upload version.txt for build bCN2ovXhxf 2026-06-08 11:24:45 +08:00
97e1fc5ee1 chore: upload site.zip for build bCN2ovXhxf 2026-06-08 11:24:43 +08:00
github-actions[bot]
afd69b75e7 chore: cleanup build ZvFfF6lz4Z 2026-06-07 06:30:28 +00:00
196a65b611 chore: upload logo.png for build ZvFfF6lz4Z 2026-06-07 14:25:31 +08:00
a6f9f4730a chore: upload version.txt for build ZvFfF6lz4Z 2026-06-07 14:25:30 +08:00
069774798c chore: upload site.zip for build ZvFfF6lz4Z 2026-06-07 14:25:29 +08:00
ac2a070c40 chore: upload version.txt for build THbyXdMiH4 2026-06-06 20:33:55 +08:00
595bdcb809 chore: upload site.zip for build THbyXdMiH4 2026-06-06 20:33:53 +08:00
github-actions[bot]
db7158fd13 chore: cleanup build 3ILcnyxRgh 2026-06-06 09:50:11 +00:00
4fa7bc0346 chore: upload logo.png for build 3ILcnyxRgh 2026-06-06 17:45:23 +08:00
71c1577bfd chore: upload version.txt for build 3ILcnyxRgh 2026-06-06 17:45:23 +08:00
07955ab48b chore: upload site.zip for build 3ILcnyxRgh 2026-06-06 17:45:21 +08:00
github-actions[bot]
2766bea112 chore: cleanup build JDGPnhOn1_ 2026-06-06 09:37:31 +00:00
eb2b6fa792 chore: upload logo.png for build JDGPnhOn1_ 2026-06-06 17:32:32 +08:00
764c053c28 chore: upload version.txt for build JDGPnhOn1_ 2026-06-06 17:32:31 +08:00
fa4e09e5f4 chore: upload site.zip for build JDGPnhOn1_ 2026-06-06 17:32:30 +08:00
github-actions[bot]
7e2a75d6c5 chore: cleanup build 5wPK-omehe 2026-06-06 07:08:43 +00:00
95a3663d07 chore: upload logo.png for build 5wPK-omehe 2026-06-06 15:04:29 +08:00
e81101e3b8 chore: upload version.txt for build 5wPK-omehe 2026-06-06 15:04:28 +08:00
be77438392 chore: upload site.zip for build 5wPK-omehe 2026-06-06 15:04:26 +08:00
8af54dde0e chore: upload version.txt for build NfzpJAOEUO 2026-06-06 15:02:45 +08:00
c6ba211081 chore: upload site.zip for build NfzpJAOEUO 2026-06-06 15:02:43 +08:00
github-actions[bot]
9422ecf1aa chore: cleanup build XLfZ3xTnXV 2026-06-06 06:25:48 +00:00
af566f22a1 chore: upload logo.png for build XLfZ3xTnXV 2026-06-06 14:20:41 +08:00
747aaf09ad chore: upload version.txt for build XLfZ3xTnXV 2026-06-06 14:20:39 +08:00
e143ae6c0f chore: upload site.zip for build XLfZ3xTnXV 2026-06-06 14:20:37 +08:00
79 changed files with 1531 additions and 62 deletions

15
.dev.vars.example Normal file
View File

@@ -0,0 +1,15 @@
# Wrangler 本地开发密钥(复制为 .dev.vars 后填写真实值)
# cp .dev.vars.example .dev.vars
# GitHub Personal Access Token权限至少包含 repo、workflow
GITHUB_TOKEN=ghp_xxx
# 用于接收构建产物的 GitHub 仓库
GITHUB_OWNER=your-github-username
GITHUB_REPO=Web2App
# 触发 workflow 的默认分支(需与仓库默认分支一致)
DEFAULT_BRANCH=main
# 单次上传 zip 的最大体积MB
MAX_UPLOAD_MB=50

41
.github/scripts/inject-tauri-shell.mjs vendored Normal file
View File

@@ -0,0 +1,41 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "../..");
const MARKER = "data-web2app-tauri-shell";
const PERMISSIONS_POLICY_MARKER = "data-web2app-permissions-policy";
const PERMISSIONS_POLICY_TAG = `<meta ${PERMISSIONS_POLICY_MARKER} http-equiv="Permissions-Policy" content="camera=*, microphone=*" />`;
const scriptPath = path.join(root, "template", "scripts", "tauri-shell.js");
export function injectTauriShell(distDir) {
const indexPath = path.join(distDir, "index.html");
if (!fs.existsSync(indexPath)) return;
let html = fs.readFileSync(indexPath, "utf8");
const needsPolicy = !html.includes(PERMISSIONS_POLICY_MARKER);
const needsScript = !html.includes(MARKER);
if (!needsPolicy && !needsScript) return;
const injections = [];
if (needsPolicy) injections.push(PERMISSIONS_POLICY_TAG);
if (needsScript) {
const script = fs.readFileSync(scriptPath, "utf8").trim();
injections.push(`<script ${MARKER}>${script}</script>`);
}
const block = `${injections.join("\n")}\n`;
if (html.includes("<head>")) {
html = html.replace("<head>", `<head>\n${block}`);
} else if (html.includes("</head>")) {
html = html.replace("</head>", `${block}</head>`);
} else if (html.includes("</body>")) {
html = html.replace("</body>", `${block}</body>`);
} else {
html += `\n${block}`;
}
fs.writeFileSync(indexPath, html);
console.log("Injected Tauri shell script into index.html");
}

View File

@@ -32,6 +32,8 @@ function patchMainActivity(filePath) {
const content = `package ${pkg} const content = `package ${pkg}
import android.os.Bundle import android.os.Bundle
import android.webkit.CookieManager
import android.webkit.WebView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
class MainActivity : TauriActivity() { class MainActivity : TauriActivity() {
@@ -39,6 +41,13 @@ class MainActivity : TauriActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// 内容不延伸到状态栏 / 导航栏区域 // 内容不延伸到状态栏 / 导航栏区域
WindowCompat.setDecorFitsSystemWindows(window, true) WindowCompat.setDecorFitsSystemWindows(window, true)
CookieManager.getInstance().setAcceptCookie(true)
}
override fun onWebViewCreate(webView: WebView) {
super.onWebViewCreate(webView)
// Android WebView 默认拦截跨站 Cookie桌面壳请求远程 API 时需放行
CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)
} }
} }
`; `;
@@ -127,16 +136,34 @@ function ensureNetworkSecurityXml(androidRoot) {
console.log(`Wrote ${path.relative(root, target)}`); console.log(`Wrote ${path.relative(root, target)}`);
} }
const ANDROID_PERMISSIONS = [
"android.permission.INTERNET",
"android.permission.CAMERA",
];
function ensureUsesPermission(xml, permission) {
if (xml.includes(permission)) return xml;
const tag = ` <uses-permission android:name="${permission}" />\n`;
if (xml.includes("<application")) {
return xml.replace(/<application/, `${tag}\n <application`);
}
return xml.replace(/<\/manifest>/, `${tag}</manifest>`);
}
function patchAndroidManifest(androidRoot) { function patchAndroidManifest(androidRoot) {
for (const file of walk(androidRoot, (p) => p.endsWith("AndroidManifest.xml"))) { for (const file of walk(androidRoot, (p) => p.endsWith("AndroidManifest.xml"))) {
const normalized = file.replace(/\\/g, "/"); const normalized = file.replace(/\\/g, "/");
if (!normalized.includes("/src/main/")) continue; if (!normalized.includes("/src/main/")) continue;
let xml = fs.readFileSync(file, "utf8"); let xml = fs.readFileSync(file, "utf8");
if (!xml.includes("android.permission.INTERNET")) { for (const permission of ANDROID_PERMISSIONS) {
xml = ensureUsesPermission(xml, permission);
}
if (!xml.includes("android.hardware.camera")) {
xml = xml.replace( xml = xml.replace(
/<application/, /<application/,
' <uses-permission android:name="android.permission.INTERNET" />\n\n <application', ' <uses-feature android:name="android.hardware.camera" android:required="false" />\n\n <application',
); );
} }
@@ -197,4 +224,4 @@ ensureNetworkSecurityXml(androidRoot);
patchAndroidManifest(androidRoot); patchAndroidManifest(androidRoot);
patchGradleCleartext(androidRoot); patchGradleCleartext(androidRoot);
console.log("Android app patches applied (system bars + network)"); console.log("Android app patches applied (system bars + network + camera)");

View File

@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { BUNDLE_ICONS, generateAppIcons } from "./generate-app-icons.mjs"; import { BUNDLE_ICONS, generateAppIcons } from "./generate-app-icons.mjs";
import { injectInAppNav } from "./inject-in-app-nav.mjs"; import { injectInAppNav } from "./inject-in-app-nav.mjs";
import { injectTauriShell } from "./inject-tauri-shell.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "../.."); const root = path.resolve(__dirname, "../..");
@@ -109,6 +110,7 @@ function patchTauriConfig(filePath) {
fs.writeFileSync(filePath, `${JSON.stringify(conf, null, 2)}\n`); fs.writeFileSync(filePath, `${JSON.stringify(conf, null, 2)}\n`);
} }
injectTauriShell(distDir);
injectInAppNav(distDir); injectInAppNav(distDir);
patchTauriConfig(confPath); patchTauriConfig(confPath);

View File

@@ -19,6 +19,10 @@ on:
description: Application bundle identifier description: Application bundle identifier
required: true required: true
type: string type: string
app_version:
description: Application version string
required: true
type: string
permissions: permissions:
contents: write contents: write
@@ -67,14 +71,17 @@ jobs:
shell: pwsh shell: pwsh
run: | run: |
New-Item -ItemType Directory -Force -Path artifacts/windows | Out-Null New-Item -ItemType Directory -Force -Path artifacts/windows | Out-Null
$bundleRoot = "template/src-tauri/target/release/bundle" $nsisDir = "template/src-tauri/target/release/bundle/nsis"
if (Test-Path $bundleRoot) { if (-not (Test-Path $nsisDir)) {
Get-ChildItem -Path $bundleRoot -Recurse -Include *setup.exe,*.exe,*.msi -ErrorAction SilentlyContinue | Write-Error "NSIS bundle directory not found: $nsisDir"
Copy-Item -Destination artifacts/windows -Force exit 1
} }
Get-ChildItem -Path "template/src-tauri/target/release" -Filter "*.exe" -ErrorAction SilentlyContinue | $installers = Get-ChildItem -Path $nsisDir -Filter "*-setup.exe"
Where-Object { $_.Name -notmatch "wix|candle|light" } | if (-not $installers) {
Copy-Item -Destination artifacts/windows -Force Write-Error "No NSIS installer (*-setup.exe) found in $nsisDir"
exit 1
}
$installers | Copy-Item -Destination artifacts/windows -Force
Get-ChildItem artifacts/windows Get-ChildItem artifacts/windows
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
@@ -212,7 +219,7 @@ jobs:
body: | body: |
Automated build for **${{ inputs.app_name }}** (English: `${{ inputs.app_name_en }}`, version: `${{ inputs.app_version }}`, ID: `${{ inputs.app_identifier }}`). Automated build for **${{ inputs.app_name }}** (English: `${{ inputs.app_name_en }}`, version: `${{ inputs.app_version }}`, ID: `${{ inputs.app_identifier }}`).
- Windows desktop installer/portable binary - Windows NSIS installer (*-setup.exe)
- Android APK (arm64-v8a, signed for sideload install) - Android APK (arm64-v8a, signed for sideload install)
draft: false draft: false
prerelease: true prerelease: true

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
node_modules/ node_modules/
frontend/dist/ frontend/dist/
!frontend/dist/index.html !frontend/dist/index.html
frontend/dist-desktop/
frontend/.env.desktop
uploads/ uploads/
data/ data/
.env .env

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 shumengya
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

BIN
builds/321e_TEtP-/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
builds/321e_TEtP-/site.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
2026.7.3

BIN
builds/3ILcnyxRgh/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1 @@
2026.6.6

BIN
builds/5wPK-omehe/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1 @@
2026.6.6

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
builds/DpGVZlP-Fq/site.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
2026.7.3

BIN
builds/JDGPnhOn1_/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1 @@
2026.6.6

BIN
builds/KwsSx9ylIh/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1 @@
2026.7.1

BIN
builds/LKfLydwq5n/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1 @@
2026.7.3

BIN
builds/NWQr97MqIW/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1 @@
2026.7.3

BIN
builds/NfzpJAOEUO/site.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
2026.6.6

BIN
builds/NhwFTosc3j/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1 @@
2026.7.3

BIN
builds/R2ppnODBNe/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

View File

@@ -0,0 +1 @@
2026.7.4

BIN
builds/S7RlSKReoQ/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1 @@
2026.7.3

BIN
builds/THbyXdMiH4/site.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
2026.6.6

BIN
builds/TvE7ui81pG/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1 @@
2026.7.3

BIN
builds/X4BTsYv3O2/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -0,0 +1 @@
2026.7.18

BIN
builds/X883begTyJ/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1 @@
2026.7.12

BIN
builds/XLfZ3xTnXV/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -0,0 +1 @@
2026.6.6

BIN
builds/ZvFfF6lz4Z/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1 @@
2026.6.7

BIN
builds/aLsGL-0e0O/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1 @@
2026.7.3

BIN
builds/bCN2ovXhxf/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

View File

@@ -0,0 +1 @@
2026.6.8

BIN
builds/opbpiRjcUO/site.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
2026.7.3

BIN
builds/rlFZgIuQa1/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -0,0 +1 @@
2026.7.3

View File

@@ -4,6 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web2App</title> <title>Web2App</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png" />
<link rel="apple-touch-icon" href="/logo-192.png" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build && vite build --mode desktop",
"build:desktop": "vite build --mode desktop",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

71
frontend/public/logo.svg Normal file
View File

@@ -0,0 +1,71 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e40af"/>
<stop offset="100%" stop-color="#3b82f6"/>
</linearGradient>
<linearGradient id="glow" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.18"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="512" height="512" rx="100" fill="url(#bg)"/>
<!-- Subtle top sheen -->
<rect width="512" height="256" rx="100" fill="url(#glow)"/>
<!-- ===== LEFT: Browser window ===== -->
<rect x="44" y="155" width="192" height="148" rx="14"
fill="white" fill-opacity="0.12" stroke="white" stroke-width="11" stroke-opacity="0.9"/>
<!-- Title bar stripe -->
<rect x="44" y="155" width="192" height="40" rx="14" fill="white" fill-opacity="0.18"/>
<rect x="44" y="181" width="192" height="14" fill="white" fill-opacity="0.18"/>
<!-- Traffic lights -->
<circle cx="70" cy="175" r="7" fill="#ef4444" fill-opacity="0.85"/>
<circle cx="92" cy="175" r="7" fill="#f59e0b" fill-opacity="0.85"/>
<circle cx="114" cy="175" r="7" fill="#22c55e" fill-opacity="0.85"/>
<!-- URL bar -->
<rect x="130" y="165" width="90" height="18" rx="9" fill="white" fill-opacity="0.28"/>
<!-- Page content lines -->
<rect x="64" y="212" width="152" height="10" rx="5" fill="white" fill-opacity="0.55"/>
<rect x="64" y="232" width="118" height="10" rx="5" fill="white" fill-opacity="0.38"/>
<rect x="64" y="252" width="134" height="10" rx="5" fill="white" fill-opacity="0.38"/>
<rect x="64" y="272" width="96" height="10" rx="5" fill="white" fill-opacity="0.25"/>
<!-- ===== MIDDLE: Conversion arrow ===== -->
<!-- Shaft -->
<line x1="256" y1="256" x2="302" y2="256"
stroke="white" stroke-width="14" stroke-linecap="round" stroke-opacity="0.95"/>
<!-- Arrowhead -->
<polyline points="284,236 308,256 284,276"
fill="none" stroke="white" stroke-width="14"
stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.95"/>
<!-- Sparkle particles -->
<circle cx="264" cy="222" r="5.5" fill="white" fill-opacity="0.72"/>
<circle cx="280" cy="212" r="3.5" fill="white" fill-opacity="0.50"/>
<circle cx="298" cy="218" r="2.5" fill="white" fill-opacity="0.38"/>
<circle cx="268" cy="296" r="4.5" fill="white" fill-opacity="0.62"/>
<circle cx="258" cy="308" r="3" fill="white" fill-opacity="0.42"/>
<circle cx="292" cy="302" r="2.5" fill="white" fill-opacity="0.32"/>
<!-- ===== RIGHT: Phone ===== -->
<rect x="324" y="158" width="130" height="214" rx="24"
fill="white" fill-opacity="0.12" stroke="white" stroke-width="11" stroke-opacity="0.9"/>
<!-- Punch-hole camera -->
<circle cx="389" cy="175" r="7" fill="white" fill-opacity="0.45"/>
<!-- Screen area -->
<rect x="340" y="192" width="98" height="156" rx="7" fill="white" fill-opacity="0.10"/>
<!-- App icon grid (3×2) -->
<rect x="348" y="202" width="26" height="26" rx="7" fill="white" fill-opacity="0.50"/>
<rect x="382" y="202" width="26" height="26" rx="7" fill="white" fill-opacity="0.50"/>
<rect x="416" y="202" width="26" height="26" rx="7" fill="white" fill-opacity="0.38"/>
<rect x="348" y="236" width="26" height="26" rx="7" fill="white" fill-opacity="0.50"/>
<rect x="382" y="236" width="26" height="26" rx="7" fill="white" fill-opacity="0.38"/>
<rect x="416" y="236" width="26" height="26" rx="7" fill="white" fill-opacity="0.28"/>
<!-- Bottom mini-bar rows -->
<rect x="348" y="272" width="94" height="8" rx="4" fill="white" fill-opacity="0.25"/>
<rect x="348" y="288" width="72" height="8" rx="4" fill="white" fill-opacity="0.18"/>
<!-- Home indicator bar -->
<rect x="361" y="356" width="56" height="6" rx="3" fill="white" fill-opacity="0.65"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,11 +1,15 @@
import { Link, Outlet } from "react-router-dom"; import { Link, Outlet, useLocation } from "react-router-dom";
export default function Layout() { export default function Layout() {
const { pathname } = useLocation();
const isBuilds = pathname === "/jobs";
return ( return (
<div className="app-shell"> <div className="app-shell">
<header className="site-header"> <header className="site-header">
<div className="site-header-inner"> <div className="site-header-inner">
<Link to="/" className="site-brand"> <Link to="/" className="site-brand">
<img src="/logo.svg" alt="" className="site-logo" />
Web2App Web2App
</Link> </Link>
<nav className="site-nav"> <nav className="site-nav">
@@ -15,7 +19,7 @@ export default function Layout() {
</div> </div>
</header> </header>
<main className="site-main"> <main className="site-main">
<div className="content"> <div className={isBuilds ? "content content-wide" : "content"}>
<Outlet /> <Outlet />
</div> </div>
</main> </main>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { fetchBuilds, statusLabel, type Build } from "../api/client"; import { fetchBuilds, statusLabel, type Build } from "../api/client";
import { getBuildDuration, isBuildFinished } from "../lib/duration"; import { getBuildDuration, isBuildFinished } from "../lib/duration";
@@ -6,6 +6,8 @@ import { getBuildDuration, isBuildFinished } from "../lib/duration";
export default function BuildList() { export default function BuildList() {
const [builds, setBuilds] = useState<Build[]>([]); const [builds, setBuilds] = useState<Build[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedPkg, setSelectedPkg] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => { useEffect(() => {
fetchBuilds() fetchBuilds()
@@ -15,35 +17,136 @@ export default function BuildList() {
); );
}, []); }, []);
return ( const packages = useMemo(() => {
<> const map = new Map<
<h1 className="page-title"></h1> string,
<p className="page-lead"> 20 </p> { appName: string; latestAt: string; count: number }
>();
for (const b of builds) {
const existing = map.get(b.appIdentifier);
if (!existing) {
map.set(b.appIdentifier, {
appName: b.appName,
latestAt: b.createdAt,
count: 1,
});
} else {
existing.count++;
if (b.createdAt > existing.latestAt) {
existing.latestAt = b.createdAt;
existing.appName = b.appName;
}
}
}
return [...map.entries()]
.sort((a, b) => b[1].latestAt.localeCompare(a[1].latestAt))
.map(([appIdentifier, { appName, count }]) => ({
appIdentifier,
appName,
count,
}));
}, [builds]);
<section className="doc-section"> const filtered = selectedPkg
{error ? <p className="error-text">{error}</p> : null} ? builds.filter((b) => b.appIdentifier === selectedPkg)
{!error && builds.length === 0 ? ( : builds;
<p className="prose"></p>
) : null} function selectPkg(pkg: string | null) {
{builds.length > 0 ? ( setSelectedPkg(pkg);
<ul className="build-list"> setSidebarOpen(false);
{builds.map((build) => ( }
<li key={build.id}>
<Link to={`/jobs/${build.id}`}> return (
{build.appName} ({build.appNameEn}) <div className="builds-layout">
</Link> {sidebarOpen && (
<div className="meta-line"> <div
v{build.appVersion} · {statusLabel(build.status)} className="sidebar-overlay"
{isBuildFinished(build.status) onClick={() => setSidebarOpen(false)}
? ` · 耗时 ${getBuildDuration(build).text}` />
: null}{" "} )}
· {new Date(build.createdAt).toLocaleString()}
</div> <aside className={`builds-sidebar${sidebarOpen ? " sidebar-open" : ""}`}>
</li> <div className="sidebar-header">
))} <span className="sidebar-title"></span>
</ul> <button
) : null} className="sidebar-close"
</section> onClick={() => setSidebarOpen(false)}
</> aria-label="关闭侧边栏"
>
</button>
</div>
<ul className="pkg-list">
<li>
<button
className={`pkg-item${!selectedPkg ? " pkg-item-active" : ""}`}
onClick={() => selectPkg(null)}
>
<span className="pkg-info">
<span className="pkg-name"></span>
</span>
<span className="pkg-count">{builds.length}</span>
</button>
</li>
{packages.map(({ appIdentifier, appName, count }) => (
<li key={appIdentifier}>
<button
className={`pkg-item${selectedPkg === appIdentifier ? " pkg-item-active" : ""}`}
onClick={() => selectPkg(appIdentifier)}
>
<span className="pkg-info">
<span className="pkg-name">{appName}</span>
<span className="pkg-en">{appIdentifier}</span>
</span>
<span className="pkg-count">{count}</span>
</button>
</li>
))}
</ul>
</aside>
<div className="builds-main">
<div className="builds-topbar">
<button
className="sidebar-toggle"
onClick={() => setSidebarOpen(true)}
aria-label="打开包名筛选"
>
</button>
<h1 className="page-title"></h1>
</div>
<p className="page-lead">
{selectedPkg
? `${selectedPkg} 的构建记录`
: "最近 20 条构建任务。"}
</p>
<section className="doc-section">
{error ? <p className="error-text">{error}</p> : null}
{!error && filtered.length === 0 ? (
<p className="prose"></p>
) : null}
{filtered.length > 0 ? (
<ul className="build-list">
{filtered.map((build) => (
<li key={build.id}>
<Link to={`/jobs/${build.id}`}>
{build.appName} ({build.appNameEn})
</Link>
<div className="meta-line">
v{build.appVersion} · {statusLabel(build.status)}
{isBuildFinished(build.status)
? ` · 耗时 ${getBuildDuration(build).text}`
: null}{" "}
· {new Date(build.createdAt).toLocaleString()}
</div>
</li>
))}
</ul>
) : null}
</section>
</div>
</div>
); );
} }

View File

@@ -74,6 +74,9 @@ a:hover {
font-size: 1.125rem; font-size: 1.125rem;
color: var(--text); color: var(--text);
text-decoration: none; text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
} }
.site-brand:hover { .site-brand:hover {
@@ -81,6 +84,13 @@ a:hover {
color: var(--accent); color: var(--accent);
} }
.site-logo {
width: 28px;
height: 28px;
border-radius: 6px;
flex-shrink: 0;
}
.site-nav { .site-nav {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@@ -97,6 +107,10 @@ a:hover {
margin: 0 auto; margin: 0 auto;
} }
.content-wide {
max-width: 64rem;
}
.page-title { .page-title {
margin: 0 0 0.25rem; margin: 0 0 0.25rem;
font-size: 1.75rem; font-size: 1.75rem;
@@ -355,6 +369,209 @@ a:hover {
color: var(--muted); color: var(--muted);
} }
/* ===== Builds page layout ===== */
.builds-layout {
display: flex;
gap: 1.25rem;
align-items: flex-start;
}
.builds-sidebar {
width: 190px;
flex-shrink: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0;
position: sticky;
top: 1rem;
}
.sidebar-header {
display: none;
}
.sidebar-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sidebar-close {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
padding: 0.25rem;
color: var(--muted);
line-height: 1;
}
.sidebar-close:hover {
color: var(--text);
}
.pkg-list {
list-style: none;
margin: 0;
padding: 0;
}
.pkg-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.875rem;
text-align: left;
background: none;
border: none;
cursor: pointer;
font: inherit;
font-size: 0.875rem;
color: var(--text);
border-radius: 0;
transition: background 0.1s;
}
.pkg-item:hover {
background: var(--bg);
}
.pkg-item-active {
color: var(--accent);
font-weight: 600;
background: #eff6ff;
}
.pkg-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.pkg-name {
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pkg-en {
font-size: 0.75rem;
color: var(--muted);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pkg-item-active .pkg-en {
color: #93c5fd;
}
.pkg-count {
flex-shrink: 0;
background: #e5e7eb;
color: #6b7280;
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.4rem;
border-radius: 10px;
min-width: 1.375rem;
text-align: center;
line-height: 1.5;
}
.pkg-item-active .pkg-count {
background: #bfdbfe;
color: var(--accent);
}
.builds-main {
flex: 1;
min-width: 0;
}
.builds-topbar {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.25rem;
}
.sidebar-toggle {
display: none;
}
.sidebar-overlay {
display: none;
}
@media (max-width: 767px) {
.builds-layout {
display: block;
}
.sidebar-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
flex-shrink: 0;
padding: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
font-size: 1.125rem;
color: var(--text);
}
.builds-sidebar {
position: fixed;
top: 0;
left: 0;
width: 280px;
max-width: 85vw;
height: 100vh;
z-index: 200;
overflow-y: auto;
transform: translateX(-100%);
transition: transform 0.25s ease;
border-radius: 0;
border: none;
border-right: 1px solid var(--border);
padding-top: 0;
}
.builds-sidebar.sidebar-open {
transform: translateX(0);
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--border);
margin-bottom: 0.25rem;
}
.sidebar-overlay {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 199;
}
}
@media (min-width: 768px) { @media (min-width: 768px) {
:root { :root {
--page-pad: 1.5rem; --page-pad: 1.5rem;

View File

@@ -1,7 +1,7 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig(({ mode }) => ({
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173, port: 5173,
@@ -13,7 +13,7 @@ export default defineConfig({
}, },
}, },
build: { build: {
outDir: "dist", outDir: mode === "desktop" ? "dist-desktop" : "dist",
emptyOutDir: true, emptyOutDir: true,
}, },
}); }));

View File

@@ -11,6 +11,7 @@
"scripts": { "scripts": {
"dev": "concurrently -n web,worker -c green,blue \"npm run dev -w frontend\" \"wrangler dev\"", "dev": "concurrently -n web,worker -c green,blue \"npm run dev -w frontend\" \"wrangler dev\"",
"build": "npm run build -w frontend", "build": "npm run build -w frontend",
"build:desktop": "npm run build:desktop -w frontend",
"deploy": "npm run build && wrangler deploy", "deploy": "npm run build && wrangler deploy",
"db:migrate:local": "wrangler d1 migrations apply web2app --local", "db:migrate:local": "wrangler d1 migrations apply web2app --local",
"db:migrate:remote": "wrangler d1 migrations apply web2app --remote" "db:migrate:remote": "wrangler d1 migrations apply web2app --remote"

View File

@@ -0,0 +1,369 @@
(function () {
if (window.__web2appShellInstalled) return;
window.__web2appShellInstalled = true;
function isTauriShell() {
var protocol = location.protocol;
var host = location.hostname;
return (
protocol === "tauri:" ||
protocol === "file:" ||
host === "asset.localhost" ||
host === "tauri.localhost"
);
}
if (!isTauriShell()) return;
var TOKEN_KEYS = ["sprout2fa_session", "__web2app_bearer_token"];
/**
* Bodies larger than this stay on the browser fetch path (needs server CORS
* for https://tauri.localhost). Keep high enough for typical R2 uploads.
*/
var PROXY_BODY_LIMIT = 64 * 1024 * 1024;
/** Soft warn threshold for huge proxied responses (base64 over IPC). */
var PROXY_RESPONSE_LIMIT = 64 * 1024 * 1024;
function readToken() {
try {
for (var i = 0; i < TOKEN_KEYS.length; i++) {
var value = localStorage.getItem(TOKEN_KEYS[i]);
if (value) return value;
}
} catch (_) {
/* ignore */
}
return null;
}
function writeToken(token) {
if (!token) return;
try {
for (var i = 0; i < TOKEN_KEYS.length; i++) {
localStorage.setItem(TOKEN_KEYS[i], token);
}
} catch (_) {
/* ignore */
}
}
function clearToken() {
try {
for (var i = 0; i < TOKEN_KEYS.length; i++) {
localStorage.removeItem(TOKEN_KEYS[i]);
}
} catch (_) {
/* ignore */
}
}
if ("serviceWorker" in navigator) {
navigator.serviceWorker.getRegistrations().then(function (regs) {
for (var i = 0; i < regs.length; i++) regs[i].unregister();
});
navigator.serviceWorker.register = function () {
return Promise.resolve({
active: null,
installing: null,
waiting: null,
unregister: function () {
return Promise.resolve(true);
},
update: function () {
return Promise.resolve();
},
});
};
}
function getInvoke() {
try {
var core = window.__TAURI__ && window.__TAURI__.core;
if (core && typeof core.invoke === "function") {
return core.invoke.bind(core);
}
} catch (_) {
/* ignore */
}
return null;
}
function resolveUrl(input) {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
if (input && typeof input.url === "string") return input.url;
return "";
}
function resolveMethod(input, init) {
if (init && init.method) return String(init.method).toUpperCase();
if (input instanceof Request) return input.method.toUpperCase();
return "GET";
}
/**
* Collect headers without inventing an empty Headers that would later
* overwrite a signed Request (aws4fetch passes fetch(Request) only).
*/
function collectHeaders(input, init) {
var headers = new Headers();
if (input instanceof Request) {
input.headers.forEach(function (value, key) {
headers.set(key, value);
});
}
if (init && init.headers) {
new Headers(init.headers).forEach(function (value, key) {
headers.set(key, value);
});
}
return headers;
}
function headersToObject(headers) {
var out = {};
headers.forEach(function (value, key) {
// Host is set by the real client; hop-by-hop headers are stripped natively.
if (key.toLowerCase() === "host") return;
out[key] = value;
});
return out;
}
function arrayBufferToBase64(buffer) {
var bytes = new Uint8Array(buffer);
var binary = "";
var chunk = 0x8000;
for (var i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + chunk),
);
}
return btoa(binary);
}
function base64ToUint8Array(b64) {
var binary = atob(b64 || "");
var len = binary.length;
var bytes = new Uint8Array(len);
for (var i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function bodyToArrayBuffer(input, init) {
if (init && init.body != null) {
var body = init.body;
if (body === "") return Promise.resolve(null);
if (typeof body === "string") {
return Promise.resolve(new TextEncoder().encode(body).buffer);
}
if (body instanceof ArrayBuffer) return Promise.resolve(body);
if (ArrayBuffer.isView(body)) {
return Promise.resolve(
body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength),
);
}
if (typeof Blob !== "undefined" && body instanceof Blob) {
return body.arrayBuffer();
}
if (
typeof URLSearchParams !== "undefined" &&
body instanceof URLSearchParams
) {
return Promise.resolve(new TextEncoder().encode(body.toString()).buffer);
}
if (typeof FormData !== "undefined" && body instanceof FormData) {
// FormData needs multipart framing; fall back to browser fetch.
return Promise.reject(new Error("FORMDATA_UNSUPPORTED"));
}
if (
typeof ReadableStream !== "undefined" &&
body instanceof ReadableStream
) {
return new Response(body).arrayBuffer();
}
return Promise.resolve(null);
}
if (input instanceof Request) {
// Clone so we do not lock the original Request body stream.
return input
.clone()
.arrayBuffer()
.then(function (buf) {
return buf && buf.byteLength > 0 ? buf : null;
})
.catch(function () {
return null;
});
}
return Promise.resolve(null);
}
function maybeInjectToken(headers) {
if (headers.has("Authorization")) return;
var token = readToken();
if (token) headers.set("Authorization", "Bearer " + token);
}
function trackAuthResponse(requestUrl, response) {
if (!response || !response.ok) return response;
var path = requestUrl.pathname.replace(/\/$/, "");
if (/\/auth\/login$/i.test(path)) {
response
.clone()
.json()
.then(function (data) {
if (data && typeof data.token === "string" && data.token) {
writeToken(data.token);
}
})
.catch(function () {
/* ignore */
});
}
if (/\/auth\/logout$/i.test(path)) {
clearToken();
}
return response;
}
function callOriginalFetch(input, init) {
if (init === undefined) return originalFetch.call(window, input);
return originalFetch.call(window, input, init);
}
/**
* Proxy cross-origin HTTP(S) through Rust so WebView CORS never applies.
* Origin of packaged apps is https://tauri.localhost (etc.), which remote
* APIs (R2/S3) usually do not list — even when AllowedOrigins is ["*"],
* signed preflights and some WebView builds still fail.
*/
function proxyFetch(input, init) {
var invoke = getInvoke();
if (!invoke) return null;
var url = resolveUrl(input);
var method = resolveMethod(input, init);
var headers = collectHeaders(input, init);
maybeInjectToken(headers);
return bodyToArrayBuffer(input, init)
.then(function (buf) {
if (buf && buf.byteLength > PROXY_BODY_LIMIT) {
// Large upload: browser path (needs R2 CORS for tauri.localhost).
return callOriginalFetch(url, {
method: method,
headers: headers,
body: buf,
});
}
var bodyBase64 =
buf && buf.byteLength > 0 ? arrayBufferToBase64(buf) : null;
return invoke("proxy_fetch", {
method: method,
url: url,
headers: headersToObject(headers),
bodyBase64: bodyBase64,
}).then(function (result) {
if (!result || typeof result.status !== "number") {
throw new TypeError("Failed to fetch");
}
if (
result.bodyBase64 &&
result.bodyBase64.length * 0.75 > PROXY_RESPONSE_LIMIT
) {
console.warn(
"[web2app] proxied response is very large; consider presigned URLs",
);
}
var bytes = base64ToUint8Array(result.bodyBase64 || "");
var responseHeaders = new Headers();
var pairs = result.headers || [];
for (var i = 0; i < pairs.length; i++) {
var pair = pairs[i];
if (pair && pair.length >= 2) {
try {
responseHeaders.append(pair[0], pair[1]);
} catch (_) {
/* invalid header name/value */
}
}
}
return new Response(bytes, {
status: result.status,
statusText: result.statusText || "",
headers: responseHeaders,
});
});
})
.catch(function (err) {
if (err && err.message === "FORMDATA_UNSUPPORTED") {
return callOriginalFetch(url, {
method: method,
headers: headers,
body: init && init.body,
});
}
throw err;
});
}
var originalFetch = window.fetch;
window.fetch = function (input, init) {
var url = resolveUrl(input);
var requestUrl;
try {
requestUrl = new URL(url, location.href);
} catch (_) {
return callOriginalFetch(input, init);
}
var crossOrigin = requestUrl.origin !== location.origin;
var isHttp =
requestUrl.protocol === "http:" || requestUrl.protocol === "https:";
// Same-origin / non-HTTP: never rewrite (preserves Request signatures).
if (!crossOrigin || !isHttp) {
return callOriginalFetch(input, init);
}
var proxied = proxyFetch(input, init);
if (proxied) {
return proxied.then(function (response) {
return trackAuthResponse(requestUrl, response);
});
}
// Tauri IPC not ready yet: pass through without clobbering Request headers.
// Only add a bearer token when Authorization is absent.
var headers = collectHeaders(input, init);
var hadAuth = headers.has("Authorization");
maybeInjectToken(headers);
if (!hadAuth && headers.has("Authorization")) {
var passInit = Object.assign({}, init || {}, {
method: resolveMethod(input, init),
headers: headers,
});
if (passInit.body == null && input instanceof Request) {
// Keep original Request if we only needed headers from it and body unset.
return callOriginalFetch(input.url, Object.assign({}, passInit, {
body: undefined,
})).then(function (response) {
return trackAuthResponse(requestUrl, response);
});
}
return callOriginalFetch(requestUrl.href, passInit).then(function (
response,
) {
return trackAuthResponse(requestUrl, response);
});
}
return callOriginalFetch(input, init).then(function (response) {
return trackAuthResponse(requestUrl, response);
});
};
})();

View File

@@ -47,6 +47,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "async-compression"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "atk" name = "atk"
version = "0.18.2" version = "0.18.2"
@@ -309,6 +321,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@@ -331,6 +360,24 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "compression-codecs"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
[[package]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.1" version = "0.18.1"
@@ -390,6 +437,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -1001,8 +1057,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1024,10 +1082,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core",
"wasip2", "wasip2",
"wasip3", "wasip3",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1286,6 +1347,22 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@@ -1699,6 +1776,12 @@ version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.38.0" version = "0.38.0"
@@ -2307,6 +2390,62 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.4.2",
"lru-slab",
"rand",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -2328,6 +2467,32 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core",
]
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -2403,6 +2568,44 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.4" version = "0.13.4"
@@ -2437,6 +2640,20 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -2452,12 +2669,53 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustls"
version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -2648,6 +2906,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.20.0" version = "3.20.0"
@@ -2718,7 +2988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest",
] ]
@@ -2846,6 +3116,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -2998,7 +3274,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -3301,6 +3577,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@@ -3455,12 +3741,17 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [ dependencies = [
"async-compression",
"bitflags 2.11.1", "bitflags 2.11.1",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@@ -3597,6 +3888,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -3833,10 +4130,22 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "web2app-template" name = "web2app-template"
version = "1.0.0" version = "1.0.0"
dependencies = [ dependencies = [
"base64 0.22.1",
"reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -3899,6 +4208,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -4129,6 +4447,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -4529,6 +4856,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" version = "0.2.4"

View File

@@ -16,6 +16,9 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "brotli", "deflate"] }
base64 = "0.22"
[profile.release] [profile.release]
codegen-units = 1 codegen-units = 1

View File

@@ -3,5 +3,5 @@
"identifier": "default", "identifier": "default",
"description": "Default capability for the main window", "description": "Default capability for the main window",
"windows": ["main"], "windows": ["main"],
"permissions": ["core:default"] "permissions": ["core:default", "allow-proxy-fetch"]
} }

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capability for the main window","local":true,"windows":["main"],"permissions":["core:default"]}} {"default":{"identifier":"default","description":"Default capability for the main window","local":true,"windows":["main"],"permissions":["core:default","allow-proxy-fetch"]}}

View File

@@ -176,6 +176,12 @@
"Identifier": { "Identifier": {
"description": "Permission identifier", "description": "Permission identifier",
"oneOf": [ "oneOf": [
{
"description": "Allow the app webview to perform native HTTP requests (CORS bypass for packaged sites).",
"type": "string",
"const": "allow-proxy-fetch",
"markdownDescription": "Allow the app webview to perform native HTTP requests (CORS bypass for packaged sites)."
},
{ {
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string", "type": "string",

View File

@@ -176,6 +176,12 @@
"Identifier": { "Identifier": {
"description": "Permission identifier", "description": "Permission identifier",
"oneOf": [ "oneOf": [
{
"description": "Allow the app webview to perform native HTTP requests (CORS bypass for packaged sites).",
"type": "string",
"const": "allow-proxy-fetch",
"markdownDescription": "Allow the app webview to perform native HTTP requests (CORS bypass for packaged sites)."
},
{ {
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string", "type": "string",

View File

@@ -0,0 +1,4 @@
[[permission]]
identifier = "allow-proxy-fetch"
description = "Allow the app webview to perform native HTTP requests (CORS bypass for packaged sites)."
commands.allow = ["proxy_fetch"]

View File

@@ -1,5 +1,10 @@
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use serde::Serialize;
use std::collections::HashMap;
use tauri::Manager; use tauri::Manager;
const TAURI_SHELL_SCRIPT: &str = include_str!("../../scripts/tauri-shell.js");
const IN_APP_NAV_SCRIPT: &str = r##" const IN_APP_NAV_SCRIPT: &str = r##"
(function () { (function () {
if (window.__web2appNavInstalled) return; if (window.__web2appNavInstalled) return;
@@ -63,17 +68,125 @@ const IN_APP_NAV_SCRIPT: &str = r##"
})(); })();
"##; "##;
/// Hop-by-hop / forbidden request headers (RFC 7230 / fetch).
const STRIP_REQUEST_HEADERS: &[&str] = &[
"accept-encoding",
"connection",
"content-length",
"host",
"keep-alive",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyFetchResponse {
status: u16,
status_text: String,
headers: Vec<(String, String)>,
body_base64: String,
}
/// Native HTTP client used to bypass WebView CORS for packaged apps.
/// Front-end origin is typically `https://tauri.localhost`, which remote APIs
/// (Cloudflare R2, S3, custom backends) rarely allow even with `AllowedOrigins: ["*"]`.
#[tauri::command]
async fn proxy_fetch(
method: String,
url: String,
headers: HashMap<String, String>,
body_base64: Option<String>,
) -> Result<ProxyFetchResponse, String> {
let method = method.trim().to_uppercase();
if method.is_empty() {
return Err("method is required".into());
}
let url = url.trim();
if url.is_empty() {
return Err("url is required".into());
}
let parsed = reqwest::Url::parse(url).map_err(|e| format!("invalid url: {e}"))?;
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return Err(format!("unsupported scheme: {}", parsed.scheme()));
}
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::limited(10))
.build()
.map_err(|e| format!("http client error: {e}"))?;
let http_method = reqwest::Method::from_bytes(method.as_bytes())
.map_err(|_| format!("invalid method: {method}"))?;
let mut builder = client.request(http_method, parsed);
for (name, value) in &headers {
let lower = name.to_ascii_lowercase();
if STRIP_REQUEST_HEADERS.iter().any(|h| *h == lower.as_str()) {
continue;
}
builder = builder.header(name.as_str(), value.as_str());
}
if let Some(b64) = body_base64.as_deref() {
if !b64.is_empty() {
let bytes = BASE64
.decode(b64)
.map_err(|e| format!("invalid body base64: {e}"))?;
builder = builder.body(bytes);
}
}
let response = builder
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
let status = response.status();
let status_text = status.canonical_reason().unwrap_or("").to_string();
let headers: Vec<(String, String)> = response
.headers()
.iter()
.map(|(k, v)| {
(
k.as_str().to_string(),
v.to_str().unwrap_or("").to_string(),
)
})
.collect();
let bytes = response
.bytes()
.await
.map_err(|e| format!("read body failed: {e}"))?;
Ok(ProxyFetchResponse {
status: status.as_u16(),
status_text,
headers,
body_base64: BASE64.encode(&bytes),
})
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.invoke_handler(tauri::generate_handler![proxy_fetch])
.setup(|app| { .setup(|app| {
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let _ = window.eval(TAURI_SHELL_SCRIPT);
let _ = window.eval(IN_APP_NAV_SCRIPT); let _ = window.eval(IN_APP_NAV_SCRIPT);
} }
Ok(()) Ok(())
}) })
.on_page_load(|webview, payload| { .on_page_load(|webview, payload| {
if payload.event() == tauri::webview::PageLoadEvent::Finished { if payload.event() == tauri::webview::PageLoadEvent::Finished {
let _ = webview.eval(TAURI_SHELL_SCRIPT);
let _ = webview.eval(IN_APP_NAV_SCRIPT); let _ = webview.eval(IN_APP_NAV_SCRIPT);
} }
}) })

BIN
web2app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

71
web2app-icon.svg Normal file
View File

@@ -0,0 +1,71 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e40af"/>
<stop offset="100%" stop-color="#3b82f6"/>
</linearGradient>
<linearGradient id="glow" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.18"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="512" height="512" rx="100" fill="url(#bg)"/>
<!-- Subtle top sheen -->
<rect width="512" height="256" rx="100" fill="url(#glow)"/>
<!-- ===== LEFT: Browser window ===== -->
<rect x="44" y="155" width="192" height="148" rx="14"
fill="white" fill-opacity="0.12" stroke="white" stroke-width="11" stroke-opacity="0.9"/>
<!-- Title bar stripe -->
<rect x="44" y="155" width="192" height="40" rx="14" fill="white" fill-opacity="0.18"/>
<rect x="44" y="181" width="192" height="14" fill="white" fill-opacity="0.18"/>
<!-- Traffic lights -->
<circle cx="70" cy="175" r="7" fill="#ef4444" fill-opacity="0.85"/>
<circle cx="92" cy="175" r="7" fill="#f59e0b" fill-opacity="0.85"/>
<circle cx="114" cy="175" r="7" fill="#22c55e" fill-opacity="0.85"/>
<!-- URL bar -->
<rect x="130" y="165" width="90" height="18" rx="9" fill="white" fill-opacity="0.28"/>
<!-- Page content lines -->
<rect x="64" y="212" width="152" height="10" rx="5" fill="white" fill-opacity="0.55"/>
<rect x="64" y="232" width="118" height="10" rx="5" fill="white" fill-opacity="0.38"/>
<rect x="64" y="252" width="134" height="10" rx="5" fill="white" fill-opacity="0.38"/>
<rect x="64" y="272" width="96" height="10" rx="5" fill="white" fill-opacity="0.25"/>
<!-- ===== MIDDLE: Conversion arrow ===== -->
<!-- Shaft -->
<line x1="256" y1="256" x2="302" y2="256"
stroke="white" stroke-width="14" stroke-linecap="round" stroke-opacity="0.95"/>
<!-- Arrowhead -->
<polyline points="284,236 308,256 284,276"
fill="none" stroke="white" stroke-width="14"
stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.95"/>
<!-- Sparkle particles -->
<circle cx="264" cy="222" r="5.5" fill="white" fill-opacity="0.72"/>
<circle cx="280" cy="212" r="3.5" fill="white" fill-opacity="0.50"/>
<circle cx="298" cy="218" r="2.5" fill="white" fill-opacity="0.38"/>
<circle cx="268" cy="296" r="4.5" fill="white" fill-opacity="0.62"/>
<circle cx="258" cy="308" r="3" fill="white" fill-opacity="0.42"/>
<circle cx="292" cy="302" r="2.5" fill="white" fill-opacity="0.32"/>
<!-- ===== RIGHT: Phone ===== -->
<rect x="324" y="158" width="130" height="214" rx="24"
fill="white" fill-opacity="0.12" stroke="white" stroke-width="11" stroke-opacity="0.9"/>
<!-- Punch-hole camera -->
<circle cx="389" cy="175" r="7" fill="white" fill-opacity="0.45"/>
<!-- Screen area -->
<rect x="340" y="192" width="98" height="156" rx="7" fill="white" fill-opacity="0.10"/>
<!-- App icon grid (3×2) -->
<rect x="348" y="202" width="26" height="26" rx="7" fill="white" fill-opacity="0.50"/>
<rect x="382" y="202" width="26" height="26" rx="7" fill="white" fill-opacity="0.50"/>
<rect x="416" y="202" width="26" height="26" rx="7" fill="white" fill-opacity="0.38"/>
<rect x="348" y="236" width="26" height="26" rx="7" fill="white" fill-opacity="0.50"/>
<rect x="382" y="236" width="26" height="26" rx="7" fill="white" fill-opacity="0.38"/>
<rect x="416" y="236" width="26" height="26" rx="7" fill="white" fill-opacity="0.28"/>
<!-- Bottom mini-bar rows -->
<rect x="348" y="272" width="94" height="8" rx="4" fill="white" fill-opacity="0.25"/>
<rect x="348" y="288" width="72" height="8" rx="4" fill="white" fill-opacity="0.18"/>
<!-- Home indicator bar -->
<rect x="361" y="356" width="56" height="6" rx="3" fill="white" fill-opacity="0.65"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -22,6 +22,18 @@ export default {
return await handleBuildsRequest(request, env, url); return await handleBuildsRequest(request, env, url);
} }
// 静态资源 / SPA 回退(需 wrangler.toml [assets] binding = "ASSETS"
if (!env.ASSETS) {
return jsonResponseWithCors(
request,
{
error:
"ASSETS binding is not configured. Set [assets] binding = \"ASSETS\" in wrangler.toml and redeploy.",
},
500,
);
}
return env.ASSETS.fetch(request); return env.ASSETS.fetch(request);
} catch (error) { } catch (error) {
console.error("Unhandled worker error:", error); console.error("Unhandled worker error:", error);

View File

@@ -1,4 +1,10 @@
import { nanoid } from "nanoid"; import { customAlphabet } from "nanoid";
// GitHub published releases reject tag names ending with '-'.
const generateJobId = customAlphabet(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
10,
);
import type { Env } from "../env"; import type { Env } from "../env";
import { import {
getBuild, getBuild,
@@ -112,7 +118,7 @@ async function createBuild(request: Request, env: Env): Promise<Response> {
const buffer = new Uint8Array(await file.arrayBuffer()); const buffer = new Uint8Array(await file.arrayBuffer());
const { normalizedBuffer } = validateZipBuffer(buffer, maxUploadBytes(env)); const { normalizedBuffer } = validateZipBuffer(buffer, maxUploadBytes(env));
const jobId = nanoid(10); const jobId = generateJobId();
const slug = slugifyIdentifier(appNameEn) || jobId.toLowerCase(); const slug = slugifyIdentifier(appNameEn) || jobId.toLowerCase();
const appIdentifier = normalizeAppIdentifier( const appIdentifier = normalizeAppIdentifier(
identifierInput || `com.web2app.${slug}`, identifierInput || `com.web2app.${slug}`,
@@ -148,6 +154,7 @@ async function createBuild(request: Request, env: Env): Promise<Response> {
appName: appNameZh, appName: appNameZh,
appNameEn, appNameEn,
appIdentifier, appIdentifier,
appVersion,
}); });
await updateBuild(env, jobId, { await updateBuild(env, jobId, {

View File

@@ -115,6 +115,7 @@ export async function triggerBuildWorkflow(
appName: string; appName: string;
appNameEn: string; appNameEn: string;
appIdentifier: string; appIdentifier: string;
appVersion: string;
}, },
): Promise<number> { ): Promise<number> {
const { owner, repo, branch } = getGitHubConfig(env); const { owner, repo, branch } = getGitHubConfig(env);
@@ -133,6 +134,7 @@ export async function triggerBuildWorkflow(
app_name: input.appName, app_name: input.appName,
app_name_en: input.appNameEn, app_name_en: input.appNameEn,
app_identifier: input.appIdentifier, app_identifier: input.appIdentifier,
app_version: input.appVersion,
}, },
}), }),
}, },
@@ -217,18 +219,19 @@ export async function getReleaseAssets(
assets: Array<{ name: string; browser_download_url: string }>; assets: Array<{ name: string; browser_download_url: string }>;
}; };
let windowsUrl: string | null = null; let windowsInstallerUrl: string | null = null;
let windowsFallbackUrl: string | null = null;
let androidUrl: string | null = null; let androidUrl: string | null = null;
for (const asset of release.assets) { for (const asset of release.assets) {
const name = asset.name.toLowerCase(); const name = asset.name.toLowerCase();
if ( if (name.endsWith("-setup.exe") || name.endsWith(".msi")) {
!windowsUrl && windowsInstallerUrl = asset.browser_download_url;
(name.endsWith(".exe") || } else if (
name.endsWith(".msi") || !windowsFallbackUrl &&
name.includes("windows")) name.endsWith(".exe")
) { ) {
windowsUrl = asset.browser_download_url; windowsFallbackUrl = asset.browser_download_url;
} }
if ( if (
!androidUrl && !androidUrl &&
@@ -238,7 +241,10 @@ export async function getReleaseAssets(
} }
} }
return { windowsUrl, androidUrl }; return {
windowsUrl: windowsInstallerUrl ?? windowsFallbackUrl,
androidUrl,
};
} }
export function getActionsRunUrl(env: Env, runId: number | null): string | null { export function getActionsRunUrl(env: Env, runId: number | null): string | null {

View File

@@ -4,6 +4,8 @@ compatibility_date = "2024-11-01"
[assets] [assets]
directory = "./frontend/dist" directory = "./frontend/dist"
binding = "ASSETS"
# SPA未匹配的前端路由如 /jobs/:id经 env.ASSETS.fetch 回退到 index.html
not_found_handling = "single-page-application" not_found_handling = "single-page-application"
# 运行 `wrangler d1 create web2app` 后将输出的 database_id 替换下方占位符 # 运行 `wrangler d1 create web2app` 后将输出的 database_id 替换下方占位符