diff --git a/.github/scripts/patch-android-system-bars.mjs b/.github/scripts/patch-android-system-bars.mjs index 795c9bd..e900864 100644 --- a/.github/scripts/patch-android-system-bars.mjs +++ b/.github/scripts/patch-android-system-bars.mjs @@ -100,4 +100,100 @@ for (const file of walk(androidRoot, (p) => { patchThemes(file); } -console.log("Android system bar patch applied"); +const NETWORK_SECURITY_XML = ` + + + + + + + + +`; + +function ensureNetworkSecurityXml(androidRoot) { + const resDirs = walk(androidRoot, (p) => { + const normalized = p.replace(/\\/g, "/"); + return normalized.endsWith("/app/src/main/res"); + }); + + for (const resDir of resDirs) { + const xmlDir = path.join(resDir, "xml"); + fs.mkdirSync(xmlDir, { recursive: true }); + const target = path.join(xmlDir, "network_security_config.xml"); + fs.writeFileSync(target, NETWORK_SECURITY_XML); + console.log(`Wrote ${path.relative(root, target)}`); + } +} + +function patchAndroidManifest(androidRoot) { + for (const file of walk(androidRoot, (p) => p.endsWith("AndroidManifest.xml"))) { + const normalized = file.replace(/\\/g, "/"); + if (!normalized.includes("/src/main/")) continue; + + let xml = fs.readFileSync(file, "utf8"); + if (!xml.includes("android.permission.INTERNET")) { + xml = xml.replace( + /\n\n ]*)>/, + '', + ); + } + + if (!xml.includes("networkSecurityConfig")) { + xml = xml.replace( + /]*)>/, + '', + ); + } + + fs.writeFileSync(file, xml); + console.log(`Patched AndroidManifest: ${path.relative(root, file)}`); + } +} + +function patchGradleCleartext(androidRoot) { + for (const file of walk(androidRoot, (p) => p.endsWith("build.gradle.kts"))) { + const normalized = file.replace(/\\/g, "/"); + if (!normalized.includes("/app/")) continue; + + let content = fs.readFileSync(file, "utf8"); + if (content.includes('manifestPlaceholders["usesCleartextTraffic"]')) { + content = content.replace( + /manifestPlaceholders\["usesCleartextTraffic"\]\s*=\s*"[^"]*"/, + 'manifestPlaceholders["usesCleartextTraffic"] = "true"', + ); + } else if (content.includes("defaultConfig {")) { + content = content.replace( + /defaultConfig\s*\{/, + `defaultConfig {\n manifestPlaceholders["usesCleartextTraffic"] = "true"`, + ); + } else { + continue; + } + + fs.writeFileSync(file, content); + console.log(`Patched Gradle cleartext: ${path.relative(root, file)}`); + } +} + +ensureNetworkSecurityXml(androidRoot); +patchAndroidManifest(androidRoot); +patchGradleCleartext(androidRoot); + +console.log("Android app patches applied (system bars + network)"); diff --git a/.github/scripts/prepare-template.mjs b/.github/scripts/prepare-template.mjs index df3f76b..43e50b7 100644 --- a/.github/scripts/prepare-template.mjs +++ b/.github/scripts/prepare-template.mjs @@ -84,6 +84,23 @@ function patchTauriConfig(filePath) { if (conf.app?.windows?.[0]) { conf.app.windows[0].title = appNameZh; } + conf.app = { + ...conf.app, + security: { + csp: { + "default-src": "'self' asset: tauri: https: http: data: blob: file:", + "connect-src": + "'self' asset: tauri: https: http: ws: wss: data: blob: file:", + "img-src": "'self' asset: tauri: https: http: data: blob: file:", + "media-src": "'self' asset: tauri: https: http: data: blob: file:", + "style-src": "'self' 'unsafe-inline' asset: tauri: https: http:", + "script-src": + "'self' 'unsafe-inline' 'unsafe-eval' asset: tauri: https: http:", + "frame-src": "'self' asset: tauri: https: http: data: blob:", + "worker-src": "'self' blob: data:", + }, + }, + }; conf.bundle = { ...conf.bundle, active: true, diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index 6a11d79..ef10da0 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -145,7 +145,7 @@ jobs: env: NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/${{ env.NDK_VERSION }} - - name: Disable edge-to-edge system bars on Android + - name: Patch Android app (system bars + network) run: node .github/scripts/patch-android-system-bars.mjs - name: Regenerate app icons for Android diff --git a/template/src-tauri/tauri.conf.json b/template/src-tauri/tauri.conf.json index dabe568..a0bfa52 100644 --- a/template/src-tauri/tauri.conf.json +++ b/template/src-tauri/tauri.conf.json @@ -21,7 +21,16 @@ } ], "security": { - "csp": null + "csp": { + "default-src": "'self' asset: tauri: https: http: data: blob: file:", + "connect-src": "'self' asset: tauri: https: http: ws: wss: data: blob: file:", + "img-src": "'self' asset: tauri: https: http: data: blob: file:", + "media-src": "'self' asset: tauri: https: http: data: blob: file:", + "style-src": "'self' 'unsafe-inline' asset: tauri: https: http:", + "script-src": "'self' 'unsafe-inline' 'unsafe-eval' asset: tauri: https: http:", + "frame-src": "'self' asset: tauri: https: http: data: blob:", + "worker-src": "'self' blob: data:" + } } }, "bundle": { diff --git a/worker/src/index.ts b/worker/src/index.ts index 814c0e1..6a97e05 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,13 +1,20 @@ import type { Env } from "./env"; import { handleBuildsRequest } from "./routes/builds"; -import { jsonResponse } from "./lib/response"; +import { + corsPreflightResponse, + jsonResponseWithCors, +} from "./lib/response"; export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); + if (request.method === "OPTIONS" && url.pathname.startsWith("/api/")) { + return corsPreflightResponse(request); + } + if (url.pathname === "/api/health") { - return jsonResponse({ ok: true }); + return jsonResponseWithCors(request, { ok: true }); } if (url.pathname.startsWith("/api/builds")) { diff --git a/worker/src/lib/response.ts b/worker/src/lib/response.ts index 7e97438..ff2dfe7 100644 --- a/worker/src/lib/response.ts +++ b/worker/src/lib/response.ts @@ -1,3 +1,16 @@ +export function corsHeaders(request: Request): Record { + const origin = request.headers.get("Origin"); + if (!origin) return {}; + + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + Vary: "Origin", + }; +} + export function jsonResponse( data: unknown, status = 200, @@ -12,6 +25,25 @@ export function jsonResponse( }); } +export function jsonResponseWithCors( + request: Request, + data: unknown, + status = 200, + headers?: Record, +): Response { + return jsonResponse(data, status, { + ...corsHeaders(request), + ...headers, + }); +} + +export function corsPreflightResponse(request: Request): Response { + return new Response(null, { + status: 204, + headers: corsHeaders(request), + }); +} + export function errorResponse(message: string, status: number): Response { return jsonResponse({ error: message }, status); } diff --git a/worker/src/routes/builds.ts b/worker/src/routes/builds.ts index 099e2f5..e071892 100644 --- a/worker/src/routes/builds.ts +++ b/worker/src/routes/builds.ts @@ -8,7 +8,19 @@ import { updateBuild, type BuildRecord, } from "../db/builds"; -import { errorResponse, jsonResponse } from "../lib/response"; +import { jsonResponseWithCors } from "../lib/response"; + +function apiJson( + request: Request, + data: unknown, + status = 200, +): Response { + return jsonResponseWithCors(request, data, status); +} + +function apiError(request: Request, message: string, status: number): Response { + return jsonResponseWithCors(request, { error: message }, status); +} import { getActionsRunUrl, getReleaseAssets, @@ -45,7 +57,7 @@ export async function handleBuildsRequest( if (path === "/api/builds" && request.method === "GET") { const builds = (await listBuilds(env)).map(toPublicBuild); - return jsonResponse({ builds }); + return apiJson(request, { builds }); } const match = path.match(/^\/api\/builds\/([^/]+)$/); @@ -55,24 +67,24 @@ export async function handleBuildsRequest( if (request.method === "GET") { const record = await getBuild(env, id); if (!record) { - return errorResponse("Build not found", 404); + return apiError(request, "Build not found", 404); } const refreshed = await refreshBuildStatus(env, record); - return jsonResponse({ + return apiJson(request, { ...toPublicBuild(refreshed), actionsUrl: getActionsRunUrl(env, refreshed.workflow_run_id), }); } - return errorResponse("Method not allowed", 405); + return apiError(request, "Method not allowed", 405); } if (path === "/api/builds" && request.method === "POST") { return createBuild(request, env); } - return errorResponse("Not found", 404); + return apiError(request, "Not found", 404); } async function createBuild(request: Request, env: Env): Promise { @@ -91,11 +103,11 @@ async function createBuild(request: Request, env: Env): Promise { const file = formData.get("file"); if (!(file instanceof File)) { - return errorResponse("file is required", 400); + return apiError(request, "file is required", 400); } if (!file.name.toLowerCase().endsWith(".zip")) { - return errorResponse("Only .zip files are supported", 400); + return apiError(request, "Only .zip files are supported", 400); } const buffer = new Uint8Array(await file.arrayBuffer()); @@ -143,7 +155,8 @@ async function createBuild(request: Request, env: Env): Promise { status: "in_progress", }); - return jsonResponse( + return apiJson( + request, { id: jobId, status: "in_progress", @@ -157,11 +170,12 @@ async function createBuild(request: Request, env: Env): Promise { error instanceof VersionValidationError || error instanceof IconValidationError ) { - return errorResponse(error.message, 400); + return apiError(request, error.message, 400); } console.error(error); - return errorResponse( + return apiError( + request, error instanceof Error ? error.message : "Failed to create build", 500, );