Compare commits

...

10 Commits

Author SHA1 Message Date
c0075543f0 feat: desktop build support and build list UI improvements
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 21:57:39 +08:00
53711b02ae fix: remove cpu_ms limit for Workers Free plan deploy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 14:16:49 +08:00
8d6e2e00b8 fix: reduce upload failures on Cloudflare Workers
Optimize zip validation, lower max upload to 25MB, raise CPU limit, and show clear errors when Cloudflare returns HTML error pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 14:14:46 +08:00
cursor[bot]
1cfa823a95 Merge pull request #5 from shumengya/cursor/fix-network-security-config-b4e4
fix(android): 修复 network_security_config 资源缺失导致构建失败
2026-06-06 06:08:35 +00:00
Cursor Agent
475face0d3 fix(android): write network_security_config.xml to res/xml
walk() only matches files, so ensureNetworkSecurityXml never found
app/src/main/res and skipped creating the XML. AndroidManifest still
referenced @xml/network_security_config, causing AAPT link failures.

Co-authored-by: ShuMengya <shumengya666@outlook.com>
2026-06-06 06:08:26 +00:00
c55aaf8f30 chore: upload logo.png for build LJRfMN2Am2 2026-06-06 14:02:51 +08:00
23f90b03a4 chore: upload version.txt for build LJRfMN2Am2 2026-06-06 14:02:49 +08:00
2970e90a79 chore: upload site.zip for build LJRfMN2Am2 2026-06-06 14:02:47 +08:00
f8b05c69d6 fix(android): allow network requests and relax WebView CSP
Enable cleartext HTTP in release APK, add network security config, and set connect-src CSP. Add CORS on Worker API for cross-origin fetches from packaged apps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 13:53:05 +08:00
58d5dc8ae8 fix(android): keep content below status and navigation bars 2026-06-06 13:40:35 +08:00
30 changed files with 997 additions and 89 deletions

View File

@@ -8,4 +8,4 @@ GITHUB_TOKEN=ghp_your_personal_access_token
GITHUB_OWNER=shumengya
GITHUB_REPO=Web2App
DEFAULT_BRANCH=main
MAX_UPLOAD_MB=50
MAX_UPLOAD_MB=25

View File

@@ -0,0 +1,200 @@
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 androidRoot = path.join(root, "template", "src-tauri", "gen", "android");
function walk(dir, matcher) {
const hits = [];
if (!fs.existsSync(dir)) return hits;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
hits.push(...walk(full, matcher));
} else if (matcher(full)) {
hits.push(full);
}
}
return hits;
}
function patchMainActivity(filePath) {
const original = fs.readFileSync(filePath, "utf8");
const packageMatch = original.match(/^package\s+([^\s]+)/m);
if (!packageMatch) {
console.warn(`Skip MainActivity (no package): ${filePath}`);
return;
}
const pkg = packageMatch[1];
const content = `package ${pkg}
import android.os.Bundle
import androidx.core.view.WindowCompat
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 内容不延伸到状态栏 / 导航栏区域
WindowCompat.setDecorFitsSystemWindows(window, true)
}
}
`;
fs.writeFileSync(filePath, content);
console.log(`Patched MainActivity: ${path.relative(root, filePath)}`);
}
function ensureThemeItems(xml) {
let next = xml;
const items = [
`<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>`,
`<item name="android:statusBarColor">?android:colorBackground</item>`,
`<item name="android:navigationBarColor">?android:colorBackground</item>`,
];
for (const item of items) {
const name = item.match(/name="([^"]+)"/)?.[1];
if (!name) continue;
if (next.includes(`name="${name}"`)) {
next = next.replace(
new RegExp(`<item name="${name}">[^<]*</item>`, "g"),
item,
);
} else {
next = next.replace(/<\/style>/, ` ${item}\n </style>`);
}
}
return next;
}
function patchThemes(filePath) {
const original = fs.readFileSync(filePath, "utf8");
const patched = ensureThemeItems(original);
if (patched !== original) {
fs.writeFileSync(filePath, patched);
console.log(`Patched theme: ${path.relative(root, filePath)}`);
}
}
if (!fs.existsSync(androidRoot)) {
console.error(`Android project not found: ${androidRoot}`);
console.error("Run tauri android init before patch-android-system-bars.mjs");
process.exit(1);
}
for (const file of walk(androidRoot, (p) => p.endsWith("MainActivity.kt"))) {
patchMainActivity(file);
}
for (const file of walk(androidRoot, (p) => {
const normalized = p.replace(/\\/g, "/");
return (
normalized.endsWith("values/themes.xml") ||
normalized.endsWith("values-night/themes.xml")
);
})) {
patchThemes(file);
}
const NETWORK_SECURITY_XML = `<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
`;
function ensureNetworkSecurityXml(androidRoot) {
const resDir = path.join(androidRoot, "app", "src", "main", "res");
if (!fs.existsSync(resDir)) {
console.warn(
`No Android res directory found for network_security_config.xml: ${resDir}`,
);
return;
}
const xmlDir = path.join(resDir, "xml");
fs.mkdirSync(xmlDir, { recursive: true });
const target = path.join(xmlDir, "network_security_config.xml");
fs.writeFileSync(target, NETWORK_SECURITY_XML);
console.log(`Wrote ${path.relative(root, target)}`);
}
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")) {
xml = xml.replace(
/<application/,
' <uses-permission android:name="android.permission.INTERNET" />\n\n <application',
);
}
xml = xml.replace(
/android:usesCleartextTraffic="\$\{usesCleartextTraffic\}"/g,
'android:usesCleartextTraffic="true"',
);
xml = xml.replace(
/android:usesCleartextTraffic="false"/g,
'android:usesCleartextTraffic="true"',
);
if (!xml.includes("usesCleartextTraffic")) {
xml = xml.replace(
/<application([^>]*)>/,
'<application$1 android:usesCleartextTraffic="true">',
);
}
if (!xml.includes("networkSecurityConfig")) {
xml = xml.replace(
/<application([^>]*)>/,
'<application$1 android:networkSecurityConfig="@xml/network_security_config">',
);
}
fs.writeFileSync(file, xml);
console.log(`Patched AndroidManifest: ${path.relative(root, file)}`);
}
}
function patchGradleCleartext(androidRoot) {
for (const file of walk(androidRoot, (p) => p.endsWith("build.gradle.kts"))) {
const normalized = file.replace(/\\/g, "/");
if (!normalized.includes("/app/")) continue;
let content = fs.readFileSync(file, "utf8");
if (content.includes('manifestPlaceholders["usesCleartextTraffic"]')) {
content = content.replace(
/manifestPlaceholders\["usesCleartextTraffic"\]\s*=\s*"[^"]*"/,
'manifestPlaceholders["usesCleartextTraffic"] = "true"',
);
} else if (content.includes("defaultConfig {")) {
content = content.replace(
/defaultConfig\s*\{/,
`defaultConfig {\n manifestPlaceholders["usesCleartextTraffic"] = "true"`,
);
} else {
continue;
}
fs.writeFileSync(file, content);
console.log(`Patched Gradle cleartext: ${path.relative(root, file)}`);
}
}
ensureNetworkSecurityXml(androidRoot);
patchAndroidManifest(androidRoot);
patchGradleCleartext(androidRoot);
console.log("Android app patches applied (system bars + network)");

View File

@@ -84,6 +84,23 @@ function patchTauriConfig(filePath) {
if (conf.app?.windows?.[0]) {
conf.app.windows[0].title = appNameZh;
}
conf.app = {
...conf.app,
security: {
csp: {
"default-src": "'self' asset: tauri: https: http: data: blob: file:",
"connect-src":
"'self' asset: tauri: https: http: ws: wss: data: blob: file:",
"img-src": "'self' asset: tauri: https: http: data: blob: file:",
"media-src": "'self' asset: tauri: https: http: data: blob: file:",
"style-src": "'self' 'unsafe-inline' asset: tauri: https: http:",
"script-src":
"'self' 'unsafe-inline' 'unsafe-eval' asset: tauri: https: http:",
"frame-src": "'self' asset: tauri: https: http: data: blob:",
"worker-src": "'self' blob: data:",
},
},
};
conf.bundle = {
...conf.bundle,
active: true,

View File

@@ -145,6 +145,9 @@ jobs:
env:
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/${{ env.NDK_VERSION }}
- name: Patch Android app (system bars + network)
run: node .github/scripts/patch-android-system-bars.mjs
- name: Regenerate app icons for Android
run: node .github/scripts/generate-app-icons.mjs
env:

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

BIN
builds/LJRfMN2Am2/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
builds/LJRfMN2Am2/site.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
2026.6.6

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,3 +1,5 @@
import { MAX_UPLOAD_BYTES, MAX_UPLOAD_MB } from "../lib/upload-limits";
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
export type BuildStatus =
@@ -36,6 +38,12 @@ function apiUrl(path: string): string {
return `${API_BASE}${path}`;
}
export function assertUploadSize(file: File): void {
if (file.size > MAX_UPLOAD_BYTES) {
throw new Error(`zip 不能超过 ${MAX_UPLOAD_MB}MB请压缩后重试`);
}
}
export async function createBuild(formData: FormData): Promise<{ id: string }> {
let response: Response;
try {
@@ -49,7 +57,7 @@ export async function createBuild(formData: FormData): Promise<{ id: string }> {
);
}
const data = (await readJson(response)) as CreateBuildResponse;
const data = (await readResponseBody(response)) as CreateBuildResponse;
if (!response.ok) {
throw new Error(data.error ?? "上传失败");
}
@@ -58,7 +66,7 @@ export async function createBuild(formData: FormData): Promise<{ id: string }> {
export async function fetchBuild(id: string): Promise<Build> {
const response = await fetch(apiUrl(`/api/builds/${id}`));
const data = (await readJson(response)) as Build;
const data = (await readResponseBody(response)) as Build;
if (!response.ok) {
const errorData = data as Build & ApiErrorResponse;
throw new Error(errorData.error ?? "Failed to load build");
@@ -68,21 +76,56 @@ export async function fetchBuild(id: string): Promise<Build> {
export async function fetchBuilds(): Promise<Build[]> {
const response = await fetch(apiUrl("/api/builds"));
const data = (await readJson(response)) as { builds: Build[] };
const data = (await readResponseBody(response)) as { builds: Build[] };
if (!response.ok) {
throw new Error("Failed to load builds");
}
return data.builds;
}
async function readJson(response: Response): Promise<unknown> {
function parseCloudflareHtmlError(status: number, text: string): string {
const codeMatch = text.match(/cloudflare[^0-9]*(\d{4})/i);
const cfCode = codeMatch?.[1];
if (status === 413) {
return `上传体积过大HTTP 413请压缩 zip 到 ${MAX_UPLOAD_MB}MB 以内`;
}
if (status === 502 || status === 503 || status === 524) {
return `服务器处理超时或暂时不可用HTTP ${status})。请缩小 zip 体积后重试,或稍后再试`;
}
if (cfCode === "1102" || text.includes("1102")) {
return "Worker CPU 时间超限Cloudflare 1102。请缩小 zip 或升级 Workers 付费套餐";
}
if (cfCode === "1101" || text.includes("1101")) {
return "Worker 运行时错误Cloudflare 1101。请检查 zip 是否损坏,或查看部署日志";
}
return `服务器返回了错误页面HTTP ${status}),通常因 zip 过大或 Worker 超时。请压缩到 ${MAX_UPLOAD_MB}MB 以内后重试`;
}
async function readResponseBody(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) return {};
const trimmed = text.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return JSON.parse(trimmed) as unknown;
} catch {
/* fall through */
}
}
if (trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html")) {
throw new Error(parseCloudflareHtmlError(response.status, text));
}
try {
return JSON.parse(text) as unknown;
return JSON.parse(trimmed) as unknown;
} catch {
throw new Error(`Unexpected response from server: ${text.slice(0, 200)}`);
throw new Error(
`服务器返回了无法解析的响应HTTP ${response.status}${text.slice(0, 120)}`,
);
}
}

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

@@ -0,0 +1,7 @@
/** 与 wrangler.toml MAX_UPLOAD_MB 保持一致 */
export const MAX_UPLOAD_MB = 25;
export const MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024;
export function formatMaxUploadLabel(): string {
return `${MAX_UPLOAD_MB}MB`;
}

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

@@ -1,7 +1,8 @@
import { FormEvent, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createBuild } from "../api/client";
import { assertUploadSize, createBuild } from "../api/client";
import { getDefaultAppVersion } from "../lib/version";
import { formatMaxUploadLabel } from "../lib/upload-limits";
export default function Upload() {
const navigate = useNavigate();
@@ -32,6 +33,13 @@ export default function Upload() {
return;
}
try {
assertUploadSize(file);
} catch (err) {
setError(err instanceof Error ? err.message : "文件过大");
return;
}
setLoading(true);
setError(null);
@@ -67,7 +75,8 @@ export default function Upload() {
<section className="doc-section">
<h2>使</h2>
<p className="prose">
index.html 50MB
index.html{" "}
{formatMaxUploadLabel()}
PNG / JPG / ICO zip
2026.5.29 Bundle ID
</p>

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 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout.tsx","./src/lib/version.ts","./src/pages/buildlist.tsx","./src/pages/jobstatus.tsx","./src/pages/upload.tsx"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout.tsx","./src/lib/duration.ts","./src/lib/upload-limits.ts","./src/lib/version.ts","./src/pages/buildlist.tsx","./src/pages/jobstatus.tsx","./src/pages/upload.tsx"],"version":"5.6.3"}

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

@@ -21,7 +21,16 @@
}
],
"security": {
"csp": null
"csp": {
"default-src": "'self' asset: tauri: https: http: data: blob: file:",
"connect-src": "'self' asset: tauri: https: http: ws: wss: data: blob: file:",
"img-src": "'self' asset: tauri: https: http: data: blob: file:",
"media-src": "'self' asset: tauri: https: http: data: blob: file:",
"style-src": "'self' 'unsafe-inline' asset: tauri: https: http:",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' asset: tauri: https: http:",
"frame-src": "'self' asset: tauri: https: http: data: blob:",
"worker-src": "'self' blob: data:"
}
}
},
"bundle": {

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,19 +1,40 @@
import type { Env } from "./env";
import { handleBuildsRequest } from "./routes/builds";
import { jsonResponse } from "./lib/response";
import {
corsPreflightResponse,
jsonResponseWithCors,
} from "./lib/response";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
try {
const url = new URL(request.url);
if (url.pathname === "/api/health") {
return jsonResponse({ ok: true });
if (request.method === "OPTIONS" && url.pathname.startsWith("/api/")) {
return corsPreflightResponse(request);
}
if (url.pathname === "/api/health") {
return jsonResponseWithCors(request, { ok: true });
}
if (url.pathname.startsWith("/api/builds")) {
return await handleBuildsRequest(request, env, url);
}
return env.ASSETS.fetch(request);
} catch (error) {
console.error("Unhandled worker error:", error);
return jsonResponseWithCors(
request,
{
error:
error instanceof Error
? error.message
: "Internal server error",
},
500,
);
}
if (url.pathname.startsWith("/api/builds")) {
return handleBuildsRequest(request, env, url);
}
return env.ASSETS.fetch(request);
},
};

View File

@@ -1,3 +1,16 @@
export function corsHeaders(request: Request): Record<string, string> {
const origin = request.headers.get("Origin");
if (!origin) return {};
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
Vary: "Origin",
};
}
export function jsonResponse(
data: unknown,
status = 200,
@@ -12,6 +25,25 @@ export function jsonResponse(
});
}
export function jsonResponseWithCors(
request: Request,
data: unknown,
status = 200,
headers?: Record<string, string>,
): Response {
return jsonResponse(data, status, {
...corsHeaders(request),
...headers,
});
}
export function corsPreflightResponse(request: Request): Response {
return new Response(null, {
status: 204,
headers: corsHeaders(request),
});
}
export function errorResponse(message: string, status: number): Response {
return jsonResponse({ error: message }, status);
}

View File

@@ -8,7 +8,7 @@ import {
updateBuild,
type BuildRecord,
} from "../db/builds";
import { errorResponse, jsonResponse } from "../lib/response";
import { jsonResponseWithCors } from "../lib/response";
import {
getActionsRunUrl,
getReleaseAssets,
@@ -31,6 +31,18 @@ import {
ZipValidationError,
} from "../services/zip";
function apiJson(
request: Request,
data: unknown,
status = 200,
): Response {
return jsonResponseWithCors(request, data, status);
}
function apiError(request: Request, message: string, status: number): Response {
return jsonResponseWithCors(request, { error: message }, status);
}
function maxUploadBytes(env: Env): number {
const mb = Number(env.MAX_UPLOAD_MB ?? "50");
return mb * 1024 * 1024;
@@ -45,7 +57,7 @@ export async function handleBuildsRequest(
if (path === "/api/builds" && request.method === "GET") {
const builds = (await listBuilds(env)).map(toPublicBuild);
return jsonResponse({ builds });
return apiJson(request, { builds });
}
const match = path.match(/^\/api\/builds\/([^/]+)$/);
@@ -55,24 +67,24 @@ export async function handleBuildsRequest(
if (request.method === "GET") {
const record = await getBuild(env, id);
if (!record) {
return errorResponse("Build not found", 404);
return apiError(request, "Build not found", 404);
}
const refreshed = await refreshBuildStatus(env, record);
return jsonResponse({
return apiJson(request, {
...toPublicBuild(refreshed),
actionsUrl: getActionsRunUrl(env, refreshed.workflow_run_id),
});
}
return errorResponse("Method not allowed", 405);
return apiError(request, "Method not allowed", 405);
}
if (path === "/api/builds" && request.method === "POST") {
return createBuild(request, env);
}
return errorResponse("Not found", 404);
return apiError(request, "Not found", 404);
}
async function createBuild(request: Request, env: Env): Promise<Response> {
@@ -91,11 +103,11 @@ async function createBuild(request: Request, env: Env): Promise<Response> {
const file = formData.get("file");
if (!(file instanceof File)) {
return errorResponse("file is required", 400);
return apiError(request, "file is required", 400);
}
if (!file.name.toLowerCase().endsWith(".zip")) {
return errorResponse("Only .zip files are supported", 400);
return apiError(request, "Only .zip files are supported", 400);
}
const buffer = new Uint8Array(await file.arrayBuffer());
@@ -143,7 +155,8 @@ async function createBuild(request: Request, env: Env): Promise<Response> {
status: "in_progress",
});
return jsonResponse(
return apiJson(
request,
{
id: jobId,
status: "in_progress",
@@ -157,11 +170,12 @@ async function createBuild(request: Request, env: Env): Promise<Response> {
error instanceof VersionValidationError ||
error instanceof IconValidationError
) {
return errorResponse(error.message, 400);
return apiError(request, error.message, 400);
}
console.error(error);
return errorResponse(
return apiError(
request,
error instanceof Error ? error.message : "Failed to create build",
500,
);

View File

@@ -7,6 +7,99 @@ export class ZipValidationError extends Error {
}
}
const CDH_SIG = 0x02014b50;
/** 只读中央目录,不解压文件体,降低 Worker CPU 占用 */
function listZipEntryNames(buffer: Uint8Array): string[] {
const eocdOffset = findEndOfCentralDirectory(buffer);
if (eocdOffset < 0) {
throw new ZipValidationError("Invalid zip archive");
}
const view = new DataView(
buffer.buffer,
buffer.byteOffset,
buffer.byteLength,
);
const cdSize = view.getUint32(eocdOffset + 12, true);
const cdOffset = view.getUint32(eocdOffset + 16, true);
const names: string[] = [];
let ptr = cdOffset;
const end = cdOffset + cdSize;
while (ptr + 46 <= end) {
if (view.getUint32(ptr, true) !== CDH_SIG) break;
const nameLen = view.getUint16(ptr + 28, true);
const extraLen = view.getUint16(ptr + 30, true);
const commentLen = view.getUint16(ptr + 32, true);
const nameStart = ptr + 46;
if (nameStart + nameLen > buffer.length) break;
const raw = new TextDecoder().decode(
buffer.subarray(nameStart, nameStart + nameLen),
);
const name = raw.replace(/\\/g, "/");
if (!name.endsWith("/")) {
names.push(name);
}
ptr = nameStart + nameLen + extraLen + commentLen;
}
if (names.length === 0) {
throw new ZipValidationError("Zip archive is empty");
}
return names;
}
function findEndOfCentralDirectory(buffer: Uint8Array): number {
const minEocd = 22;
const maxComment = 0xffff;
const start = Math.max(0, buffer.length - minEocd - maxComment);
for (let i = buffer.length - minEocd; i >= start; i--) {
if (
buffer[i] === 0x50 &&
buffer[i + 1] === 0x4b &&
buffer[i + 2] === 0x05 &&
buffer[i + 3] === 0x06
) {
return i;
}
}
return -1;
}
function needsFlatten(entries: string[]): boolean {
if (entries.includes("index.html")) return false;
const indexEntry = entries.find((entry) => {
const parts = entry.split("/");
return parts.length === 2 && parts[1] === "index.html";
});
if (!indexEntry) {
throw new ZipValidationError(
"Zip must contain index.html at root or in a single top-level folder",
);
}
const folder = indexEntry.split("/")[0];
const topDirs = new Set(entries.map((k) => k.split("/")[0]).filter(Boolean));
if (topDirs.size !== 1 || !topDirs.has(folder)) {
throw new ZipValidationError(
"index.html must be at zip root or inside exactly one folder",
);
}
return true;
}
export function validateZipBuffer(
buffer: Uint8Array,
maxBytes: number,
@@ -17,6 +110,12 @@ export function validateZipBuffer(
);
}
const entries = listZipEntryNames(buffer);
if (!needsFlatten(entries)) {
return { normalizedBuffer: buffer };
}
let files: Record<string, Uint8Array>;
try {
files = unzipSync(buffer);
@@ -24,26 +123,6 @@ export function validateZipBuffer(
throw new ZipValidationError("Invalid zip archive");
}
const entries = Object.keys(files).filter(
(key) => !key.endsWith("/") && files[key].length > 0,
);
if (entries.length === 0) {
throw new ZipValidationError("Zip archive is empty");
}
const indexEntry = entries.find((entry) => {
const normalized = entry.replace(/\\/g, "/");
if (normalized === "index.html") return true;
const parts = normalized.split("/");
return parts.length === 2 && parts[1] === "index.html";
});
if (!indexEntry) {
throw new ZipValidationError(
"Zip must contain index.html at root or in a single top-level folder",
);
}
const normalized = normalizeZipFiles(files);
return { normalizedBuffer: zipSync(normalized) };
}

View File

@@ -15,6 +15,6 @@ migrations_dir = "worker/migrations"
[vars]
DEFAULT_BRANCH = "main"
MAX_UPLOAD_MB = "50"
MAX_UPLOAD_MB = "25"
GITHUB_OWNER = "shumengya"
GITHUB_REPO = "Web2App"