Initial commit: Web2App static site to Tauri platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-27 21:56:23 +08:00
commit 8298278c10
64 changed files with 11509 additions and 0 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
GITHUB_TOKEN=ghp_your_personal_access_token
GITHUB_OWNER=shumengya
GITHUB_REPO=Web2App
DEFAULT_BRANCH=main
PORT=3001
MAX_UPLOAD_MB=50

57
.github/scripts/prepare-template.mjs vendored Normal file
View File

@@ -0,0 +1,57 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import AdmZip from "adm-zip";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "../..");
const jobId = process.env.JOB_ID;
const appName = process.env.APP_NAME;
const appIdentifier = process.env.APP_IDENTIFIER;
if (!jobId || !appName || !appIdentifier) {
console.error("JOB_ID, APP_NAME, and APP_IDENTIFIER are required");
process.exit(1);
}
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");
if (!fs.existsSync(zipPath)) {
console.error(`Missing upload: ${zipPath}`);
process.exit(1);
}
fs.rmSync(distDir, { recursive: true, force: true });
fs.mkdirSync(distDir, { recursive: true });
const zip = new AdmZip(zipPath);
zip.extractAllTo(distDir, true);
const indexPath = path.join(distDir, "index.html");
if (!fs.existsSync(indexPath)) {
console.error("template/dist/index.html not found after extraction");
process.exit(1);
}
const conf = JSON.parse(fs.readFileSync(confPath, "utf8"));
conf.productName = appName;
conf.mainBinaryName = appName.replace(/[^\w.-]+/g, "") || "Web2App";
conf.identifier = appIdentifier;
conf.version = "1.0.0";
conf.app.windows = conf.app.windows ?? [{}];
conf.app.windows[0].title = appName;
fs.writeFileSync(confPath, `${JSON.stringify(conf, null, 2)}\n`);
const iconCandidates = ["icon.png", "favicon.png", "logo.png"];
for (const candidate of iconCandidates) {
const iconPath = path.join(distDir, candidate);
if (fs.existsSync(iconPath)) {
console.log(`Found icon candidate: ${candidate}`);
break;
}
}
console.log("Template prepared successfully");

185
.github/workflows/build-app.yml vendored Normal file
View File

@@ -0,0 +1,185 @@
name: Build App
on:
workflow_dispatch:
inputs:
job_id:
description: Build job ID
required: true
type: string
app_name:
description: Application display name
required: true
type: string
app_identifier:
description: Application bundle identifier
required: true
type: string
permissions:
contents: write
env:
JOB_ID: ${{ inputs.job_id }}
APP_NAME: ${{ inputs.app_name }}
APP_IDENTIFIER: ${{ inputs.app_identifier }}
NDK_VERSION: "27.2.12479018"
jobs:
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- name: Prepare template
run: node .github/scripts/prepare-template.mjs
- name: Install dependencies
run: npm ci
- uses: dtolnay/rust-toolchain@stable
- uses: swatinem/rust-cache@v2
with:
workspaces: template/src-tauri -> target
- name: Build Windows app
working-directory: template
run: npm run tauri build
- name: Collect Windows artifacts
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path artifacts/windows | Out-Null
Get-ChildItem -Path "template/src-tauri/target/release/bundle" -Recurse -Include *.exe,*.msi |
Copy-Item -Destination artifacts/windows -Force
- uses: actions/upload-artifact@v4
with:
name: windows-build
path: artifacts/windows/*
if-no-files-found: error
build-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: android-actions/setup-android@v3
- name: Install Android SDK components
run: sdkmanager "platforms;android-34" "build-tools;34.0.0" "ndk;${{ env.NDK_VERSION }}"
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
- uses: swatinem/rust-cache@v2
with:
workspaces: template/src-tauri -> target
- name: Prepare template
run: node .github/scripts/prepare-template.mjs
- name: Install dependencies
run: npm ci
- name: Initialize Android project
working-directory: template
run: npm run tauri android init
env:
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/${{ env.NDK_VERSION }}
- name: Build Android APK
working-directory: template
run: npm run tauri android build -- --apk
env:
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/${{ env.NDK_VERSION }}
- name: Collect Android artifacts
run: |
mkdir -p artifacts/android
find template/src-tauri/gen/android -name "*.apk" -exec cp {} artifacts/android/ \;
find template/src-tauri -path "*/build/outputs/apk/*" -name "*.apk" -exec cp {} artifacts/android/ \;
ls -la artifacts/android
- uses: actions/upload-artifact@v4
with:
name: android-build
path: artifacts/android/*
if-no-files-found: error
finalize:
needs: [build-windows, build-android]
runs-on: ubuntu-latest
if: always() && needs.build-windows.result == 'success' && needs.build-android.result == 'success'
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: windows-build
path: release-assets/windows
- uses: actions/download-artifact@v4
with:
name: android-build
path: release-assets/android
- name: Rename release assets
run: |
mkdir -p release-assets/final
shopt -s nullglob
for file in release-assets/windows/*; do
base="$(basename "$file")"
cp "$file" "release-assets/final/web2app-${{ inputs.job_id }}-windows-${base}"
done
for file in release-assets/android/*; do
base="$(basename "$file")"
cp "$file" "release-assets/final/web2app-${{ inputs.job_id }}-android-${base}"
done
ls -la release-assets/final
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: build-${{ inputs.job_id }}
name: Web2App ${{ inputs.app_name }} (${{ inputs.job_id }})
body: |
Automated build for **${{ inputs.app_name }}** (`${{ inputs.app_identifier }}`).
- Windows desktop installer/portable binary
- Android APK (demo signing, sideload only)
draft: false
prerelease: true
files: release-assets/final/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Remove uploaded site zip
run: |
path="builds/${{ inputs.job_id }}/site.zip"
sha=$(gh api repos/${{ github.repository }}/contents/$path --jq .sha 2>/dev/null || true)
if [ -n "$sha" ]; then
gh api repos/${{ github.repository }}/contents/$path \
-X DELETE \
-f message="chore: cleanup build ${{ inputs.job_id }}" \
-f sha="$sha"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

12
.gitignore vendored Normal file
View File

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

147
README.md Normal file
View File

@@ -0,0 +1,147 @@
# Web2App
将已构建好的静态网页zip入口 `index.html`)转换为 **Windows 桌面应用****Android APK**。本仓库包含:
- `web/`:上传与构建状态页面
- `server/`Express API负责 zip 校验、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
4. GitHub Actions 解压 zip 到 `template/dist/`,构建 Windows 与 Android
5. `finalize` job 创建 Release`build-{jobId}`,并返回下载链接
## 前置条件
- Node.js 20+
- 一个 GitHub 仓库(建议名称 `Web2App`
- Personal Access Token权限至少包含
- `repo`
- `workflow`
## 快速开始
### 1. 初始化 GitHub 仓库
```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
```
### 2. 配置环境变量
```bash
cp .env.example .env
```
编辑 `.env`
```env
GITHUB_TOKEN=ghp_xxx
GITHUB_OWNER=your-github-username
GITHUB_REPO=Web2App
DEFAULT_BRANCH=main
PORT=3001
MAX_UPLOAD_MB=50
```
### 3. 安装依赖并启动
```bash
npm install
npm run dev
```
- 前端:<http://localhost:5173>
- 后端:<http://localhost:3001>
### 4. 上传测试
准备一个 zip根目录包含 `index.html`,也可使用 `examples/sample-site/` 打包:
```bash
cd examples/sample-site
powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
```
在网页中填写应用名称并上传 zip。
## API
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/builds` | `multipart/form-data`: `file`, `appName`, 可选 `identifier` |
| `GET` | `/api/builds/:id` | 查询构建状态与下载链接 |
| `GET` | `/api/builds` | 最近 20 条构建记录 |
## GitHub Actions 说明
`build-app.yml` 通过 `workflow_dispatch` 触发,输入:
- `job_id`
- `app_name`
- `app_identifier`
构建产物发布到 Release tag`build-{job_id}`
### Android 说明
- CI 中使用 debug/默认签名,适合 sideload 测试
- 首次 Android 构建会在 CI 中执行 `tauri android init`
- Android 构建依赖 NDK / SDKworkflow 已预装常见组件
### 手动验证 workflow
在 GitHub 仓库 Actions 页选择 **Build App**,手动填写:
- `job_id`: 任意已上传目录名(需先存在 `builds/{job_id}/site.zip`
- `app_name`: Demo App
- `app_identifier`: com.web2app.demo
## 本地数据
- SQLite`data/web2app.db`
- 上传临时文件:内存处理,不持久化到磁盘
## Demo 限制
- 无登录鉴权,仅适合本地/内网 demo
- GitHub Actions 有排队时间与额度限制
- 大 zip 会短暂占用仓库空间finalize 会尝试删除 `site.zip`
- Android APK 非 Play Store 发布级别
## 目录结构
```
Web2App/
├── web/ # Vite + React 前端
├── server/ # Express 后端
├── template/ # Tauri 2 模板
├── .github/workflows/ # CI 构建
├── examples/ # 示例静态站点
└── data/ # 本地 SQLite运行时生成
```
## 常见问题
**workflow_dispatch 返回 404**
- 确认 workflow 文件已在默认分支
- 确认 `DEFAULT_BRANCH` 与仓库默认分支一致
**Release 没有下载链接**
- 等待 `finalize` job 完成
- 检查 Windows / Android job 是否都成功
**zip 校验失败**
- 确保 `index.html` 在 zip 根目录,或仅套一层文件夹

BIN
examples/sample-site.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sample Site</title>
<style>
body {
font-family: system-ui, sans-serif;
display: grid;
place-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #dbeafe, #fef3c7);
}
main {
text-align: center;
padding: 32px;
border-radius: 20px;
background: white;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12);
}
</style>
</head>
<body>
<main>
<h1>Hello Web2App</h1>
<p>这是一个用于测试打包流程的示例静态页面。</p>
</main>
</body>
</html>

5068
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "web2app",
"private": true,
"version": "0.1.0",
"type": "module",
"workspaces": [
"web",
"server",
"template"
],
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev: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"
},
"devDependencies": {
"adm-zip": "^0.5.17",
"concurrently": "^9.1.2",
"typescript": "~5.6.2"
}
}

117
server/db/index.ts Normal file
View File

@@ -0,0 +1,117 @@
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_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);
return db;
}
export function insertBuild(record: {
id: string;
appName: string;
appIdentifier: string;
}): BuildRecord {
const database = getDb();
database
.prepare(
`INSERT INTO builds (id, app_name, app_identifier, status)
VALUES (@id, @appName, @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,
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,
};
}

14
server/db/schema.sql Normal file
View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS builds (
id TEXT PRIMARY KEY,
app_name TEXT NOT NULL,
app_identifier TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
workflow_run_id INTEGER,
windows_url TEXT,
android_url TEXT,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_builds_created_at ON builds(created_at DESC);

36
server/index.ts Normal file
View File

@@ -0,0 +1,36 @@
import cors from "cors";
import dotenv from "dotenv";
import express from "express";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildsRouter } from "./routes/builds.js";
dotenv.config({ path: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.env") });
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);
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, () => {
console.log(`Web2App server listening on http://localhost:${port}`);
});

31
server/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "web2app-server",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch index.ts",
"build": "tsc",
"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"
}
}

170
server/routes/builds.ts Normal file
View File

@@ -0,0 +1,170 @@
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 { slugifyIdentifier, 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 appName = String(req.body.appName ?? "").trim();
const identifierInput = String(req.body.identifier ?? "").trim();
if (!appName) {
res.status(400).json({ error: "appName is required" });
return;
}
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 appIdentifier =
identifierInput ||
`com.web2app.${slugifyIdentifier(appName) || jobId.toLowerCase()}`;
insertBuild({ id: jobId, appName, appIdentifier });
await uploadSiteZip(jobId, normalizedBuffer);
updateBuild(jobId, { status: "queued" });
const workflowRunId = await triggerBuildWorkflow({
jobId,
appName,
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 };

155
server/services/github.ts Normal file
View File

@@ -0,0 +1,155 @@
import { Octokit } from "octokit";
const owner = process.env.GITHUB_OWNER ?? "";
const repo = process.env.GITHUB_REPO ?? "";
const token = process.env.GITHUB_TOKEN ?? "";
function getOctokit(): Octokit {
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() {
return { owner, repo };
}
export async function uploadSiteZip(jobId: string, zipBuffer: Buffer): Promise<void> {
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;
appIdentifier: string;
}): Promise<number> {
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_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 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 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 {
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));
}

102
server/services/zip.ts Normal file
View File

@@ -0,0 +1,102 @@
import AdmZip from "adm-zip";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export class ZipValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ZipValidationError";
}
}
export function validateZipBuffer(
buffer: Buffer,
maxBytes: number,
): { normalizedBuffer: Buffer } {
if (buffer.length > maxBytes) {
throw new ZipValidationError(
`Zip file exceeds ${Math.floor(maxBytes / (1024 * 1024))}MB limit`,
);
}
let zip: AdmZip;
try {
zip = new AdmZip(buffer);
} catch {
throw new ZipValidationError("Invalid zip archive");
}
const entries = zip.getEntries().filter((entry) => !entry.isDirectory);
if (entries.length === 0) {
throw new ZipValidationError("Zip archive is empty");
}
const indexEntry = entries.find((entry) => {
const normalized = entry.entryName.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 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 });
}
}
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 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));
}
}
}
export function slugifyIdentifier(input: string): string {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, ".")
.replace(/^\.+|\.+$/g, "")
.slice(0, 48);
}

15
server/tsconfig.json Normal file
View File

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

12
template/dist/index.html vendored Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web2App Placeholder</title>
</head>
<body>
<h1>Web2App</h1>
<p>Replace this folder with your uploaded static site during CI.</p>
</body>
</html>

12
template/package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "web2app-template",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "web2app-template"
version = "1.0.0"
description = "Web2App static site shell"
authors = ["web2app"]
edition = "2021"
[lib]
name = "web2app_template_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
codegen-units = 1
lto = true
strip = true

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for the main window",
"windows": ["main"],
"permissions": ["core:default"]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-show","core:window:allow-hide","core:window:allow-set-focus","core:window:allow-set-size","core:window:allow-close","allow-fetch-tickers","allow-coin-settings","allow-api-settings"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,6 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
web2app_template_lib::run()
}

View File

@@ -0,0 +1,14 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Web2App",
"version": "1.0.0",
"identifier": "com.web2app.default",
"app": {
"windows": [
{
"label": "main",
"title": "Web2App"
}
]
}
}

View File

@@ -0,0 +1,38 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Web2App",
"mainBinaryName": "Web2App",
"version": "1.0.0",
"identifier": "com.web2app.default",
"build": {
"beforeDevCommand": "",
"beforeBuildCommand": "",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "Web2App",
"width": 1024,
"height": 768,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

11
tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true
}
}

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web2App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

23
web/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "web2app-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.2",
"vite": "^6.0.3"
}
}

23
web/src/App.tsx Normal file
View File

@@ -0,0 +1,23 @@
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>
);
}

59
web/src/api.ts Normal file
View File

@@ -0,0 +1,59 @@
export type BuildStatus =
| "pending"
| "queued"
| "in_progress"
| "completed"
| "failed";
export interface Build {
id: string;
appName: string;
appIdentifier: string;
status: BuildStatus;
workflowRunId: number | null;
windowsUrl: string | null;
androidUrl: string | null;
error: string | null;
createdAt: string;
updatedAt: string;
actionsUrl?: string | null;
}
export async function createBuild(formData: FormData): Promise<{ id: string }> {
const response = await fetch("/api/builds", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error ?? "Upload failed");
}
return data;
}
export async function fetchBuild(id: string): Promise<Build> {
const response = await fetch(`/api/builds/${id}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error ?? "Failed to load build");
}
return data;
}
export function statusLabel(status: BuildStatus): string {
switch (status) {
case "pending":
return "准备中";
case "queued":
return "排队中";
case "in_progress":
return "构建中";
case "completed":
return "已完成";
case "failed":
return "失败";
default:
return status;
}
}

13
web/src/main.tsx Normal file
View File

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

120
web/src/pages/JobStatus.tsx Normal file
View File

@@ -0,0 +1,120 @@
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>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>
);
}

78
web/src/pages/Upload.tsx Normal file
View File

@@ -0,0 +1,78 @@
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createBuild } from "../api";
export default function Upload() {
const navigate = useNavigate();
const [appName, setAppName] = 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("appName", appName);
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</p>
<form className="form" onSubmit={onSubmit}>
<label>
<input
value={appName}
onChange={(e) => setAppName(e.target.value)}
placeholder="My App"
required
/>
</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>
);
}

195
web/src/styles.css Normal file
View File

@@ -0,0 +1,195 @@
: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;
}
}

1
web/src/vite-env.d.ts vendored Normal file
View File

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

17
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

View File

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

4
web/tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

10
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.6.3"}

1
web/tsconfig.tsbuildinfo Normal file
View File

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

14
web/vite.config.js Normal file
View File

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

15
web/vite.config.ts Normal file
View File

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