fix(android): allow network requests and relax WebView CSP
Enable cleartext HTTP in release APK, add network security config, and set connect-src CSP. Add CORS on Worker API for cross-origin fetches from packaged apps. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
98
.github/scripts/patch-android-system-bars.mjs
vendored
98
.github/scripts/patch-android-system-bars.mjs
vendored
@@ -100,4 +100,100 @@ for (const file of walk(androidRoot, (p) => {
|
||||
patchThemes(file);
|
||||
}
|
||||
|
||||
console.log("Android system bar patch applied");
|
||||
const NETWORK_SECURITY_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
`;
|
||||
|
||||
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(
|
||||
/<application/,
|
||||
' <uses-permission android:name="android.permission.INTERNET" />\n\n <application',
|
||||
);
|
||||
}
|
||||
|
||||
xml = xml.replace(
|
||||
/android:usesCleartextTraffic="\$\{usesCleartextTraffic\}"/g,
|
||||
'android:usesCleartextTraffic="true"',
|
||||
);
|
||||
xml = xml.replace(
|
||||
/android:usesCleartextTraffic="false"/g,
|
||||
'android:usesCleartextTraffic="true"',
|
||||
);
|
||||
|
||||
if (!xml.includes("usesCleartextTraffic")) {
|
||||
xml = xml.replace(
|
||||
/<application([^>]*)>/,
|
||||
'<application$1 android:usesCleartextTraffic="true">',
|
||||
);
|
||||
}
|
||||
|
||||
if (!xml.includes("networkSecurityConfig")) {
|
||||
xml = xml.replace(
|
||||
/<application([^>]*)>/,
|
||||
'<application$1 android:networkSecurityConfig="@xml/network_security_config">',
|
||||
);
|
||||
}
|
||||
|
||||
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)");
|
||||
|
||||
17
.github/scripts/prepare-template.mjs
vendored
17
.github/scripts/prepare-template.mjs
vendored
@@ -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,
|
||||
|
||||
2
.github/workflows/build-app.yml
vendored
2
.github/workflows/build-app.yml
vendored
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<Response> {
|
||||
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")) {
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
export function corsHeaders(request: Request): Record<string, string> {
|
||||
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<string, string>,
|
||||
): 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);
|
||||
}
|
||||
|
||||
@@ -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<Response> {
|
||||
@@ -91,11 +103,11 @@ async function createBuild(request: Request, env: Env): Promise<Response> {
|
||||
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user