/** Commands for maintaining the generated Capacitor package. */ import { spawnSync } from "node:child_process"; import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs"; import { join, resolve } from "node:path"; import { loadAppConfig } from "@wrnexus/styles"; import { compileNativeWireFile } from "@wrnexus/compiler"; function validPackageName(value: string): boolean { return /^(?:@[a-z0-9._~-]+\/)?[a-z0-9._~-]+(?:@[a-zA-Z0-9._~^<>=|*+-]+)?$/.test(value); } function run(command: string, args: string[], cwd: string): void { const result = spawnSync(command, args, { cwd, stdio: "inherit", shell: process.platform === "win32", }); if (result.error) throw result.error; if (result.status !== 0) { throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}`); } } function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => { const entities: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }; return entities[char]!; }); } async function updateMobileErrorPage(root: string, mobileDir: string): Promise { const config = await loadAppConfig(root); const mobile = config.mobile ?? {}; const title = mobile.errorTitle ?? "Connection unavailable"; const message = mobile.errorMessage ?? "Check your Wi-Fi and make sure the WrNexus server is running."; const serverUrl = mobile.serverUrl ?? "http://localhost:3000"; const background = mobile.backgroundColor ?? "#0f172a"; const retryUrl = JSON.stringify(serverUrl).replaceAll("<", "\\u003c"); const page = ` ${escapeHtml(title)}

${escapeHtml(title)}

${escapeHtml(message)}

`; const path = join(mobileDir, "web", "error.html"); mkdirSync(resolve(path, ".."), { recursive: true }); writeFileSync(path, page, "utf8"); console.log(" ✓ mobile/web/error.html updated from wrnexus.config.ts"); } function configureAndroidNetworkErrors(mobileDir: string): void { const javaRoot = join(mobileDir, "android", "app", "src", "main", "java"); if (!existsSync(javaRoot)) return; const file = (readdirSync(javaRoot, { recursive: true }) as string[]) .map((entry) => join(javaRoot, entry)) .find((entry) => entry.endsWith("MainActivity.java")); if (!file) return; const current = readFileSync(file, "utf8"); if (current.includes("WRNEXUS_NETWORK_ERROR_ONLY")) return; const packageName = /^package\s+([\w.]+);/m.exec(current)?.[1]; if ( !packageName || !/public\s+class\s+MainActivity\s+extends\s+BridgeActivity\s*\{\s*\}/s.test(current) ) { console.warn(" • MainActivity.java is customized — network-error handling was not changed"); return; } writeFileSync( file, `package ${packageName}; import android.os.Bundle; import android.webkit.WebResourceError; import android.webkit.WebResourceRequest; import android.webkit.WebResourceResponse; import android.webkit.WebView; import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeWebViewClient; // WRNEXUS_NETWORK_ERROR_ONLY public class MainActivity extends BridgeActivity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); bridge.setWebViewClient(new BridgeWebViewClient(bridge) { @Override public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { if (request.isForMainFrame()) view.loadUrl("file:///android_asset/public/error.html"); } @Override public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse response) { // Preserve WrNexus HTTP error pages (404, 500, etc.). } }); } } `, "utf8", ); console.log(" ✓ Android network-only error page configured"); } export async function runMobileCommand( appRoot: string, subcommand?: string, args: string[] = [], ): Promise { const root = resolve(appRoot); const mobileDir = join(root, "mobile"); if (!existsSync(join(mobileDir, "package.json"))) { throw new Error("No mobile project found. Run `wrnexus generate mobile` first."); } const mobilePackage = JSON.parse(readFileSync(join(mobileDir, "package.json"), "utf8")) as { wrnexus?: { mode?: "webview" | "native" }; }; const mode = mobilePackage.wrnexus?.mode ?? "webview"; if (subcommand === "compile") { if (mode !== "native") throw new Error("`wrnexus mobile compile` requires native mode."); const pagesDir = join(root, "app", "pages"); if (!existsSync(pagesDir)) throw new Error(`Pages directory not found: ${pagesDir}`); let count = 0; for (const entry of readdirSync(pagesDir, { recursive: true }) as string[]) { if (!entry.endsWith(".wrn")) continue; const source = join(pagesDir, entry); const relative = entry.replace(/\.wrn$/, ".tsx"); const output = join(mobileDir, "app", relative); mkdirSync(resolve(output, ".."), { recursive: true }); try { writeFileSync(output, compileNativeWireFile(readFileSync(source, "utf8")), "utf8"); } catch (error) { throw new Error( `Native compilation failed for app/pages/${entry}: ${(error as Error).message}`, { cause: error }, ); } count++; } console.log(` ✓ compiled ${count} .wrn page${count === 1 ? "" : "s"} to mobile/app`); return; } if (subcommand === "add") { const packages = args.filter((arg) => !arg.startsWith("--")); if (!packages.length || packages.some((name) => !validPackageName(name))) { throw new Error("Usage: wrnexus mobile add "); } if (mode === "native") { run("bunx", ["expo", "install", ...packages], mobileDir); return; } // The root app needs the JavaScript proxy for its future browser bundle; // the mobile package needs the dependency so Capacitor can sync native code. run("bun", ["add", ...packages], root); run("bun", ["add", ...packages], mobileDir); await updateMobileErrorPage(root, mobileDir); run("bun", ["run", "sync"], mobileDir); configureAndroidNetworkErrors(mobileDir); return; } if (subcommand === "sync") { if (mode === "native") { run("bunx", ["expo", "prebuild"], mobileDir); return; } await updateMobileErrorPage(root, mobileDir); run("bun", ["run", "sync"], mobileDir); configureAndroidNetworkErrors(mobileDir); return; } if (subcommand === "assets") { const config = await loadAppConfig(root); if (!config.mobile?.icon) { throw new Error("Set `mobile.icon` in wrnexus.config.ts before generating native assets."); } const source = resolve(root, config.mobile.icon); if (!existsSync(source)) throw new Error(`Mobile icon not found: ${source}`); const resources = join(mobileDir, "resources"); mkdirSync(resources, { recursive: true }); copyFileSync(source, join(resources, "icon.png")); if (mode === "native") { console.log(" ✓ mobile/resources/icon.png copied (reference it with `mobile.expo.icon`)"); return; } run("bunx", ["capacitor-assets", "generate"], mobileDir); return; } throw new Error("Usage: wrnexus mobile |sync|assets>"); }