feat: separate Chinese display name and English packaging name

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 08:54:43 +08:00
parent aef449e0e4
commit edf2bdada6
11 changed files with 104 additions and 28 deletions

View File

@@ -7,11 +7,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "../.."); const root = path.resolve(__dirname, "../..");
const jobId = process.env.JOB_ID; const jobId = process.env.JOB_ID;
const appName = process.env.APP_NAME; const appNameZh = process.env.APP_NAME;
const appNameEn = process.env.APP_NAME_EN;
const appIdentifier = process.env.APP_IDENTIFIER; const appIdentifier = process.env.APP_IDENTIFIER;
if (!jobId || !appName || !appIdentifier) { if (!jobId || !appNameZh || !appNameEn || !appIdentifier) {
console.error("JOB_ID, APP_NAME, and APP_IDENTIFIER are required"); console.error(
"JOB_ID, APP_NAME, APP_NAME_EN, and APP_IDENTIFIER are required",
);
process.exit(1); process.exit(1);
} }
@@ -49,13 +52,13 @@ function toSafeBinaryName(name, fallback) {
function patchTauriConfig(filePath) { function patchTauriConfig(filePath) {
const conf = JSON.parse(fs.readFileSync(filePath, "utf8")); const conf = JSON.parse(fs.readFileSync(filePath, "utf8"));
const safeBinaryName = toSafeBinaryName(appName, `App${jobId}`); const safeBinaryName = toSafeBinaryName(appNameEn, `App${jobId}`);
conf.productName = appName; conf.productName = appNameZh;
conf.mainBinaryName = safeBinaryName; conf.mainBinaryName = safeBinaryName;
conf.identifier = appIdentifier; conf.identifier = appIdentifier;
conf.version = "1.0.0"; conf.version = "1.0.0";
if (conf.app?.windows?.[0]) { if (conf.app?.windows?.[0]) {
conf.app.windows[0].title = appName; conf.app.windows[0].title = appNameZh;
} }
fs.writeFileSync(filePath, `${JSON.stringify(conf, null, 2)}\n`); fs.writeFileSync(filePath, `${JSON.stringify(conf, null, 2)}\n`);
} }

View File

@@ -8,7 +8,11 @@ on:
required: true required: true
type: string type: string
app_name: app_name:
description: Application display name description: Application display name (Chinese)
required: true
type: string
app_name_en:
description: Application English name for packaging
required: true required: true
type: string type: string
app_identifier: app_identifier:
@@ -22,6 +26,7 @@ permissions:
env: env:
JOB_ID: ${{ inputs.job_id }} JOB_ID: ${{ inputs.job_id }}
APP_NAME: ${{ inputs.app_name }} APP_NAME: ${{ inputs.app_name }}
APP_NAME_EN: ${{ inputs.app_name_en }}
APP_IDENTIFIER: ${{ inputs.app_identifier }} APP_IDENTIFIER: ${{ inputs.app_identifier }}
NDK_VERSION: "27.2.12479018" NDK_VERSION: "27.2.12479018"
@@ -168,7 +173,7 @@ jobs:
tag_name: build-${{ inputs.job_id }} tag_name: build-${{ inputs.job_id }}
name: Web2App ${{ inputs.app_name }} (${{ inputs.job_id }}) name: Web2App ${{ inputs.app_name }} (${{ inputs.job_id }})
body: | body: |
Automated build for **${{ inputs.app_name }}** (`${{ inputs.app_identifier }}`). Automated build for **${{ inputs.app_name }}** (English: `${{ inputs.app_name_en }}`, ID: `${{ inputs.app_identifier }}`).
- Windows desktop installer/portable binary - Windows desktop installer/portable binary
- Android APK (demo signing, sideload only) - Android APK (demo signing, sideload only)

View File

@@ -78,7 +78,7 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| `POST` | `/api/builds` | `multipart/form-data`: `file`, `appName`, 可选 `identifier` | | `POST` | `/api/builds` | `multipart/form-data`: `file`, `appNameZh`, `appNameEn`, 可选 `identifier` |
| `GET` | `/api/builds/:id` | 查询构建状态与下载链接 | | `GET` | `/api/builds/:id` | 查询构建状态与下载链接 |
| `GET` | `/api/builds` | 最近 20 条构建记录 | | `GET` | `/api/builds` | 最近 20 条构建记录 |
@@ -103,7 +103,8 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
在 GitHub 仓库 Actions 页选择 **Build App**,手动填写: 在 GitHub 仓库 Actions 页选择 **Build App**,手动填写:
- `job_id`: 任意已上传目录名(需先存在 `builds/{job_id}/site.zip` - `job_id`: 任意已上传目录名(需先存在 `builds/{job_id}/site.zip`
- `app_name`: Demo App - `app_name`: 演示应用
- `app_name_en`: Demo App
- `app_identifier`: com.web2app.demo - `app_identifier`: com.web2app.demo
## 本地数据 ## 本地数据

View File

@@ -15,6 +15,7 @@ export type BuildStatus =
export interface BuildRecord { export interface BuildRecord {
id: string; id: string;
app_name: string; app_name: string;
app_name_en: string;
app_identifier: string; app_identifier: string;
status: BuildStatus; status: BuildStatus;
workflow_run_id: number | null; workflow_run_id: number | null;
@@ -36,19 +37,31 @@ export function getDb(): Database.Database {
db = new Database(dbPath); db = new Database(dbPath);
const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf8"); const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf8");
db.exec(schema); db.exec(schema);
migrateBuildsTable(db);
return db; return db;
} }
function migrateBuildsTable(database: Database.Database) {
const columns = database
.prepare("PRAGMA table_info(builds)")
.all() as Array<{ name: string }>;
if (!columns.some((column) => column.name === "app_name_en")) {
database.exec("ALTER TABLE builds ADD COLUMN app_name_en TEXT NOT NULL DEFAULT ''");
}
}
export function insertBuild(record: { export function insertBuild(record: {
id: string; id: string;
appName: string; appName: string;
appNameEn: string;
appIdentifier: string; appIdentifier: string;
}): BuildRecord { }): BuildRecord {
const database = getDb(); const database = getDb();
database database
.prepare( .prepare(
`INSERT INTO builds (id, app_name, app_identifier, status) `INSERT INTO builds (id, app_name, app_name_en, app_identifier, status)
VALUES (@id, @appName, @appIdentifier, 'pending')`, VALUES (@id, @appName, @appNameEn, @appIdentifier, 'pending')`,
) )
.run(record); .run(record);
@@ -105,6 +118,7 @@ export function toPublicBuild(record: BuildRecord) {
return { return {
id: record.id, id: record.id,
appName: record.app_name, appName: record.app_name,
appNameEn: record.app_name_en || record.app_name,
appIdentifier: record.app_identifier, appIdentifier: record.app_identifier,
status: record.status, status: record.status,
workflowRunId: record.workflow_run_id, workflowRunId: record.workflow_run_id,

View File

@@ -1,6 +1,7 @@
CREATE TABLE IF NOT EXISTS builds ( CREATE TABLE IF NOT EXISTS builds (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
app_name TEXT NOT NULL, app_name TEXT NOT NULL,
app_name_en TEXT NOT NULL DEFAULT '',
app_identifier TEXT NOT NULL, app_identifier TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
workflow_run_id INTEGER, workflow_run_id INTEGER,

View File

@@ -19,6 +19,8 @@ import {
import { import {
normalizeAppIdentifier, normalizeAppIdentifier,
slugifyIdentifier, slugifyIdentifier,
validateChineseAppName,
validateEnglishAppName,
validateZipBuffer, validateZipBuffer,
ZipValidationError, ZipValidationError,
} from "../services/zip.js"; } from "../services/zip.js";
@@ -54,14 +56,14 @@ buildsRouter.get("/:id", async (req, res) => {
buildsRouter.post("/", upload.single("file"), async (req, res) => { buildsRouter.post("/", upload.single("file"), async (req, res) => {
try { try {
const appName = String(req.body.appName ?? "").trim(); const appNameZh = validateChineseAppName(
String(req.body.appNameZh ?? req.body.appName ?? ""),
);
const appNameEn = validateEnglishAppName(
String(req.body.appNameEn ?? ""),
);
const identifierInput = String(req.body.identifier ?? "").trim(); const identifierInput = String(req.body.identifier ?? "").trim();
if (!appName) {
res.status(400).json({ error: "appName is required" });
return;
}
if (!req.file) { if (!req.file) {
res.status(400).json({ error: "file is required" }); res.status(400).json({ error: "file is required" });
return; return;
@@ -74,19 +76,25 @@ buildsRouter.post("/", upload.single("file"), async (req, res) => {
const { normalizedBuffer } = validateZipBuffer(req.file.buffer, maxUploadBytes); const { normalizedBuffer } = validateZipBuffer(req.file.buffer, maxUploadBytes);
const jobId = nanoid(10); const jobId = nanoid(10);
const slug = slugifyIdentifier(appName) || jobId.toLowerCase(); const slug = slugifyIdentifier(appNameEn) || jobId.toLowerCase();
const appIdentifier = normalizeAppIdentifier( const appIdentifier = normalizeAppIdentifier(
identifierInput || `com.web2app.${slug}`, identifierInput || `com.web2app.${slug}`,
); );
insertBuild({ id: jobId, appName, appIdentifier }); insertBuild({
id: jobId,
appName: appNameZh,
appNameEn,
appIdentifier,
});
await uploadSiteZip(jobId, normalizedBuffer); await uploadSiteZip(jobId, normalizedBuffer);
updateBuild(jobId, { status: "queued" }); updateBuild(jobId, { status: "queued" });
const workflowRunId = await triggerBuildWorkflow({ const workflowRunId = await triggerBuildWorkflow({
jobId, jobId,
appName, appName: appNameZh,
appNameEn,
appIdentifier, appIdentifier,
}); });

View File

@@ -61,6 +61,7 @@ export async function uploadSiteZip(jobId: string, zipBuffer: Buffer): Promise<v
export async function triggerBuildWorkflow(input: { export async function triggerBuildWorkflow(input: {
jobId: string; jobId: string;
appName: string; appName: string;
appNameEn: string;
appIdentifier: string; appIdentifier: string;
}): Promise<number> { }): Promise<number> {
const { owner, repo } = getGitHubConfig(); const { owner, repo } = getGitHubConfig();
@@ -74,6 +75,7 @@ export async function triggerBuildWorkflow(input: {
inputs: { inputs: {
job_id: input.jobId, job_id: input.jobId,
app_name: input.appName, app_name: input.appName,
app_name_en: input.appNameEn,
app_identifier: input.appIdentifier, app_identifier: input.appIdentifier,
}, },
}); });

View File

@@ -154,6 +154,27 @@ function sanitizeSegment(segment: string): string {
return value.slice(0, 48); return value.slice(0, 48);
} }
export function validateEnglishAppName(input: string): string {
const value = input.trim();
if (!value) {
throw new ZipValidationError("应用英文名不能为空");
}
if (!/^[a-zA-Z][a-zA-Z0-9 _.-]*$/.test(value)) {
throw new ZipValidationError(
"应用英文名需以字母开头,仅支持英文字母、数字、空格、下划线和连字符",
);
}
return value;
}
export function validateChineseAppName(input: string): string {
const value = input.trim();
if (!value) {
throw new ZipValidationError("应用中文名不能为空");
}
return value;
}
export function slugifyIdentifier(input: string): string { export function slugifyIdentifier(input: string): string {
return input return input
.toLowerCase() .toLowerCase()

View File

@@ -8,6 +8,7 @@ export type BuildStatus =
export interface Build { export interface Build {
id: string; id: string;
appName: string; appName: string;
appNameEn: string;
appIdentifier: string; appIdentifier: string;
status: BuildStatus; status: BuildStatus;
workflowRunId: number | null; workflowRunId: number | null;

View File

@@ -58,9 +58,13 @@ export default function JobStatus() {
<dd>{build.id}</dd> <dd>{build.id}</dd>
</div> </div>
<div> <div>
<dt></dt> <dt></dt>
<dd>{build.appName}</dd> <dd>{build.appName}</dd>
</div> </div>
<div>
<dt></dt>
<dd>{build.appNameEn}</dd>
</div>
<div> <div>
<dt>Bundle ID</dt> <dt>Bundle ID</dt>
<dd>{build.appIdentifier}</dd> <dd>{build.appIdentifier}</dd>

View File

@@ -4,7 +4,8 @@ import { createBuild } from "../api";
export default function Upload() { export default function Upload() {
const navigate = useNavigate(); const navigate = useNavigate();
const [appName, setAppName] = useState(""); const [appNameZh, setAppNameZh] = useState("");
const [appNameEn, setAppNameEn] = useState("");
const [identifier, setIdentifier] = useState(""); const [identifier, setIdentifier] = useState("");
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -23,7 +24,8 @@ export default function Upload() {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("appName", appName); formData.append("appNameZh", appNameZh);
formData.append("appNameEn", appNameEn);
if (identifier.trim()) { if (identifier.trim()) {
formData.append("identifier", identifier.trim()); formData.append("identifier", identifier.trim());
} }
@@ -40,15 +42,29 @@ export default function Upload() {
return ( return (
<section className="card"> <section className="card">
<h2></h2> <h2></h2>
<p className="hint"> index.html 50MB</p> <p className="hint">
index.html 50MB
Bundle ID
</p>
<form className="form" onSubmit={onSubmit}> <form className="form" onSubmit={onSubmit}>
<label> <label>
<input <input
value={appName} value={appNameZh}
onChange={(e) => setAppName(e.target.value)} onChange={(e) => setAppNameZh(e.target.value)}
placeholder="我的应用"
required
/>
</label>
<label>
<input
value={appNameEn}
onChange={(e) => setAppNameEn(e.target.value)}
placeholder="My App" placeholder="My App"
required required
pattern="[a-zA-Z][a-zA-Z0-9 _.-]*"
title="以字母开头,仅含英文字母、数字、空格、下划线和连字符"
/> />
</label> </label>
<label> <label>