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