feat: separate Chinese display name and English packaging name
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
15
.github/scripts/prepare-template.mjs
vendored
15
.github/scripts/prepare-template.mjs
vendored
@@ -7,11 +7,14 @@ 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 appNameZh = process.env.APP_NAME;
|
||||
const appNameEn = process.env.APP_NAME_EN;
|
||||
const appIdentifier = process.env.APP_IDENTIFIER;
|
||||
|
||||
if (!jobId || !appName || !appIdentifier) {
|
||||
console.error("JOB_ID, APP_NAME, and APP_IDENTIFIER are required");
|
||||
if (!jobId || !appNameZh || !appNameEn || !appIdentifier) {
|
||||
console.error(
|
||||
"JOB_ID, APP_NAME, APP_NAME_EN, and APP_IDENTIFIER are required",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -49,13 +52,13 @@ function toSafeBinaryName(name, fallback) {
|
||||
|
||||
function patchTauriConfig(filePath) {
|
||||
const conf = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
const safeBinaryName = toSafeBinaryName(appName, `App${jobId}`);
|
||||
conf.productName = appName;
|
||||
const safeBinaryName = toSafeBinaryName(appNameEn, `App${jobId}`);
|
||||
conf.productName = appNameZh;
|
||||
conf.mainBinaryName = safeBinaryName;
|
||||
conf.identifier = appIdentifier;
|
||||
conf.version = "1.0.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`);
|
||||
}
|
||||
|
||||
9
.github/workflows/build-app.yml
vendored
9
.github/workflows/build-app.yml
vendored
@@ -8,7 +8,11 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
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
|
||||
type: string
|
||||
app_identifier:
|
||||
@@ -22,6 +26,7 @@ permissions:
|
||||
env:
|
||||
JOB_ID: ${{ inputs.job_id }}
|
||||
APP_NAME: ${{ inputs.app_name }}
|
||||
APP_NAME_EN: ${{ inputs.app_name_en }}
|
||||
APP_IDENTIFIER: ${{ inputs.app_identifier }}
|
||||
NDK_VERSION: "27.2.12479018"
|
||||
|
||||
@@ -168,7 +173,7 @@ jobs:
|
||||
tag_name: build-${{ inputs.job_id }}
|
||||
name: Web2App ${{ inputs.app_name }} (${{ inputs.job_id }})
|
||||
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
|
||||
- Android APK (demo signing, sideload only)
|
||||
|
||||
@@ -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` | 最近 20 条构建记录 |
|
||||
|
||||
@@ -103,7 +103,8 @@ powershell Compress-Archive -Path * -DestinationPath ../sample-site.zip -Force
|
||||
在 GitHub 仓库 Actions 页选择 **Build App**,手动填写:
|
||||
|
||||
- `job_id`: 任意已上传目录名(需先存在 `builds/{job_id}/site.zip`)
|
||||
- `app_name`: Demo App
|
||||
- `app_name`: 演示应用
|
||||
- `app_name_en`: Demo App
|
||||
- `app_identifier`: com.web2app.demo
|
||||
|
||||
## 本地数据
|
||||
|
||||
@@ -15,6 +15,7 @@ export type BuildStatus =
|
||||
export interface BuildRecord {
|
||||
id: string;
|
||||
app_name: string;
|
||||
app_name_en: string;
|
||||
app_identifier: string;
|
||||
status: BuildStatus;
|
||||
workflow_run_id: number | null;
|
||||
@@ -36,19 +37,31 @@ export function getDb(): Database.Database {
|
||||
db = new Database(dbPath);
|
||||
const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf8");
|
||||
db.exec(schema);
|
||||
migrateBuildsTable(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
function migrateBuildsTable(database: Database.Database) {
|
||||
const columns = database
|
||||
.prepare("PRAGMA table_info(builds)")
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
if (!columns.some((column) => column.name === "app_name_en")) {
|
||||
database.exec("ALTER TABLE builds ADD COLUMN app_name_en TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
}
|
||||
|
||||
export function insertBuild(record: {
|
||||
id: string;
|
||||
appName: string;
|
||||
appNameEn: string;
|
||||
appIdentifier: string;
|
||||
}): BuildRecord {
|
||||
const database = getDb();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO builds (id, app_name, app_identifier, status)
|
||||
VALUES (@id, @appName, @appIdentifier, 'pending')`,
|
||||
`INSERT INTO builds (id, app_name, app_name_en, app_identifier, status)
|
||||
VALUES (@id, @appName, @appNameEn, @appIdentifier, 'pending')`,
|
||||
)
|
||||
.run(record);
|
||||
|
||||
@@ -105,6 +118,7 @@ export function toPublicBuild(record: BuildRecord) {
|
||||
return {
|
||||
id: record.id,
|
||||
appName: record.app_name,
|
||||
appNameEn: record.app_name_en || record.app_name,
|
||||
appIdentifier: record.app_identifier,
|
||||
status: record.status,
|
||||
workflowRunId: record.workflow_run_id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS builds (
|
||||
id TEXT PRIMARY KEY,
|
||||
app_name TEXT NOT NULL,
|
||||
app_name_en TEXT NOT NULL DEFAULT '',
|
||||
app_identifier TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
workflow_run_id INTEGER,
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import {
|
||||
normalizeAppIdentifier,
|
||||
slugifyIdentifier,
|
||||
validateChineseAppName,
|
||||
validateEnglishAppName,
|
||||
validateZipBuffer,
|
||||
ZipValidationError,
|
||||
} from "../services/zip.js";
|
||||
@@ -54,14 +56,14 @@ buildsRouter.get("/:id", async (req, res) => {
|
||||
|
||||
buildsRouter.post("/", upload.single("file"), async (req, res) => {
|
||||
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();
|
||||
|
||||
if (!appName) {
|
||||
res.status(400).json({ error: "appName is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: "file is required" });
|
||||
return;
|
||||
@@ -74,19 +76,25 @@ buildsRouter.post("/", upload.single("file"), async (req, res) => {
|
||||
|
||||
const { normalizedBuffer } = validateZipBuffer(req.file.buffer, maxUploadBytes);
|
||||
const jobId = nanoid(10);
|
||||
const slug = slugifyIdentifier(appName) || jobId.toLowerCase();
|
||||
const slug = slugifyIdentifier(appNameEn) || jobId.toLowerCase();
|
||||
const appIdentifier = normalizeAppIdentifier(
|
||||
identifierInput || `com.web2app.${slug}`,
|
||||
);
|
||||
|
||||
insertBuild({ id: jobId, appName, appIdentifier });
|
||||
insertBuild({
|
||||
id: jobId,
|
||||
appName: appNameZh,
|
||||
appNameEn,
|
||||
appIdentifier,
|
||||
});
|
||||
|
||||
await uploadSiteZip(jobId, normalizedBuffer);
|
||||
updateBuild(jobId, { status: "queued" });
|
||||
|
||||
const workflowRunId = await triggerBuildWorkflow({
|
||||
jobId,
|
||||
appName,
|
||||
appName: appNameZh,
|
||||
appNameEn,
|
||||
appIdentifier,
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export async function uploadSiteZip(jobId: string, zipBuffer: Buffer): Promise<v
|
||||
export async function triggerBuildWorkflow(input: {
|
||||
jobId: string;
|
||||
appName: string;
|
||||
appNameEn: string;
|
||||
appIdentifier: string;
|
||||
}): Promise<number> {
|
||||
const { owner, repo } = getGitHubConfig();
|
||||
@@ -74,6 +75,7 @@ export async function triggerBuildWorkflow(input: {
|
||||
inputs: {
|
||||
job_id: input.jobId,
|
||||
app_name: input.appName,
|
||||
app_name_en: input.appNameEn,
|
||||
app_identifier: input.appIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -154,6 +154,27 @@ function sanitizeSegment(segment: string): string {
|
||||
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 {
|
||||
return input
|
||||
.toLowerCase()
|
||||
|
||||
@@ -8,6 +8,7 @@ export type BuildStatus =
|
||||
export interface Build {
|
||||
id: string;
|
||||
appName: string;
|
||||
appNameEn: string;
|
||||
appIdentifier: string;
|
||||
status: BuildStatus;
|
||||
workflowRunId: number | null;
|
||||
|
||||
@@ -58,9 +58,13 @@ export default function JobStatus() {
|
||||
<dd>{build.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>应用名称</dt>
|
||||
<dt>应用中文名</dt>
|
||||
<dd>{build.appName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>应用英文名</dt>
|
||||
<dd>{build.appNameEn}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bundle ID</dt>
|
||||
<dd>{build.appIdentifier}</dd>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { createBuild } from "../api";
|
||||
|
||||
export default function Upload() {
|
||||
const navigate = useNavigate();
|
||||
const [appName, setAppName] = useState("");
|
||||
const [appNameZh, setAppNameZh] = useState("");
|
||||
const [appNameEn, setAppNameEn] = useState("");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -23,7 +24,8 @@ export default function Upload() {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("appName", appName);
|
||||
formData.append("appNameZh", appNameZh);
|
||||
formData.append("appNameEn", appNameEn);
|
||||
if (identifier.trim()) {
|
||||
formData.append("identifier", identifier.trim());
|
||||
}
|
||||
@@ -40,15 +42,29 @@ export default function Upload() {
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>上传静态站点</h2>
|
||||
<p className="hint">压缩包需包含 index.html,大小默认不超过 50MB。</p>
|
||||
<p className="hint">
|
||||
压缩包需包含 index.html,大小默认不超过 50MB。中文名用于界面展示,英文名用于安装包与
|
||||
Bundle ID 生成。
|
||||
</p>
|
||||
<form className="form" onSubmit={onSubmit}>
|
||||
<label>
|
||||
应用名称
|
||||
应用中文名
|
||||
<input
|
||||
value={appName}
|
||||
onChange={(e) => setAppName(e.target.value)}
|
||||
value={appNameZh}
|
||||
onChange={(e) => setAppNameZh(e.target.value)}
|
||||
placeholder="我的应用"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
应用英文名
|
||||
<input
|
||||
value={appNameEn}
|
||||
onChange={(e) => setAppNameEn(e.target.value)}
|
||||
placeholder="My App"
|
||||
required
|
||||
pattern="[a-zA-Z][a-zA-Z0-9 _.-]*"
|
||||
title="以字母开头,仅含英文字母、数字、空格、下划线和连字符"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
Reference in New Issue
Block a user