feat: migrate to Cloudflare Workers with icon and version upload

Replace Express/SQLite and web/ with frontend/, worker/, and wrangler deploy. Add optional app icon upload, date-based app_version, and build-app workflow input.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 13:30:05 +08:00
parent 0921e5f7a1
commit b69dfc4813
55 changed files with 3285 additions and 3411 deletions

View File

@@ -1,6 +1,11 @@
# 本地开发:复制为 .dev.varsWrangler 自动加载,勿提交)
# cp .env.example .dev.vars
#
# 生产部署:敏感项用 wrangler secret put
# wrangler secret put GITHUB_TOKEN
GITHUB_TOKEN=ghp_your_personal_access_token
GITHUB_OWNER=shumengya
GITHUB_REPO=Web2App
DEFAULT_BRANCH=main
PORT=3001
MAX_UPLOAD_MB=50

View File

@@ -8,7 +8,7 @@ const root = path.resolve(__dirname, "../..");
const distDir = path.join(root, "template", "dist");
const templateDir = path.join(root, "template");
const ICON_PRIORITY = ["logo.png", "favicon.ico"];
const ICON_PRIORITY = ["logo.png", "logo.jpg", "logo.jpeg", "favicon.ico"];
function findIconSource(baseDir) {
for (const name of ICON_PRIORITY) {

View File

@@ -19,6 +19,17 @@ if (!jobId || !appNameZh || !appNameEn || !appIdentifier) {
process.exit(1);
}
function readAppVersion(jobId) {
const versionFile = path.join(root, "builds", jobId, "version.txt");
if (fs.existsSync(versionFile)) {
const fromFile = fs.readFileSync(versionFile, "utf8").trim();
if (fromFile) return fromFile;
}
const fromEnv = (process.env.APP_VERSION ?? "").trim();
return fromEnv || "1.0.0";
}
const appVersion = readAppVersion(jobId);
const zipPath = path.join(root, "builds", jobId, "site.zip");
const distDir = path.join(root, "template", "dist");
const confPath = path.join(root, "template", "src-tauri", "tauri.conf.json");
@@ -46,6 +57,15 @@ if (!fs.existsSync(indexPath)) {
process.exit(1);
}
const buildDir = path.join(root, "builds", jobId);
for (const iconName of ["logo.png", "logo.jpg", "logo.jpeg", "favicon.ico"]) {
const iconSrc = path.join(buildDir, iconName);
if (fs.existsSync(iconSrc)) {
fs.copyFileSync(iconSrc, path.join(distDir, iconName));
console.log(`Copied uploaded icon: builds/${jobId}/${iconName}`);
}
}
function toSafeBinaryName(name, fallback) {
const ascii = name.replace(/[^a-zA-Z0-9_-]+/g, "");
return (ascii || fallback).slice(0, 64);
@@ -57,7 +77,7 @@ function patchTauriConfig(filePath) {
conf.productName = appNameZh;
conf.mainBinaryName = safeBinaryName;
conf.identifier = appIdentifier;
conf.version = "1.0.0";
conf.version = appVersion;
if (conf.app?.windows?.[0]) {
conf.app.windows[0].title = appNameZh;
}

View File

@@ -28,6 +28,7 @@ env:
APP_NAME: ${{ inputs.app_name }}
APP_NAME_EN: ${{ inputs.app_name_en }}
APP_IDENTIFIER: ${{ inputs.app_identifier }}
APP_VERSION: ${{ inputs.app_version }}
NDK_VERSION: "27.2.12479018"
jobs:
@@ -176,7 +177,7 @@ jobs:
tag_name: build-${{ inputs.job_id }}
name: Web2App ${{ inputs.app_name }} (${{ inputs.job_id }})
body: |
Automated build for **${{ inputs.app_name }}** (English: `${{ inputs.app_name_en }}`, 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
- Android APK (signed for sideload install)

8
.gitignore vendored
View File

@@ -1,12 +1,14 @@
node_modules/
web/dist/
server/dist/
frontend/dist/
!frontend/dist/index.html
uploads/
data/
.env
.dev.vars
*.db
*.db-journal
.DS_Store
.wrangler/
target/
template/dist/*
!template/dist/index.html
!template/dist/index.html

157
README.md
View File

@@ -2,83 +2,111 @@
将已构建好的静态网页zip入口 `index.html`)转换为 **Windows 桌面应用****Android APK**。本仓库包含:
- `web/`:上传与构建状态页面
- `server/`Express API负责 zip 校验、GitHub 上传workflow 触发
- `frontend/`React + Vite 上传与构建状态页面(文档风格 UI
- `worker/`Cloudflare Worker APIzip 校验、GitHub 上传workflow 触发
- `template/`:最小 Tauri 2 壳CI 中注入用户静态资源后打包
- `.github/workflows/build-app.yml`:并行构建 Windows / Android 并发布 Release
## 架构
1. 用户在本地网站上传 zip
2. 后端校验 zip 后,通过 GitHub Contents API 上传到 `builds/{jobId}/site.zip`
3. 后端触发 `build-app.yml` workflow
1. 用户访问 Worker 托管的前端并上传 zip
2. Worker 校验 zip 后,通过 GitHub Contents API 上传到 `builds/{jobId}/site.zip`
3. Worker 触发 `build-app.yml` workflow
4. GitHub Actions 解压 zip 到 `template/dist/`,构建 Windows 与 Android
5. `finalize` job 创建 Release`build-{jobId}`并返回下载链接
5. `finalize` job 创建 Release`build-{jobId}`前端轮询并展示下载链接
本地/生产均由 **一个 Worker** 提供 `/api/*` 与静态资源(`wrangler deploy` 一键部署)。
## 前置条件
- Node.js 20+
- [Cloudflare 账号](https://dash.cloudflare.com/) 与 [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/)
- 一个 GitHub 仓库(建议名称 `Web2App`
- Personal Access Token权限至少包含
- `repo`
- `workflow`
- Personal Access Token权限至少包含`repo``workflow`
## 快速开始
## 快速开始(本地)
### 1. 初始化 GitHub 仓库
### 1. 安装依赖
```bash
git init
git add .
git commit -m "init web2app platform"
git branch -M main
git remote add origin https://github.com/<owner>/Web2App.git
git push -u origin main
npm install
```
### 2. 配置环境变量
### 2. 配置密钥
```bash
cp .env.example .env
cp .dev.vars.example .dev.vars
```
编辑 `.env`
编辑 `.dev.vars`Wrangler 本地开发自动加载)
```env
GITHUB_TOKEN=ghp_xxx
GITHUB_OWNER=your-github-username
GITHUB_REPO=Web2App
DEFAULT_BRANCH=main
PORT=3001
MAX_UPLOAD_MB=50
```
### 3. 安装依赖并启动
### 3. 初始化 D1本地
```bash
npm install
npm run db:migrate:local
```
### 4. 构建前端并启动
```bash
npm run build -w frontend
npm run dev
```
- 前端:<http://localhost:5173>
- 后端<http://localhost:3001>
- 前端开发服务器<http://localhost:5173>`/api` 代理到 Worker `8787`
- Worker<http://127.0.0.1:8787>
### 4. 上传测试
首次 `wrangler dev` 前需存在 `frontend/dist``npm run build -w frontend`)。
准备一个 zip根目录包含 `index.html`,也可使用 `examples/sample-site/` 打包:
### 5. 上传测试
准备一个 zip根目录包含 `index.html`,也可使用 `examples/sample-site/` 打包。
## 部署到 Cloudflare
### 1. 登录并创建 D1
```bash
cd examples/sample-site
powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
wrangler login
wrangler d1 create web2app
```
在网页中填写应用名称并上传 zip
将命令输出的 `database_id` 写入根目录 `wrangler.toml` 中对应 `[[d1_databases]]` 条目(若 Wrangler 未自动写入)
```bash
npm run db:migrate:remote
```
### 2. 配置生产密钥
```bash
wrangler secret put GITHUB_TOKEN
```
`wrangler.toml``[vars]` 中设置 `GITHUB_OWNER``GITHUB_REPO`(或使用 `wrangler secret put` 存放全部变量)。
### 3. 一键部署
```bash
npm run deploy
```
等价于:构建 `frontend/dist``wrangler deploy`Worker API + 静态资源 + D1 绑定)。
## API
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/builds` | `multipart/form-data`: `file`, `appNameZh`, `appNameEn`, 可选 `identifier` |
| `GET` | `/api/health` | 健康检查 |
| `POST` | `/api/builds` | `multipart/form-data`: `file`, `appNameZh`, `appNameEn`, `appVersion`(默认当天如 `2026.5.29`),可选 `identifier``icon`PNG/JPG/ICO |
| `GET` | `/api/builds/:id` | 查询构建状态与下载链接 |
| `GET` | `/api/builds` | 最近 20 条构建记录 |
@@ -88,7 +116,9 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
- `job_id`
- `app_name`
- `app_name_en`
- `app_identifier`
- `app_version`
构建产物发布到 Release tag`build-{job_id}`
@@ -100,20 +130,10 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
2. `favicon.ico`
3. 若都未找到,使用模板内置默认图标
找到后 CI 会执行 `tauri icon` 生成 Windows / Android 所需的多尺寸图标。
### Android 签名
### Android 签名与安装
- CI 先构建未签名 APK再用 `apksigner` **签名**,可直接侧载安装(不修改 Gradle 配置,避免构建失败)
- 未配置 Secrets 时workflow 会生成临时 CI 证书(密码 `web2app-ci`,仅适合 demo
- 生产环境建议在仓库 Settings → Secrets 配置:
| Secret | 说明 |
|--------|------|
| `ANDROID_KEYSTORE_BASE64` | `.jks` 文件 base64 |
| `ANDROID_KEYSTORE_PASSWORD` | keystore 密码 |
| `ANDROID_KEY_PASSWORD` | key 密码 |
| `ANDROID_KEY_ALIAS` | 别名,默认 `web2app` |
- CI 先构建未签名 APK再用 `apksigner` 签名
- 生产环境建议在仓库 Settings → Secrets 配置 `ANDROID_KEYSTORE_BASE64`
本地生成 keystore
@@ -121,54 +141,35 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
bash scripts/generate-android-keystore.sh web2app-release.jks web2app
```
- 首次 Android 构建会在 CI 中执行 `tauri android init`
- Android 构建依赖 NDK / SDKworkflow 已预装常见组件
### 手动验证 workflow
在 GitHub 仓库 Actions 页选择 **Build App**,手动填写:
- `job_id`: 任意已上传目录名(需先存在 `builds/{job_id}/site.zip`
- `app_name`: 演示应用
- `app_name_en`: Demo App
- `app_identifier`: com.web2app.demo
## 本地数据
- SQLite`data/web2app.db`
- 上传临时文件:内存处理,不持久化到磁盘
## Demo 限制
- 无登录鉴权,仅适合本地/内网 demo
- GitHub Actions 有排队时间与额度限制
- 大 zip 会短暂占用仓库空间finalize 会尝试删除 `site.zip`
- 未配置自有证书时,各次 CI 构建可能使用不同签名,无法覆盖安装旧版 APK
## 目录结构
```
Web2App/
├── web/ # Vite + React 前端
├── server/ # Express 后端
├── frontend/ # React + Vite 前端
├── worker/ # Cloudflare Worker API + D1 migrations
├── wrangler.toml # 统一部署配置
├── template/ # Tauri 2 模板
├── .github/workflows/ # CI 构建
── examples/ # 示例静态站点
└── data/ # 本地 SQLite运行时生成
├── .github/workflows/ # CI 构建(未随 Worker 迁移改动)
── examples/ # 示例静态站点
```
## 常见问题
**`wrangler dev` 找不到静态资源**
- 运行 `npm run build -w frontend`
**workflow_dispatch 返回 404**
- 确认 workflow 文件已在默认分支
- 确认 workflow 已在默认分支
- 确认 `DEFAULT_BRANCH` 与仓库默认分支一致
**D1 表不存在**
- 本地:`npm run db:migrate:local`
- 远程:`npm run db:migrate:remote`
**Release 没有下载链接**
- 等待 `finalize` job 完成
- 检查 Windows / Android job 是否都成功
**zip 校验失败**
- 确保 `index.html` 在 zip 根目录,或仅套一层文件夹

View File

@@ -1,5 +1,5 @@
{
"name": "web2app-web",
"name": "web2app-frontend",
"private": true,
"version": "0.1.0",
"type": "module",

17
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { Route, Routes } from "react-router-dom";
import Layout from "./components/Layout";
import BuildList from "./pages/BuildList";
import JobStatus from "./pages/JobStatus";
import Upload from "./pages/Upload";
export default function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Upload />} />
<Route path="/jobs" element={<BuildList />} />
<Route path="/jobs/:id" element={<JobStatus />} />
</Route>
</Routes>
);
}

View File

@@ -1,3 +1,5 @@
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
export type BuildStatus =
| "pending"
| "queued"
@@ -10,6 +12,7 @@ export interface Build {
appName: string;
appNameEn: string;
appIdentifier: string;
appVersion: string;
status: BuildStatus;
workflowRunId: number | null;
windowsUrl: string | null;
@@ -29,15 +32,21 @@ interface CreateBuildResponse {
error?: string;
}
function apiUrl(path: string): string {
return `${API_BASE}${path}`;
}
export async function createBuild(formData: FormData): Promise<{ id: string }> {
let response: Response;
try {
response = await fetch("/api/builds", {
response = await fetch(apiUrl("/api/builds"), {
method: "POST",
body: formData,
});
} catch {
throw new Error("无法连接后端服务,请确认已运行 npm run dev 且 server 已启动");
throw new Error(
"无法连接后端,请确认已运行 npm run dev 且 wrangler 已启动",
);
}
const data = (await readJson(response)) as CreateBuildResponse;
@@ -48,7 +57,7 @@ export async function createBuild(formData: FormData): Promise<{ id: string }> {
}
export async function fetchBuild(id: string): Promise<Build> {
const response = await fetch(`/api/builds/${id}`);
const response = await fetch(apiUrl(`/api/builds/${id}`));
const data = (await readJson(response)) as Build;
if (!response.ok) {
const errorData = data as Build & ApiErrorResponse;
@@ -57,6 +66,15 @@ export async function fetchBuild(id: string): Promise<Build> {
return data;
}
export async function fetchBuilds(): Promise<Build[]> {
const response = await fetch(apiUrl("/api/builds"));
const data = (await readJson(response)) as { builds: Build[] };
if (!response.ok) {
throw new Error("Failed to load builds");
}
return data.builds;
}
async function readJson(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) return {};

View File

@@ -0,0 +1,27 @@
import { Link, Outlet } from "react-router-dom";
export default function Layout() {
return (
<div className="app-shell">
<header className="site-header">
<div className="site-header-inner">
<Link to="/" className="site-brand">
Web2App
</Link>
<nav className="site-nav">
<Link to="/"></Link>
<Link to="/jobs"></Link>
</nav>
</div>
</header>
<main className="site-main">
<div className="content">
<Outlet />
</div>
</main>
<footer className="site-footer">
zip Windows / Android
</footer>
</div>
);
}

View File

@@ -0,0 +1,4 @@
/** 默认版本号:当前日期,格式如 2026.5.29 */
export function getDefaultAppVersion(date = new Date()): string {
return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}`;
}

View File

@@ -2,7 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
import "./styles/doc.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>

View File

@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { fetchBuilds, statusLabel, type Build } from "../api/client";
export default function BuildList() {
const [builds, setBuilds] = useState<Build[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchBuilds()
.then(setBuilds)
.catch((err) =>
setError(err instanceof Error ? err.message : "加载失败"),
);
}, []);
return (
<>
<h1 className="page-title"></h1>
<p className="page-lead"> 20 </p>
<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} · {build.id} · {statusLabel(build.status)}{" "}
· {new Date(build.createdAt).toLocaleString()}
</div>
</li>
))}
</ul>
) : null}
</section>
</>
);
}

View File

@@ -0,0 +1,150 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { fetchBuild, statusLabel, type Build } from "../api/client";
export default function JobStatus() {
const { id } = useParams<{ id: string }>();
const [build, setBuild] = useState<Build | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
let cancelled = false;
async function poll() {
try {
const data = await fetchBuild(id!);
if (cancelled) return;
setBuild(data);
setError(null);
if (data.status === "completed" || data.status === "failed") {
return;
}
window.setTimeout(poll, 5000);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : "加载失败");
window.setTimeout(poll, 5000);
}
}
poll();
return () => {
cancelled = true;
};
}, [id]);
if (!id) {
return <p className="error-text"> ID</p>;
}
return (
<>
<div className="status-toolbar">
<h1 className="page-title" style={{ margin: 0 }}>
</h1>
<Link to="/"></Link>
</div>
{error ? <p className="error-text">{error}</p> : null}
<section className="doc-section">
{build ? (
<>
<dl className="meta-list">
<div>
<dt> ID</dt>
<dd>
<code>{build.id}</code>
</dd>
</div>
<div>
<dt></dt>
<dd>{build.appName}</dd>
</div>
<div>
<dt></dt>
<dd>{build.appNameEn}</dd>
</div>
<div>
<dt>Bundle ID</dt>
<dd>
<code>{build.appIdentifier}</code>
</dd>
</div>
<div>
<dt></dt>
<dd>
<code>{build.appVersion}</code>
</dd>
</div>
<div>
<dt></dt>
<dd>
<span className={`badge badge-${build.status}`}>
{statusLabel(build.status)}
</span>
</dd>
</div>
</dl>
{build.status === "completed" ? (
<>
<h3></h3>
<div className="download-row">
{build.windowsUrl ? (
<a
className="btn btn-primary"
href={build.windowsUrl}
target="_blank"
rel="noreferrer"
>
Windows
</a>
) : (
<span className="muted">Windows </span>
)}
{build.androidUrl ? (
<a
className="btn btn-secondary"
href={build.androidUrl}
target="_blank"
rel="noreferrer"
>
Android
</a>
) : (
<span className="muted">Android </span>
)}
</div>
</>
) : null}
{build.status === "failed" ? (
<div className="error-box">
<p>{build.error ?? "构建失败,请查看 GitHub Actions 日志。"}</p>
{build.actionsUrl ? (
<a href={build.actionsUrl} target="_blank" rel="noreferrer">
Actions
</a>
) : null}
</div>
) : null}
{build.status !== "completed" && build.status !== "failed" ? (
<p className="prose" style={{ marginTop: "1rem" }}>
GitHub Actions
</p>
) : null}
</>
) : (
<p className="prose"></p>
)}
</section>
</>
);
}

View File

@@ -0,0 +1,153 @@
import { FormEvent, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createBuild } from "../api/client";
import { getDefaultAppVersion } from "../lib/version";
export default function Upload() {
const navigate = useNavigate();
const [appNameZh, setAppNameZh] = useState("");
const [appNameEn, setAppNameEn] = useState("");
const [appVersion, setAppVersion] = useState(() => getDefaultAppVersion());
const [identifier, setIdentifier] = useState("");
const [file, setFile] = useState<File | null>(null);
const [icon, setIcon] = useState<File | null>(null);
const [iconPreview, setIconPreview] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!icon) {
setIconPreview(null);
return;
}
const url = URL.createObjectURL(icon);
setIconPreview(url);
return () => URL.revokeObjectURL(url);
}, [icon]);
async function onSubmit(event: FormEvent) {
event.preventDefault();
if (!file) {
setError("请选择 zip 文件");
return;
}
setLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("appNameZh", appNameZh);
formData.append("appNameEn", appNameEn);
formData.append("appVersion", appVersion.trim());
if (identifier.trim()) {
formData.append("identifier", identifier.trim());
}
if (icon) {
formData.append("icon", icon);
}
const result = await createBuild(formData);
navigate(`/jobs/${result.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "上传失败");
} finally {
setLoading(false);
}
}
return (
<>
<h1 className="page-title"></h1>
<p className="page-lead">
index.html zip Windows Android
</p>
<section className="doc-section">
<h2>使</h2>
<p className="prose">
index.html 50MB
PNG / JPG / ICO zip
2026.5.29 Bundle ID
</p>
</section>
<section className="doc-section">
<h2></h2>
<form className="form" onSubmit={onSubmit}>
<label>
<input
type="text"
value={appNameZh}
onChange={(e) => setAppNameZh(e.target.value)}
placeholder="我的应用"
required
/>
</label>
<label>
<input
type="text"
value={appNameEn}
onChange={(e) => setAppNameEn(e.target.value)}
placeholder="My App"
required
pattern="[a-zA-Z][a-zA-Z0-9 _.-]*"
title="以字母开头,仅含英文字母、数字、空格、下划线和连字符"
/>
</label>
<label>
<input
type="text"
value={appVersion}
onChange={(e) => setAppVersion(e.target.value)}
placeholder={getDefaultAppVersion()}
required
pattern="\d{4}\.\d{1,2}\.\d{1,2}"
title="格式YYYY.M.D例如 2026.5.29"
/>
</label>
<label>
Bundle ID
<input
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="com.example.myapp"
/>
</label>
<label>
<input
type="file"
accept=".png,.jpg,.jpeg,.ico,image/png,image/jpeg,image/x-icon"
onChange={(e) => setIcon(e.target.files?.[0] ?? null)}
/>
</label>
{iconPreview ? (
<div className="icon-preview">
<img src={iconPreview} alt="图标预览" width={64} height={64} />
<span className="muted">{icon?.name}</span>
</div>
) : null}
<label>
zip
<input
type="file"
accept=".zip,application/zip"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
required
/>
</label>
{error ? <p className="error-text">{error}</p> : null}
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? "上传并触发构建…" : "开始构建"}
</button>
</form>
</section>
</>
);
}

375
frontend/src/styles/doc.css Normal file
View File

@@ -0,0 +1,375 @@
:root {
color-scheme: light;
--bg: #fafafa;
--surface: #ffffff;
--text: #1a1a1a;
--muted: #5c5c5c;
--border: #e5e5e5;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--danger: #b91c1c;
--danger-bg: #fef2f2;
--success: #15803d;
--warning: #a16207;
--radius: 8px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans SC", sans-serif;
--mono: ui-monospace, "Cascadia Code", "Segoe UI Mono", monospace;
--content-max: 42rem;
--page-pad: 1rem;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--font);
font-size: 1rem;
line-height: 1.6;
color: var(--text);
background: var(--bg);
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.site-header {
border-bottom: 1px solid var(--border);
background: var(--surface);
padding: 0.75rem var(--page-pad);
}
.site-header-inner {
max-width: var(--content-max);
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem 1rem;
}
.site-brand {
font-weight: 600;
font-size: 1.125rem;
color: var(--text);
text-decoration: none;
}
.site-brand:hover {
text-decoration: none;
color: var(--accent);
}
.site-nav {
display: flex;
gap: 1rem;
font-size: 0.9375rem;
}
.site-main {
flex: 1;
padding: 1.5rem var(--page-pad) 3rem;
}
.content {
max-width: var(--content-max);
margin: 0 auto;
}
.page-title {
margin: 0 0 0.25rem;
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
}
.page-lead {
margin: 0 0 1.5rem;
color: var(--muted);
font-size: 1rem;
}
.doc-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.25rem;
margin-bottom: 1.25rem;
}
.doc-section h2 {
margin: 0 0 0.75rem;
font-size: 1.125rem;
font-weight: 600;
}
.doc-section h3 {
margin: 1.25rem 0 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.prose {
margin: 0 0 1rem;
color: var(--muted);
font-size: 0.9375rem;
}
.prose:last-child {
margin-bottom: 0;
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form label {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
}
.form input[type="text"],
.form input[type="file"] {
font: inherit;
padding: 0.625rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
min-height: 44px;
}
.form input[type="file"] {
padding: 0.5rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.625rem 1.25rem;
font: inherit;
font-weight: 500;
font-size: 0.9375rem;
border: none;
border-radius: var(--radius);
cursor: pointer;
text-decoration: none;
transition: background 0.15s ease;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--bg);
text-decoration: none;
}
.error-text {
margin: 0;
color: var(--danger);
font-size: 0.875rem;
}
.error-box {
margin-top: 1rem;
padding: 1rem;
background: var(--danger-bg);
border: 1px solid #fecaca;
border-radius: var(--radius);
font-size: 0.9375rem;
}
.error-box p {
margin: 0 0 0.5rem;
}
.meta-list {
margin: 0;
display: grid;
gap: 0.75rem;
}
.meta-list > div {
display: grid;
grid-template-columns: minmax(7rem, 9rem) 1fr;
gap: 0.5rem 1rem;
align-items: baseline;
font-size: 0.9375rem;
}
.meta-list dt {
margin: 0;
color: var(--muted);
font-weight: 500;
}
.meta-list dd {
margin: 0;
word-break: break-word;
}
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 4px;
background: #f3f4f6;
color: var(--text);
}
.badge-pending,
.badge-queued {
background: #fef3c7;
color: var(--warning);
}
.badge-in_progress {
background: #dbeafe;
color: var(--accent);
}
.badge-completed {
background: #dcfce7;
color: var(--success);
}
.badge-failed {
background: var(--danger-bg);
color: var(--danger);
}
.download-row {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.5rem;
}
.muted {
color: var(--muted);
font-size: 0.875rem;
}
.status-toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.build-list {
list-style: none;
margin: 0;
padding: 0;
}
.build-list li {
border-top: 1px solid var(--border);
padding: 0.75rem 0;
}
.build-list li:first-child {
border-top: none;
padding-top: 0;
}
.build-list a {
font-weight: 500;
}
.build-list .meta-line {
font-size: 0.8125rem;
color: var(--muted);
margin-top: 0.25rem;
}
.icon-preview {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
}
.icon-preview img {
object-fit: contain;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
}
.site-footer {
border-top: 1px solid var(--border);
padding: 1rem var(--page-pad);
text-align: center;
font-size: 0.8125rem;
color: var(--muted);
}
@media (min-width: 768px) {
:root {
--page-pad: 1.5rem;
}
.page-title {
font-size: 2rem;
}
.doc-section {
padding: 1.5rem 1.75rem;
}
.download-row {
flex-direction: row;
flex-wrap: wrap;
}
}

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +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"}

View File

@@ -4,7 +4,9 @@
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}

View File

@@ -7,9 +7,13 @@ export default defineConfig({
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:3001",
target: "http://127.0.0.1:8787",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
});

3713
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,22 +4,21 @@
"version": "0.1.0",
"type": "module",
"workspaces": [
"web",
"server",
"frontend",
"worker",
"template"
],
"scripts": {
"dev": "concurrently -n server,web -c blue,green \"npm run dev:server\" \"npm run dev:web:wait\"",
"dev:web:wait": "wait-on http://127.0.0.1:3001/api/health -t 120000 && npm run dev -w web",
"dev:web": "npm run dev -w web",
"dev:server": "npm run dev -w server",
"build": "npm run build -w web && npm run build -w server",
"start": "npm run start -w server"
"dev": "concurrently -n web,worker -c green,blue \"npm run dev -w frontend\" \"wrangler dev\"",
"build": "npm run build -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"
},
"devDependencies": {
"adm-zip": "^0.5.17",
"concurrently": "^9.1.2",
"typescript": "~5.6.2",
"wait-on": "^8.0.5"
"wrangler": "^3.99.0"
}
}

View File

@@ -1,131 +0,0 @@
import Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export type BuildStatus =
| "pending"
| "queued"
| "in_progress"
| "completed"
| "failed";
export interface BuildRecord {
id: string;
app_name: string;
app_name_en: string;
app_identifier: string;
status: BuildStatus;
workflow_run_id: number | null;
windows_url: string | null;
android_url: string | null;
error: string | null;
created_at: string;
updated_at: string;
}
let db: Database.Database | null = null;
export function getDb(): Database.Database {
if (db) return db;
const dataDir = path.resolve(__dirname, "../../data");
fs.mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, "web2app.db");
db = new Database(dbPath);
const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf8");
db.exec(schema);
migrateBuildsTable(db);
return db;
}
function migrateBuildsTable(database: Database.Database) {
const columns = database
.prepare("PRAGMA table_info(builds)")
.all() as Array<{ name: string }>;
if (!columns.some((column) => column.name === "app_name_en")) {
database.exec("ALTER TABLE builds ADD COLUMN app_name_en TEXT NOT NULL DEFAULT ''");
}
}
export function insertBuild(record: {
id: string;
appName: string;
appNameEn: string;
appIdentifier: string;
}): BuildRecord {
const database = getDb();
database
.prepare(
`INSERT INTO builds (id, app_name, app_name_en, app_identifier, status)
VALUES (@id, @appName, @appNameEn, @appIdentifier, 'pending')`,
)
.run(record);
return getBuild(record.id)!;
}
export function getBuild(id: string): BuildRecord | null {
const row = getDb()
.prepare("SELECT * FROM builds WHERE id = ?")
.get(id) as BuildRecord | undefined;
return row ?? null;
}
export function listBuilds(limit = 20): BuildRecord[] {
return getDb()
.prepare("SELECT * FROM builds ORDER BY created_at DESC LIMIT ?")
.all(limit) as BuildRecord[];
}
export function updateBuild(
id: string,
patch: Partial<
Pick<
BuildRecord,
| "status"
| "workflow_run_id"
| "windows_url"
| "android_url"
| "error"
>
>,
): BuildRecord | null {
const fields: string[] = [];
const params: Record<string, unknown> = { id };
for (const [key, value] of Object.entries(patch)) {
if (value !== undefined) {
fields.push(`${key} = @${key}`);
params[key] = value;
}
}
if (fields.length === 0) return getBuild(id);
fields.push("updated_at = datetime('now')");
getDb()
.prepare(`UPDATE builds SET ${fields.join(", ")} WHERE id = @id`)
.run(params);
return getBuild(id);
}
export function toPublicBuild(record: BuildRecord) {
return {
id: record.id,
appName: record.app_name,
appNameEn: record.app_name_en || record.app_name,
appIdentifier: record.app_identifier,
status: record.status,
workflowRunId: record.workflow_run_id,
windowsUrl: record.windows_url,
androidUrl: record.android_url,
error: record.error,
createdAt: record.created_at,
updatedAt: record.updated_at,
};
}

View File

@@ -1,57 +0,0 @@
import "./load-env.js";
import cors from "cors";
import express from "express";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { MulterError } from "multer";
import { buildsRouter } from "./routes/builds.js";
const app = express();
const port = Number(process.env.PORT ?? "3001");
app.use(cors());
app.use(express.json());
app.get("/api/health", (_req, res) => {
res.json({ ok: true });
});
app.use("/api/builds", buildsRouter);
app.use((err: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
if (res.headersSent) {
next(err);
return;
}
if (err instanceof MulterError) {
const status = err.code === "LIMIT_FILE_SIZE" ? 413 : 400;
res.status(status).json({ error: err.message });
return;
}
if (err instanceof Error) {
console.error(err);
res.status(500).json({ error: err.message });
return;
}
console.error(err);
res.status(500).json({ error: "Internal server error" });
});
const webDist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../web/dist");
app.use(express.static(webDist));
app.get("*", (_req, res, next) => {
if (_req.path.startsWith("/api")) {
next();
return;
}
res.sendFile(path.join(webDist, "index.html"), (err) => {
if (err) next();
});
});
app.listen(port, "127.0.0.1", () => {
console.log(`Web2App server listening on http://127.0.0.1:${port}`);
});

View File

@@ -1,13 +0,0 @@
import dotenv from "dotenv";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const serverDir = path.dirname(fileURLToPath(import.meta.url));
const rootEnv = path.resolve(serverDir, "../.env");
if (fs.existsSync(rootEnv)) {
dotenv.config({ path: rootEnv });
} else {
dotenv.config();
}

View File

@@ -1,35 +0,0 @@
{
"name": "web2app-server",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"postinstall": "node -e \"try{require('better-sqlite3')}catch{process.exit(1)}\" || npm rebuild better-sqlite3",
"predev": "npm rebuild better-sqlite3",
"dev": "tsx watch index.ts",
"prebuild": "npm rebuild better-sqlite3",
"build": "tsc",
"prestart": "npm rebuild better-sqlite3",
"start": "node dist/index.js"
},
"dependencies": {
"adm-zip": "^0.5.16",
"better-sqlite3": "^11.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"multer": "^1.4.5-lts.1",
"nanoid": "^5.1.0",
"octokit": "^4.1.2"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.5",
"tsx": "^4.19.2",
"typescript": "~5.6.2"
}
}

View File

@@ -1,184 +0,0 @@
import { Router } from "express";
import multer from "multer";
import { nanoid } from "nanoid";
import {
getBuild,
insertBuild,
listBuilds,
toPublicBuild,
updateBuild,
type BuildStatus,
} from "../db/index.js";
import {
getActionsRunUrl,
getReleaseAssets,
getWorkflowRun,
triggerBuildWorkflow,
uploadSiteZip,
} from "../services/github.js";
import {
normalizeAppIdentifier,
slugifyIdentifier,
validateChineseAppName,
validateEnglishAppName,
validateZipBuffer,
ZipValidationError,
} from "../services/zip.js";
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 100 * 1024 * 1024 },
});
const maxUploadMb = Number(process.env.MAX_UPLOAD_MB ?? "50");
const maxUploadBytes = maxUploadMb * 1024 * 1024;
export const buildsRouter = Router();
buildsRouter.get("/", (_req, res) => {
const builds = listBuilds().map(toPublicBuild);
res.json({ builds });
});
buildsRouter.get("/:id", async (req, res) => {
const record = getBuild(req.params.id);
if (!record) {
res.status(404).json({ error: "Build not found" });
return;
}
const refreshed = await refreshBuildStatus(record);
res.json({
...toPublicBuild(refreshed),
actionsUrl: getActionsRunUrl(refreshed.workflow_run_id),
});
});
buildsRouter.post("/", upload.single("file"), async (req, res) => {
try {
const appNameZh = validateChineseAppName(
String(req.body.appNameZh ?? req.body.appName ?? ""),
);
const appNameEn = validateEnglishAppName(
String(req.body.appNameEn ?? ""),
);
const identifierInput = String(req.body.identifier ?? "").trim();
if (!req.file) {
res.status(400).json({ error: "file is required" });
return;
}
if (!req.file.originalname.toLowerCase().endsWith(".zip")) {
res.status(400).json({ error: "Only .zip files are supported" });
return;
}
const { normalizedBuffer } = validateZipBuffer(req.file.buffer, maxUploadBytes);
const jobId = nanoid(10);
const slug = slugifyIdentifier(appNameEn) || jobId.toLowerCase();
const appIdentifier = normalizeAppIdentifier(
identifierInput || `com.web2app.${slug}`,
);
insertBuild({
id: jobId,
appName: appNameZh,
appNameEn,
appIdentifier,
});
await uploadSiteZip(jobId, normalizedBuffer);
updateBuild(jobId, { status: "queued" });
const workflowRunId = await triggerBuildWorkflow({
jobId,
appName: appNameZh,
appNameEn,
appIdentifier,
});
updateBuild(jobId, {
workflow_run_id: workflowRunId,
status: "in_progress",
});
res.status(201).json({
id: jobId,
status: "in_progress",
workflowRunId,
});
} catch (error) {
if (error instanceof ZipValidationError) {
res.status(400).json({ error: error.message });
return;
}
console.error(error);
res.status(500).json({
error: error instanceof Error ? error.message : "Failed to create build",
});
}
});
async function refreshBuildStatus(record: ReturnType<typeof getBuild> & object) {
if (!record.workflow_run_id) return record;
if (record.status === "completed" || record.status === "failed") {
return record;
}
try {
const run = await getWorkflowRun(record.workflow_run_id);
if (run.status === "queued") {
return updateBuild(record.id, { status: "queued" }) ?? record;
}
if (run.status === "in_progress") {
return updateBuild(record.id, { status: "in_progress" }) ?? record;
}
if (run.status === "completed") {
if (run.conclusion === "success") {
const assets = await getReleaseAssets(record.id);
return (
updateBuild(record.id, {
status: "completed",
windows_url: assets.windowsUrl,
android_url: assets.androidUrl,
error:
assets.windowsUrl && assets.androidUrl
? null
: "Build finished but release assets were not found yet",
}) ?? record
);
}
return (
updateBuild(record.id, {
status: "failed",
error: `Workflow failed with conclusion: ${run.conclusion ?? "unknown"}`,
}) ?? record
);
}
return record;
} catch (error) {
return (
updateBuild(record.id, {
status: "failed",
error: error instanceof Error ? error.message : "Status refresh failed",
}) ?? record
);
}
}
export function buildPublicMeta(record: ReturnType<typeof getBuild>) {
if (!record) return null;
return {
...toPublicBuild(record),
actionsUrl: getActionsRunUrl(record.workflow_run_id),
};
}
export type { BuildStatus };

View File

@@ -1,169 +0,0 @@
import "../load-env.js";
import { Octokit } from "octokit";
function getGitHubConfig() {
return {
owner: (process.env.GITHUB_OWNER ?? "").trim(),
repo: (process.env.GITHUB_REPO ?? "").trim(),
token: (process.env.GITHUB_TOKEN ?? "").trim(),
};
}
function getOctokit(): Octokit {
const { owner, repo, token } = getGitHubConfig();
if (!owner || !repo || !token) {
throw new Error(
"Missing GITHUB_OWNER, GITHUB_REPO, or GITHUB_TOKEN environment variables",
);
}
return new Octokit({ auth: token });
}
export function getRepoInfo() {
const { owner, repo } = getGitHubConfig();
return { owner, repo };
}
export async function uploadSiteZip(jobId: string, zipBuffer: Buffer): Promise<void> {
const { owner, repo } = getGitHubConfig();
const octokit = getOctokit();
const path = `builds/${jobId}/site.zip`;
const content = zipBuffer.toString("base64");
try {
await octokit.rest.repos.createOrUpdateFileContents({
owner,
repo,
path,
message: `chore: upload site for build ${jobId}`,
content,
});
} catch (error: unknown) {
const status = (error as { status?: number }).status;
if (status !== 422) throw error;
const existing = await octokit.rest.repos.getContent({ owner, repo, path });
if (Array.isArray(existing.data) || existing.data.type !== "file") {
throw new Error(`Unexpected content at ${path}`);
}
await octokit.rest.repos.createOrUpdateFileContents({
owner,
repo,
path,
message: `chore: update site for build ${jobId}`,
content,
sha: existing.data.sha,
});
}
}
export async function triggerBuildWorkflow(input: {
jobId: string;
appName: string;
appNameEn: string;
appIdentifier: string;
}): Promise<number> {
const { owner, repo } = getGitHubConfig();
const octokit = getOctokit();
await octokit.rest.actions.createWorkflowDispatch({
owner,
repo,
workflow_id: "build-app.yml",
ref: process.env.DEFAULT_BRANCH ?? "main",
inputs: {
job_id: input.jobId,
app_name: input.appName,
app_name_en: input.appNameEn,
app_identifier: input.appIdentifier,
},
});
await sleep(3000);
const since = new Date(Date.now() - 120_000).toISOString();
const runs = await octokit.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: "build-app.yml",
event: "workflow_dispatch",
per_page: 10,
});
const run = runs.data.workflow_runs
.filter((item) => item.created_at >= since)
.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
)[0];
if (!run) {
throw new Error("Failed to locate workflow run after dispatch");
}
return run.id;
}
export async function getWorkflowRun(runId: number) {
const { owner, repo } = getGitHubConfig();
const octokit = getOctokit();
const { data } = await octokit.rest.actions.getWorkflowRun({
owner,
repo,
run_id: runId,
});
return data;
}
export async function getReleaseAssets(jobId: string): Promise<{
windowsUrl: string | null;
androidUrl: string | null;
}> {
const { owner, repo } = getGitHubConfig();
const octokit = getOctokit();
const tag = `build-${jobId}`;
try {
const { data: release } = await octokit.rest.repos.getReleaseByTag({
owner,
repo,
tag,
});
let windowsUrl: 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"))
) {
windowsUrl = asset.browser_download_url;
}
if (
!androidUrl &&
(name.endsWith(".apk") || name.includes("android"))
) {
androidUrl = asset.browser_download_url;
}
}
return { windowsUrl, androidUrl };
} catch {
return { windowsUrl: null, androidUrl: null };
}
}
export function getActionsRunUrl(runId: number | null): string | null {
const { owner, repo } = getGitHubConfig();
if (!runId || !owner || !repo) return null;
return `https://github.com/${owner}/${repo}/actions/runs/${runId}`;
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": ".",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["./**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -1,23 +0,0 @@
import { Route, Routes } from "react-router-dom";
import JobStatus from "./pages/JobStatus";
import Upload from "./pages/Upload";
export default function App() {
return (
<div className="app-shell">
<header className="app-header">
<div>
<p className="eyebrow">Web2App</p>
<h1></h1>
</div>
<p className="subtitle"> zip index.html Windows Android </p>
</header>
<main>
<Routes>
<Route path="/" element={<Upload />} />
<Route path="/jobs/:id" element={<JobStatus />} />
</Routes>
</main>
</div>
);
}

View File

@@ -1,124 +0,0 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { fetchBuild, statusLabel, type Build } from "../api";
export default function JobStatus() {
const { id } = useParams<{ id: string }>();
const [build, setBuild] = useState<Build | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
let cancelled = false;
async function poll() {
try {
const data = await fetchBuild(id!);
if (cancelled) return;
setBuild(data);
setError(null);
if (data.status === "completed" || data.status === "failed") {
return;
}
window.setTimeout(poll, 5000);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : "加载失败");
window.setTimeout(poll, 5000);
}
}
poll();
return () => {
cancelled = true;
};
}, [id]);
if (!id) {
return <p className="error"> ID</p>;
}
return (
<section className="card">
<div className="status-header">
<h2></h2>
<Link to="/"></Link>
</div>
{error ? <p className="error">{error}</p> : null}
{build ? (
<>
<dl className="meta">
<div>
<dt> ID</dt>
<dd>{build.id}</dd>
</div>
<div>
<dt></dt>
<dd>{build.appName}</dd>
</div>
<div>
<dt></dt>
<dd>{build.appNameEn}</dd>
</div>
<div>
<dt>Bundle ID</dt>
<dd>{build.appIdentifier}</dd>
</div>
<div>
<dt></dt>
<dd>
<span className={`badge badge-${build.status}`}>
{statusLabel(build.status)}
</span>
</dd>
</div>
</dl>
{build.status === "completed" ? (
<div className="downloads">
<h3></h3>
<div className="download-actions">
{build.windowsUrl ? (
<a className="button" href={build.windowsUrl} target="_blank" rel="noreferrer">
Windows
</a>
) : (
<span className="muted">Windows </span>
)}
{build.androidUrl ? (
<a className="button secondary" href={build.androidUrl} target="_blank" rel="noreferrer">
Android
</a>
) : (
<span className="muted">Android </span>
)}
</div>
</div>
) : null}
{build.status === "failed" ? (
<div className="error-box">
<p>{build.error ?? "构建失败,请查看 GitHub Actions 日志。"}</p>
{build.actionsUrl ? (
<a href={build.actionsUrl} target="_blank" rel="noreferrer">
Actions
</a>
) : null}
</div>
) : null}
{build.status !== "completed" && build.status !== "failed" ? (
<p className="hint"> GitHub Actions ...</p>
) : null}
</>
) : (
<p className="hint">...</p>
)}
</section>
);
}

View File

@@ -1,95 +0,0 @@
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createBuild } from "../api";
export default function Upload() {
const navigate = useNavigate();
const [appNameZh, setAppNameZh] = useState("");
const [appNameEn, setAppNameEn] = useState("");
const [identifier, setIdentifier] = useState("");
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(event: FormEvent) {
event.preventDefault();
if (!file) {
setError("请选择 zip 文件");
return;
}
setLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("appNameZh", appNameZh);
formData.append("appNameEn", appNameEn);
if (identifier.trim()) {
formData.append("identifier", identifier.trim());
}
const result = await createBuild(formData);
navigate(`/jobs/${result.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "上传失败");
} finally {
setLoading(false);
}
}
return (
<section className="card">
<h2></h2>
<p className="hint">
index.html 50MB zip logo.png
favicon.ico logo.png
Bundle ID
</p>
<form className="form" onSubmit={onSubmit}>
<label>
<input
value={appNameZh}
onChange={(e) => setAppNameZh(e.target.value)}
placeholder="我的应用"
required
/>
</label>
<label>
<input
value={appNameEn}
onChange={(e) => setAppNameEn(e.target.value)}
placeholder="My App"
required
pattern="[a-zA-Z][a-zA-Z0-9 _.-]*"
title="以字母开头,仅含英文字母、数字、空格、下划线和连字符"
/>
</label>
<label>
Bundle ID
<input
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="com.example.myapp"
/>
</label>
<label>
zip
<input
type="file"
accept=".zip,application/zip"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
required
/>
</label>
{error ? <p className="error">{error}</p> : null}
<button type="submit" disabled={loading}>
{loading ? "上传并触发构建..." : "开始构建"}
</button>
</form>
</section>
);
}

View File

@@ -1,195 +0,0 @@
:root {
color-scheme: light dark;
font-family: Inter, Segoe UI, system-ui, sans-serif;
line-height: 1.5;
background: #0f172a;
color: #e2e8f0;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(56, 189, 248, 0.18), transparent 35%),
#0f172a;
}
a {
color: #38bdf8;
}
.app-shell {
max-width: 880px;
margin: 0 auto;
padding: 48px 20px 80px;
}
.app-header {
margin-bottom: 32px;
}
.eyebrow {
margin: 0 0 8px;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 12px;
color: #94a3b8;
}
.app-header h1 {
margin: 0 0 8px;
font-size: 2.4rem;
}
.subtitle {
margin: 0;
color: #94a3b8;
}
.card {
background: rgba(15, 23, 42, 0.82);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 20px;
padding: 28px;
backdrop-filter: blur(12px);
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.35);
}
.card h2,
.card h3 {
margin-top: 0;
}
.form {
display: grid;
gap: 16px;
}
label {
display: grid;
gap: 8px;
font-weight: 600;
}
input[type="text"],
input[type="file"] {
width: 100%;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(2, 6, 23, 0.65);
color: inherit;
}
button,
.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 18px;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, #0284c7, #2563eb);
color: white;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.button.secondary {
background: linear-gradient(135deg, #059669, #047857);
}
.hint,
.muted {
color: #94a3b8;
}
.error {
color: #fca5a5;
}
.error-box {
margin-top: 16px;
padding: 16px;
border-radius: 12px;
background: rgba(127, 29, 29, 0.25);
border: 1px solid rgba(248, 113, 113, 0.35);
}
.status-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.meta {
display: grid;
gap: 12px;
margin: 20px 0;
}
.meta div {
display: grid;
grid-template-columns: 120px 1fr;
gap: 12px;
}
.meta dt {
color: #94a3b8;
}
.meta dd {
margin: 0;
}
.badge {
display: inline-flex;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.875rem;
background: rgba(148, 163, 184, 0.18);
}
.badge-completed {
background: rgba(34, 197, 94, 0.18);
color: #86efac;
}
.badge-failed {
background: rgba(239, 68, 68, 0.18);
color: #fca5a5;
}
.badge-in_progress,
.badge-queued,
.badge-pending {
background: rgba(56, 189, 248, 0.18);
color: #7dd3fc;
}
.downloads {
margin-top: 24px;
}
.download-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
@media (max-width: 640px) {
.meta div {
grid-template-columns: 1fr;
}
}

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1 +0,0 @@
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/pages/jobstatus.tsx","./src/pages/upload.tsx"],"version":"5.6.3"}

View File

@@ -1 +0,0 @@
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/pages/jobstatus.tsx","./src/pages/upload.tsx"],"errors":true,"version":"5.6.3"}

View File

@@ -1,14 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:3001",
changeOrigin: true,
},
},
},
});

View File

@@ -0,0 +1 @@
ALTER TABLE builds ADD COLUMN app_version TEXT NOT NULL DEFAULT '1.0.0';

13
worker/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "web2app-worker",
"private": true,
"version": "0.1.0",
"type": "module",
"dependencies": {
"fflate": "^0.8.2",
"nanoid": "^5.0.9"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241127.0"
}
}

128
worker/src/db/builds.ts Normal file
View File

@@ -0,0 +1,128 @@
import type { Env } from "../env";
export type BuildStatus =
| "pending"
| "queued"
| "in_progress"
| "completed"
| "failed";
export interface BuildRecord {
id: string;
app_name: string;
app_name_en: string;
app_identifier: string;
app_version: string;
status: BuildStatus;
workflow_run_id: number | null;
windows_url: string | null;
android_url: string | null;
error: string | null;
created_at: string;
updated_at: string;
}
export async function insertBuild(
env: Env,
record: {
id: string;
appName: string;
appNameEn: string;
appIdentifier: string;
appVersion: string;
},
): Promise<BuildRecord> {
await env.DB.prepare(
`INSERT INTO builds (id, app_name, app_name_en, app_identifier, app_version, status)
VALUES (?, ?, ?, ?, ?, 'pending')`,
)
.bind(
record.id,
record.appName,
record.appNameEn,
record.appIdentifier,
record.appVersion,
)
.run();
const row = await getBuild(env, record.id);
if (!row) throw new Error("Failed to insert build");
return row;
}
export async function getBuild(
env: Env,
id: string,
): Promise<BuildRecord | null> {
const row = await env.DB.prepare("SELECT * FROM builds WHERE id = ?")
.bind(id)
.first<BuildRecord>();
return row ?? null;
}
export async function listBuilds(
env: Env,
limit = 20,
): Promise<BuildRecord[]> {
const { results } = await env.DB.prepare(
"SELECT * FROM builds ORDER BY created_at DESC LIMIT ?",
)
.bind(limit)
.all<BuildRecord>();
return results ?? [];
}
export async function updateBuild(
env: Env,
id: string,
patch: Partial<
Pick<
BuildRecord,
| "status"
| "workflow_run_id"
| "windows_url"
| "android_url"
| "error"
>
>,
): Promise<BuildRecord | null> {
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, value] of Object.entries(patch)) {
if (value !== undefined) {
fields.push(`${key} = ?`);
values.push(value);
}
}
if (fields.length === 0) return getBuild(env, id);
fields.push("updated_at = datetime('now')");
values.push(id);
await env.DB.prepare(
`UPDATE builds SET ${fields.join(", ")} WHERE id = ?`,
)
.bind(...values)
.run();
return getBuild(env, id);
}
export function toPublicBuild(record: BuildRecord) {
return {
id: record.id,
appName: record.app_name,
appNameEn: record.app_name_en || record.app_name,
appIdentifier: record.app_identifier,
appVersion: record.app_version || "1.0.0",
status: record.status,
workflowRunId: record.workflow_run_id,
windowsUrl: record.windows_url,
androidUrl: record.android_url,
error: record.error,
createdAt: record.created_at,
updatedAt: record.updated_at,
};
}

9
worker/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
export interface Env {
DB: D1Database;
ASSETS: Fetcher;
GITHUB_TOKEN: string;
GITHUB_OWNER: string;
GITHUB_REPO: string;
DEFAULT_BRANCH?: string;
MAX_UPLOAD_MB?: string;
}

19
worker/src/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { Env } from "./env";
import { handleBuildsRequest } from "./routes/builds";
import { jsonResponse } from "./lib/response";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/api/health") {
return jsonResponse({ ok: true });
}
if (url.pathname.startsWith("/api/builds")) {
return handleBuildsRequest(request, env, url);
}
return env.ASSETS.fetch(request);
},
};

View File

@@ -0,0 +1,17 @@
export function jsonResponse(
data: unknown,
status = 200,
headers?: Record<string, string>,
): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
"Content-Type": "application/json",
...headers,
},
});
}
export function errorResponse(message: string, status: number): Response {
return jsonResponse({ error: message }, status);
}

226
worker/src/routes/builds.ts Normal file
View File

@@ -0,0 +1,226 @@
import { nanoid } from "nanoid";
import type { Env } from "../env";
import {
getBuild,
insertBuild,
listBuilds,
toPublicBuild,
updateBuild,
type BuildRecord,
} from "../db/builds";
import { errorResponse, jsonResponse } from "../lib/response";
import {
getActionsRunUrl,
getReleaseAssets,
getWorkflowRun,
triggerBuildWorkflow,
uploadBuildFile,
uploadSiteZip,
} from "../services/github";
import { IconValidationError, resolveIconUpload } from "../services/icon";
import {
validateAppVersion,
VersionValidationError,
} from "../services/version";
import {
normalizeAppIdentifier,
slugifyIdentifier,
validateChineseAppName,
validateEnglishAppName,
validateZipBuffer,
ZipValidationError,
} from "../services/zip";
function maxUploadBytes(env: Env): number {
const mb = Number(env.MAX_UPLOAD_MB ?? "50");
return mb * 1024 * 1024;
}
export async function handleBuildsRequest(
request: Request,
env: Env,
url: URL,
): Promise<Response> {
const path = url.pathname;
if (path === "/api/builds" && request.method === "GET") {
const builds = (await listBuilds(env)).map(toPublicBuild);
return jsonResponse({ builds });
}
const match = path.match(/^\/api\/builds\/([^/]+)$/);
if (match) {
const id = match[1];
if (request.method === "GET") {
const record = await getBuild(env, id);
if (!record) {
return errorResponse("Build not found", 404);
}
const refreshed = await refreshBuildStatus(env, record);
return jsonResponse({
...toPublicBuild(refreshed),
actionsUrl: getActionsRunUrl(env, refreshed.workflow_run_id),
});
}
return errorResponse("Method not allowed", 405);
}
if (path === "/api/builds" && request.method === "POST") {
return createBuild(request, env);
}
return errorResponse("Not found", 404);
}
async function createBuild(request: Request, env: Env): Promise<Response> {
try {
const formData = await request.formData();
const appNameZh = validateChineseAppName(
String(formData.get("appNameZh") ?? formData.get("appName") ?? ""),
);
const appNameEn = validateEnglishAppName(
String(formData.get("appNameEn") ?? ""),
);
const identifierInput = String(formData.get("identifier") ?? "").trim();
const appVersion = validateAppVersion(
String(formData.get("appVersion") ?? ""),
);
const file = formData.get("file");
if (!(file instanceof File)) {
return errorResponse("file is required", 400);
}
if (!file.name.toLowerCase().endsWith(".zip")) {
return errorResponse("Only .zip files are supported", 400);
}
const buffer = new Uint8Array(await file.arrayBuffer());
const { normalizedBuffer } = validateZipBuffer(buffer, maxUploadBytes(env));
const jobId = nanoid(10);
const slug = slugifyIdentifier(appNameEn) || jobId.toLowerCase();
const appIdentifier = normalizeAppIdentifier(
identifierInput || `com.web2app.${slug}`,
);
await insertBuild(env, {
id: jobId,
appName: appNameZh,
appNameEn,
appIdentifier,
appVersion,
});
await uploadSiteZip(env, jobId, normalizedBuffer);
await uploadBuildFile(
env,
jobId,
"version.txt",
new TextEncoder().encode(appVersion),
);
const iconFile = formData.get("icon");
if (iconFile instanceof File && iconFile.size > 0) {
const iconBuffer = new Uint8Array(await iconFile.arrayBuffer());
const { repoPath } = resolveIconUpload(iconFile, iconBuffer);
await uploadBuildFile(env, jobId, repoPath, iconBuffer);
}
await updateBuild(env, jobId, { status: "queued" });
const workflowRunId = await triggerBuildWorkflow(env, {
jobId,
appName: appNameZh,
appNameEn,
appIdentifier,
});
await updateBuild(env, jobId, {
workflow_run_id: workflowRunId,
status: "in_progress",
});
return jsonResponse(
{
id: jobId,
status: "in_progress",
workflowRunId,
},
201,
);
} catch (error) {
if (
error instanceof ZipValidationError ||
error instanceof VersionValidationError ||
error instanceof IconValidationError
) {
return errorResponse(error.message, 400);
}
console.error(error);
return errorResponse(
error instanceof Error ? error.message : "Failed to create build",
500,
);
}
}
async function refreshBuildStatus(
env: Env,
record: BuildRecord,
): Promise<BuildRecord> {
if (!record.workflow_run_id) return record;
if (record.status === "completed" || record.status === "failed") {
return record;
}
try {
const run = await getWorkflowRun(env, record.workflow_run_id);
if (run.status === "queued") {
return (await updateBuild(env, record.id, { status: "queued" })) ?? record;
}
if (run.status === "in_progress") {
return (
(await updateBuild(env, record.id, { status: "in_progress" })) ?? record
);
}
if (run.status === "completed") {
if (run.conclusion === "success") {
const assets = await getReleaseAssets(env, record.id);
return (
(await updateBuild(env, record.id, {
status: "completed",
windows_url: assets.windowsUrl,
android_url: assets.androidUrl,
error:
assets.windowsUrl && assets.androidUrl
? null
: "Build finished but release assets were not found yet",
})) ?? record
);
}
return (
(await updateBuild(env, record.id, {
status: "failed",
error: `Workflow failed with conclusion: ${run.conclusion ?? "unknown"}`,
})) ?? record
);
}
return record;
} catch (error) {
return (
(await updateBuild(env, record.id, {
status: "failed",
error: error instanceof Error ? error.message : "Status refresh failed",
})) ?? record
);
}
}

View File

@@ -0,0 +1,252 @@
import type { Env } from "../env";
function getGitHubConfig(env: Env) {
return {
owner: (env.GITHUB_OWNER ?? "").trim(),
repo: (env.GITHUB_REPO ?? "").trim(),
token: (env.GITHUB_TOKEN ?? "").trim(),
branch: (env.DEFAULT_BRANCH ?? "main").trim(),
};
}
function assertGitHubConfig(env: Env) {
const { owner, repo, token } = getGitHubConfig(env);
if (!owner || !repo || !token) {
throw new Error(
"Missing GITHUB_OWNER, GITHUB_REPO, or GITHUB_TOKEN configuration",
);
}
return { owner, repo, token };
}
async function githubFetch(
env: Env,
path: string,
init?: RequestInit,
): Promise<Response> {
const { token } = assertGitHubConfig(env);
return fetch(`https://api.github.com${path}`, {
...init,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "web2app-worker",
...(init?.headers ?? {}),
},
});
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
export async function uploadBuildFile(
env: Env,
jobId: string,
relativePath: string,
fileBuffer: Uint8Array,
): Promise<void> {
const { owner, repo } = assertGitHubConfig(env);
const path = `builds/${jobId}/${relativePath}`;
const content = bytesToBase64(fileBuffer);
const put = async (sha?: string) => {
const body: Record<string, string> = {
message: sha
? `chore: update ${relativePath} for build ${jobId}`
: `chore: upload ${relativePath} for build ${jobId}`,
content,
};
if (sha) body.sha = sha;
return githubFetch(env, `/repos/${owner}/${repo}/contents/${path}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
};
let response = await put();
if (response.status === 422) {
const getResponse = await githubFetch(
env,
`/repos/${owner}/${repo}/contents/${path}`,
);
if (!getResponse.ok) {
const text = await getResponse.text();
throw new Error(`GitHub get content failed (${getResponse.status}): ${text}`);
}
const existing = (await getResponse.json()) as {
sha?: string;
type?: string;
};
if (!existing.sha || existing.type !== "file") {
throw new Error(`Unexpected content at ${path}`);
}
response = await put(existing.sha);
}
if (!response.ok) {
const text = await response.text();
throw new Error(`GitHub upload failed (${response.status}): ${text}`);
}
}
export async function uploadSiteZip(
env: Env,
jobId: string,
zipBuffer: Uint8Array,
): Promise<void> {
return uploadBuildFile(env, jobId, "site.zip", zipBuffer);
}
export async function triggerBuildWorkflow(
env: Env,
input: {
jobId: string;
appName: string;
appNameEn: string;
appIdentifier: string;
},
): Promise<number> {
const { owner, repo, branch } = getGitHubConfig(env);
assertGitHubConfig(env);
const dispatchResponse = await githubFetch(
env,
`/repos/${owner}/${repo}/actions/workflows/build-app.yml/dispatches`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ref: branch,
inputs: {
job_id: input.jobId,
app_name: input.appName,
app_name_en: input.appNameEn,
app_identifier: input.appIdentifier,
},
}),
},
);
if (!dispatchResponse.ok) {
const text = await dispatchResponse.text();
throw new Error(
`Workflow dispatch failed (${dispatchResponse.status}): ${text}`,
);
}
await sleep(3000);
const since = new Date(Date.now() - 120_000).toISOString();
const runsResponse = await githubFetch(
env,
`/repos/${owner}/${repo}/actions/workflows/build-app.yml/runs?event=workflow_dispatch&per_page=10`,
);
if (!runsResponse.ok) {
const text = await runsResponse.text();
throw new Error(`Failed to list workflow runs: ${text}`);
}
const runsData = (await runsResponse.json()) as {
workflow_runs: Array<{ id: number; created_at: string }>;
};
const run = runsData.workflow_runs
.filter((item) => item.created_at >= since)
.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
)[0];
if (!run) {
throw new Error("Failed to locate workflow run after dispatch");
}
return run.id;
}
export async function getWorkflowRun(env: Env, runId: number) {
const { owner, repo } = assertGitHubConfig(env);
const response = await githubFetch(
env,
`/repos/${owner}/${repo}/actions/runs/${runId}`,
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to get workflow run: ${text}`);
}
return (await response.json()) as {
status: string;
conclusion: string | null;
};
}
export async function getReleaseAssets(
env: Env,
jobId: string,
): Promise<{
windowsUrl: string | null;
androidUrl: string | null;
}> {
const { owner, repo } = assertGitHubConfig(env);
const tag = `build-${jobId}`;
const response = await githubFetch(
env,
`/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`,
);
if (!response.ok) {
return { windowsUrl: null, androidUrl: null };
}
const release = (await response.json()) as {
assets: Array<{ name: string; browser_download_url: string }>;
};
let windowsUrl: 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"))
) {
windowsUrl = asset.browser_download_url;
}
if (
!androidUrl &&
(name.endsWith(".apk") || name.includes("android"))
) {
androidUrl = asset.browser_download_url;
}
}
return { windowsUrl, androidUrl };
}
export function getActionsRunUrl(env: Env, runId: number | null): string | null {
const { owner, repo } = getGitHubConfig(env);
if (!runId || !owner || !repo) return null;
return `https://github.com/${owner}/${repo}/actions/runs/${runId}`;
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,50 @@
export class IconValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "IconValidationError";
}
}
const MAX_ICON_BYTES = 2 * 1024 * 1024;
const ALLOWED_TYPES = new Set([
"image/png",
"image/jpeg",
"image/x-icon",
"image/vnd.microsoft.icon",
]);
const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".ico"]);
export function resolveIconUpload(
file: File,
buffer: Uint8Array,
): { repoPath: string; fileName: string } {
if (buffer.length > MAX_ICON_BYTES) {
throw new IconValidationError("图标文件不能超过 2MB");
}
const ext = pathExt(file.name).toLowerCase();
if (!ALLOWED_EXT.has(ext)) {
throw new IconValidationError("图标仅支持 PNG、JPG、ICO 格式");
}
const type = file.type.toLowerCase();
if (type && !ALLOWED_TYPES.has(type) && type !== "application/octet-stream") {
throw new IconValidationError("不支持的图标 MIME 类型");
}
if (ext === ".ico") {
return { repoPath: "favicon.ico", fileName: "favicon.ico" };
}
if (ext === ".jpg" || ext === ".jpeg") {
return { repoPath: "logo.jpg", fileName: "logo.jpg" };
}
return { repoPath: "logo.png", fileName: "logo.png" };
}
function pathExt(name: string): string {
const i = name.lastIndexOf(".");
return i >= 0 ? name.slice(i) : "";
}

View File

@@ -0,0 +1,25 @@
export class VersionValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "VersionValidationError";
}
}
/** 当前日期版本,格式如 2026.5.29(月、日不补零) */
export function getDefaultAppVersion(date = new Date()): string {
return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}`;
}
export function validateAppVersion(input: string): string {
const value = input.trim() || getDefaultAppVersion();
if (!/^\d{4}\.\d{1,2}\.\d{1,2}$/.test(value)) {
throw new VersionValidationError(
"版本号格式应为 YYYY.M.D例如 2026.5.29",
);
}
const [, month, day] = value.split(".").map(Number);
if (month < 1 || month > 12 || day < 1 || day > 31) {
throw new VersionValidationError("版本号中的月或日无效");
}
return value;
}

View File

@@ -1,7 +1,4 @@
import AdmZip from "adm-zip";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { unzipSync, zipSync } from "fflate";
export class ZipValidationError extends Error {
constructor(message: string) {
@@ -11,29 +8,31 @@ export class ZipValidationError extends Error {
}
export function validateZipBuffer(
buffer: Buffer,
buffer: Uint8Array,
maxBytes: number,
): { normalizedBuffer: Buffer } {
): { normalizedBuffer: Uint8Array } {
if (buffer.length > maxBytes) {
throw new ZipValidationError(
`Zip file exceeds ${Math.floor(maxBytes / (1024 * 1024))}MB limit`,
);
}
let zip: AdmZip;
let files: Record<string, Uint8Array>;
try {
zip = new AdmZip(buffer);
files = unzipSync(buffer);
} catch {
throw new ZipValidationError("Invalid zip archive");
}
const entries = zip.getEntries().filter((entry) => !entry.isDirectory);
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.entryName.replace(/\\/g, "/");
const normalized = entry.replace(/\\/g, "/");
if (normalized === "index.html") return true;
const parts = normalized.split("/");
return parts.length === 2 && parts[1] === "index.html";
@@ -45,52 +44,67 @@ export function validateZipBuffer(
);
}
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "web2app-"));
try {
zip.extractAllTo(tmpDir, true);
const rootIndex = path.join(tmpDir, "index.html");
if (!fs.existsSync(rootIndex)) {
const subdirs = fs
.readdirSync(tmpDir, { withFileTypes: true })
.filter((d) => d.isDirectory());
if (subdirs.length !== 1) {
throw new ZipValidationError(
"index.html must be at zip root or inside exactly one folder",
);
}
const nestedRoot = path.join(tmpDir, subdirs[0].name);
flattenDirectory(nestedRoot, tmpDir);
}
const normalizedZip = new AdmZip();
addDirectoryToZip(normalizedZip, tmpDir, "");
return { normalizedBuffer: normalizedZip.toBuffer() };
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
const normalized = normalizeZipFiles(files);
return { normalizedBuffer: zipSync(normalized) };
}
function flattenDirectory(sourceDir: string, targetDir: string) {
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
const from = path.join(sourceDir, entry.name);
const to = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
fs.cpSync(from, to, { recursive: true });
} else {
fs.copyFileSync(from, to);
}
function normalizeZipFiles(
files: Record<string, Uint8Array>,
): Record<string, Uint8Array> {
const map: Record<string, Uint8Array> = {};
for (const [key, data] of Object.entries(files)) {
const path = key.replace(/\\/g, "/").replace(/^\.\//, "");
if (path.endsWith("/")) continue;
map[path] = data;
}
}
function addDirectoryToZip(zip: AdmZip, dir: string, prefix: string) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
const entryName = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
addDirectoryToZip(zip, fullPath, entryName);
} else {
zip.addFile(entryName, fs.readFileSync(fullPath));
}
if (map["index.html"]) {
return map;
}
const keys = Object.keys(map);
const indexKey = keys.find((k) => {
const parts = k.split("/");
return parts.length === 2 && parts[1] === "index.html";
});
if (!indexKey) {
throw new ZipValidationError(
"index.html must be at zip root or inside exactly one folder",
);
}
const folder = indexKey.split("/")[0];
const topDirs = new Set(
keys.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",
);
}
const flat: Record<string, Uint8Array> = {};
const prefix = `${folder}/`;
for (const [key, data] of Object.entries(map)) {
if (!key.startsWith(prefix)) {
throw new ZipValidationError(
"index.html must be at zip root or inside exactly one folder",
);
}
const rel = key.slice(prefix.length);
if (!rel) continue;
flat[rel] = data;
}
if (!flat["index.html"]) {
throw new ZipValidationError(
"index.html must be at zip root or inside exactly one folder",
);
}
return flat;
}
const JAVA_RESERVED_SEGMENTS = new Set([

14
worker/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"]
}

20
wrangler.toml Normal file
View File

@@ -0,0 +1,20 @@
name = "web2app"
main = "worker/src/index.ts"
compatibility_date = "2024-11-01"
[assets]
directory = "./frontend/dist"
not_found_handling = "single-page-application"
# 运行 `wrangler d1 create web2app` 后将输出的 database_id 替换下方占位符
[[d1_databases]]
binding = "DB"
database_name = "web2app"
database_id = "e45bfa13-525b-465c-8dbb-b1a716751b44"
migrations_dir = "worker/migrations"
[vars]
DEFAULT_BRANCH = "main"
MAX_UPLOAD_MB = "50"
GITHUB_OWNER = "shumengya"
GITHUB_REPO = "Web2App"