Compare commits

...

65 Commits

Author SHA1 Message Date
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
59 changed files with 803 additions and 57 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}
import android.os.Bundle
import android.webkit.CookieManager
import android.webkit.WebView
import androidx.core.view.WindowCompat
class MainActivity : TauriActivity() {
@@ -39,6 +41,13 @@ class MainActivity : TauriActivity() {
super.onCreate(savedInstanceState)
// 内容不延伸到状态栏 / 导航栏区域
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)}`);
}
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) {
for (const file of walk(androidRoot, (p) => p.endsWith("AndroidManifest.xml"))) {
const normalized = file.replace(/\\/g, "/");
if (!normalized.includes("/src/main/")) continue;
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(
/<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);
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 { BUNDLE_ICONS, generateAppIcons } from "./generate-app-icons.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 root = path.resolve(__dirname, "../..");
@@ -109,6 +110,7 @@ function patchTauriConfig(filePath) {
fs.writeFileSync(filePath, `${JSON.stringify(conf, null, 2)}\n`);
}
injectTauriShell(distDir);
injectInAppNav(distDir);
patchTauriConfig(confPath);

View File

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

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
node_modules/
frontend/dist/
!frontend/dist/index.html
frontend/dist-desktop/
frontend/.env.desktop
uploads/
data/
.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/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/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/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 name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<body>
<div id="root"></div>

View File

@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"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"
},
"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() {
const { pathname } = useLocation();
const isBuilds = pathname === "/jobs";
return (
<div className="app-shell">
<header className="site-header">
<div className="site-header-inner">
<Link to="/" className="site-brand">
<img src="/logo.svg" alt="" className="site-logo" />
Web2App
</Link>
<nav className="site-nav">
@@ -15,7 +19,7 @@ export default function Layout() {
</div>
</header>
<main className="site-main">
<div className="content">
<div className={isBuilds ? "content content-wide" : "content"}>
<Outlet />
</div>
</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 { fetchBuilds, statusLabel, type Build } from "../api/client";
import { getBuildDuration, isBuildFinished } from "../lib/duration";
@@ -6,6 +6,8 @@ import { getBuildDuration, isBuildFinished } from "../lib/duration";
export default function BuildList() {
const [builds, setBuilds] = useState<Build[]>([]);
const [error, setError] = useState<string | null>(null);
const [selectedPkg, setSelectedPkg] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
fetchBuilds()
@@ -15,35 +17,136 @@ export default function BuildList() {
);
}, []);
return (
<>
<h1 className="page-title"></h1>
<p className="page-lead"> 20 </p>
const packages = useMemo(() => {
const map = new Map<
string,
{ 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">
{error ? <p className="error-text">{error}</p> : null}
{!error && builds.length === 0 ? (
<p className="prose"></p>
) : null}
{builds.length > 0 ? (
<ul className="build-list">
{builds.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>
</>
const filtered = selectedPkg
? builds.filter((b) => b.appIdentifier === selectedPkg)
: builds;
function selectPkg(pkg: string | null) {
setSelectedPkg(pkg);
setSidebarOpen(false);
}
return (
<div className="builds-layout">
{sidebarOpen && (
<div
className="sidebar-overlay"
onClick={() => setSidebarOpen(false)}
/>
)}
<aside className={`builds-sidebar${sidebarOpen ? " sidebar-open" : ""}`}>
<div className="sidebar-header">
<span className="sidebar-title"></span>
<button
className="sidebar-close"
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;
color: var(--text);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.site-brand:hover {
@@ -81,6 +84,13 @@ a:hover {
color: var(--accent);
}
.site-logo {
width: 28px;
height: 28px;
border-radius: 6px;
flex-shrink: 0;
}
.site-nav {
display: flex;
gap: 1rem;
@@ -97,6 +107,10 @@ a:hover {
margin: 0 auto;
}
.content-wide {
max-width: 64rem;
}
.page-title {
margin: 0 0 0.25rem;
font-size: 1.75rem;
@@ -355,6 +369,209 @@ a:hover {
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) {
:root {
--page-pad: 1.5rem;

View File

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

View File

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

View File

@@ -0,0 +1,126 @@
(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"];
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();
},
});
};
}
var originalFetch = window.fetch;
window.fetch = function (input, init) {
init = init || {};
var url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input && input.url
? input.url
: "";
var requestUrl;
try {
requestUrl = new URL(url, location.href);
} catch (_) {
return originalFetch.call(this, input, init);
}
var crossOrigin = requestUrl.origin !== location.origin;
if (crossOrigin) {
var headers = new Headers(init.headers || {});
if (!headers.has("Authorization")) {
var token = readToken();
if (token) headers.set("Authorization", "Bearer " + token);
}
init = Object.assign({}, init, { headers: headers });
}
return originalFetch.call(this, input, init).then(function (response) {
if (!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;
});
};
})();

View File

@@ -1,5 +1,7 @@
use tauri::Manager;
const TAURI_SHELL_SCRIPT: &str = include_str!("../../scripts/tauri-shell.js");
const IN_APP_NAV_SCRIPT: &str = r##"
(function () {
if (window.__web2appNavInstalled) return;
@@ -68,12 +70,14 @@ pub fn run() {
tauri::Builder::default()
.setup(|app| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.eval(TAURI_SHELL_SCRIPT);
let _ = window.eval(IN_APP_NAV_SCRIPT);
}
Ok(())
})
.on_page_load(|webview, payload| {
if payload.event() == tauri::webview::PageLoadEvent::Finished {
let _ = webview.eval(TAURI_SHELL_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

@@ -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 {
getBuild,
@@ -112,7 +118,7 @@ async function createBuild(request: Request, env: Env): Promise<Response> {
const buffer = new Uint8Array(await file.arrayBuffer());
const { normalizedBuffer } = validateZipBuffer(buffer, maxUploadBytes(env));
const jobId = nanoid(10);
const jobId = generateJobId();
const slug = slugifyIdentifier(appNameEn) || jobId.toLowerCase();
const appIdentifier = normalizeAppIdentifier(
identifierInput || `com.web2app.${slug}`,
@@ -148,6 +154,7 @@ async function createBuild(request: Request, env: Env): Promise<Response> {
appName: appNameZh,
appNameEn,
appIdentifier,
appVersion,
});
await updateBuild(env, jobId, {

View File

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