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

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);
}