Files
WRNexusJS/packages/cli/src/mobile-command.ts
T
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +05:30

210 lines
7.9 KiB
TypeScript

/** 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 { compileNativeWrnFile } 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<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
return entities[char]!;
});
}
async function updateMobileErrorPage(root: string, mobileDir: string): Promise<void> {
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 = `<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title>
<style>html{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px system-ui;background:${background};color:#e2e8f0}.card{max-width:28rem;padding:2rem;text-align:center}button{padding:.8rem 1.2rem;border:0;border-radius:.75rem;background:#6366f1;color:white;font-weight:700}</style></head>
<body><main class="card"><h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p><button id="retry">Try again</button></main><script>document.getElementById("retry").onclick=()=>location.replace(${retryUrl});</script></body>
</html>
`;
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<void> {
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, compileNativeWrnFile(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 <native-package...>");
}
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 <compile|add <package...>|sync|assets>");
}