Initial commit: Web2App static site to Tauri platform
Co-authored-by: Cursor <cursoragent@cursor.com>
6
.env.example
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 / SDK,workflow 已预装常见组件
|
||||
|
||||
### 手动验证 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
31
examples/sample-site/index.html
Normal 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
23
package.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "web2app-template",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
||||
23
template/src-tauri/Cargo.toml
Normal 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
|
||||
3
template/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
7
template/src-tauri/capabilities/default.json
Normal 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"]
|
||||
}
|
||||
1
template/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
template/src-tauri/gen/schemas/capabilities.json
Normal 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"]}}
|
||||
2310
template/src-tauri/gen/schemas/desktop-schema.json
Normal file
2310
template/src-tauri/gen/schemas/windows-schema.json
Normal file
BIN
template/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
template/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
template/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
template/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
template/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
template/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
template/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
template/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
template/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
template/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
template/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
template/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
template/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
template/src-tauri/icons/icon.icns
Normal file
BIN
template/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
template/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
6
template/src-tauri/src/lib.rs
Normal 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");
|
||||
}
|
||||
5
template/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
web2app_template_lib::run()
|
||||
}
|
||||
14
template/src-tauri/tauri.android.conf.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
38
template/src-tauri/tauri.conf.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
17
web/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
1
web/tsconfig.app.tsbuildinfo
Normal 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
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
web/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
web/tsconfig.node.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.6.3"}
|
||||
1
web/tsconfig.tsbuildinfo
Normal 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
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||