268 lines
12 KiB
TypeScript
268 lines
12 KiB
TypeScript
/**
|
|
* `wrnexus generate mobile` — scaffold a WebView or fully native mobile app.
|
|
*
|
|
* WrNexus remains the hosted SSR/API/WebSocket server. Capacitor loads that
|
|
* server in a native WebView and provides the bridge for native plugins.
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { basename, join, resolve } from "node:path";
|
|
import { loadAppConfig } from "@wrnexus/styles";
|
|
|
|
export type MobileMode = "webview" | "native";
|
|
|
|
export interface MobileOptions {
|
|
appId?: string;
|
|
appName?: string;
|
|
serverUrl?: string;
|
|
mode?: MobileMode;
|
|
}
|
|
|
|
function slug(value: string): string {
|
|
const cleaned = value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "")
|
|
.replace(/^\d+/, "");
|
|
return cleaned || "app";
|
|
}
|
|
|
|
function projectName(root: string): string {
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { name?: string };
|
|
if (pkg.name) return pkg.name.split("/").pop() || basename(root);
|
|
} catch {
|
|
// A package manifest is helpful but not required to generate the shell.
|
|
}
|
|
return basename(root);
|
|
}
|
|
|
|
function writeIfAbsent(path: string, content: string, label: string): void {
|
|
if (existsSync(path)) {
|
|
console.warn(` • ${label} already exists — skipped`);
|
|
return;
|
|
}
|
|
mkdirSync(resolve(path, ".."), { recursive: true });
|
|
writeFileSync(path, content, "utf8");
|
|
console.log(` ✓ ${label}`);
|
|
}
|
|
|
|
/** Scaffold a Capacitor mobile wrapper under `<appRoot>/mobile`. */
|
|
export async function generateMobile(appRoot: string, options: MobileOptions = {}): Promise<void> {
|
|
const root = resolve(appRoot);
|
|
const appConfig = await loadAppConfig(root);
|
|
const configured = appConfig.mobile ?? {};
|
|
const mode = options.mode ?? configured.mode ?? "webview";
|
|
if (mode !== "webview" && mode !== "native") {
|
|
throw new Error('mobile.mode must be either "webview" or "native"');
|
|
}
|
|
const name = options.appName || configured.appName || projectName(root);
|
|
const appId = options.appId || configured.appId || `com.example.${slug(name)}`;
|
|
const serverUrl = options.serverUrl || configured.serverUrl || "http://localhost:3000";
|
|
const mobile = join(root, "mobile");
|
|
|
|
if (mode === "native") {
|
|
generateNativeMobile(mobile, {
|
|
name,
|
|
appId,
|
|
apiUrl: configured.apiUrl ?? serverUrl,
|
|
configured,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const pkg = {
|
|
name: `${slug(name)}-mobile`,
|
|
version: "0.1.0",
|
|
private: true,
|
|
wrnexus: { mode: "webview" },
|
|
type: "module",
|
|
scripts: {
|
|
"add:ios": "cap add ios",
|
|
"add:android": "cap add android",
|
|
sync: "cap sync",
|
|
"open:ios": "cap open ios",
|
|
"open:android": "cap open android",
|
|
},
|
|
dependencies: {
|
|
"@capacitor/core": "^8.0.0",
|
|
"@capacitor/app": "^8.0.0",
|
|
"@capacitor/ios": "^8.0.0",
|
|
"@capacitor/android": "^8.0.0",
|
|
},
|
|
devDependencies: {
|
|
"@capacitor/cli": "^8.0.0",
|
|
"@capacitor/assets": "^3.0.0",
|
|
typescript: "^5.5.0",
|
|
},
|
|
};
|
|
|
|
const config = `import type { CapacitorConfig } from "@capacitor/cli";
|
|
import appConfig from "../wrnexus.config.ts";
|
|
|
|
const mobile = appConfig.mobile ?? {};
|
|
const serverUrl = process.env.WRNEXUS_MOBILE_URL ?? mobile.serverUrl ?? ${JSON.stringify(serverUrl)};
|
|
|
|
const config: CapacitorConfig = {
|
|
appId: mobile.appId ?? ${JSON.stringify(appId)},
|
|
appName: mobile.appName ?? ${JSON.stringify(name)},
|
|
webDir: "web",
|
|
appendUserAgent: mobile.userAgent ?? " WrNexusMobile",
|
|
backgroundColor: mobile.backgroundColor,
|
|
...(mobile.capacitor ?? {}),
|
|
server: {
|
|
...((mobile.capacitor?.server as CapacitorConfig["server"]) ?? {}),
|
|
// Development bridge only: Capacitor does not recommend server.url in production.
|
|
url: serverUrl,
|
|
cleartext: serverUrl.startsWith("http://"),
|
|
},
|
|
};
|
|
|
|
export default config;
|
|
`;
|
|
|
|
const errorPage = `<!doctype html>
|
|
<html lang="en">
|
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Connection unavailable</title>
|
|
<style>html{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px system-ui;background:#0f172a;color:#e2e8f0}.card{max-width:28rem;padding:2rem;text-align:center}button{padding:.8rem 1.2rem;border:0;border-radius:.75rem;background:#6c8cff;color:white;font-weight:700}</style></head>
|
|
<body><main class="card"><h1>Connection unavailable</h1><p>Check your Wi-Fi and make sure the WrNexus server is running.</p><button id="retry">Try again</button></main><script>document.getElementById("retry").onclick=()=>location.replace(${JSON.stringify(serverUrl)});</script></body>
|
|
</html>
|
|
`;
|
|
|
|
const fallback = `<!doctype html>
|
|
<html lang="en">
|
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${name}</title></head>
|
|
<body><p>Run the WrNexus server and then sync this mobile project.</p></body>
|
|
</html>
|
|
`;
|
|
|
|
const readme = `# ${name} mobile
|
|
|
|
Capacitor development shell for the hosted WrNexus application. The Bun server,
|
|
SSR, APIs, database, and WebSockets continue to run on your server; this directory
|
|
contains the native iOS and Android projects and native plugin dependencies.
|
|
|
|
> Capacitor documents \`server.url\` as a live-reload option that is not intended
|
|
> for production. This shell is useful for native development and testing, but a
|
|
> store release needs a bundled client build/static export that WrNexus does not
|
|
> currently produce.
|
|
|
|
## Setup
|
|
|
|
\`\`\`bash
|
|
node --version # Capacitor 8 requires Node.js 22+
|
|
bun install
|
|
bun run add:android
|
|
bun run add:ios # macOS with Xcode is required
|
|
bun run sync
|
|
\`\`\`
|
|
|
|
Set \`WRNEXUS_MOBILE_URL\` to a reachable URL before syncing the development shell:
|
|
|
|
\`\`\`bash
|
|
WRNEXUS_MOBILE_URL=https://app.example.com bun run sync
|
|
\`\`\`
|
|
|
|
For a physical device, \`localhost\` refers to the device, not your computer.
|
|
Use your computer's LAN URL during development. Do not ship the generated
|
|
\`server.url\` configuration as a production store build.
|
|
|
|
Open the native projects with \`bun run open:android\` or \`bun run open:ios\`.
|
|
Add native features with Capacitor plugins and run \`bun run sync\` afterward.
|
|
`;
|
|
|
|
console.log("Scaffolding Capacitor mobile app:");
|
|
writeIfAbsent(
|
|
join(mobile, "package.json"),
|
|
`${JSON.stringify(pkg, null, 2)}\n`,
|
|
"mobile/package.json",
|
|
);
|
|
writeIfAbsent(join(mobile, "capacitor.config.ts"), config, "mobile/capacitor.config.ts");
|
|
writeIfAbsent(join(mobile, "web", "index.html"), fallback, "mobile/web/index.html");
|
|
writeIfAbsent(join(mobile, "web", "error.html"), errorPage, "mobile/web/error.html");
|
|
writeIfAbsent(
|
|
join(mobile, ".gitignore"),
|
|
"node_modules\nandroid/.gradle\nios/App/Pods\n",
|
|
"mobile/.gitignore",
|
|
);
|
|
writeIfAbsent(join(mobile, "README.md"), readme, "mobile/README.md");
|
|
console.log("\nNext: cd mobile && bun install && bun run add:android");
|
|
console.log("iOS generation requires macOS with Xcode: bun run add:ios");
|
|
}
|
|
|
|
/** Convert CLI flags into generator options. */
|
|
export function mobileOptions(args: string[]): MobileOptions {
|
|
const value = (flag: string) =>
|
|
args.find((arg) => arg.startsWith(`${flag}=`))?.slice(flag.length + 1);
|
|
const modeValue = value("--mode");
|
|
if (modeValue && modeValue !== "webview" && modeValue !== "native") {
|
|
throw new Error('--mode must be either "webview" or "native"');
|
|
}
|
|
const mode: MobileMode | undefined =
|
|
modeValue === "webview" || modeValue === "native" ? modeValue : undefined;
|
|
return {
|
|
appId: value("--app-id"),
|
|
appName: value("--app-name"),
|
|
serverUrl: value("--url"),
|
|
mode,
|
|
};
|
|
}
|
|
|
|
function generateNativeMobile(
|
|
mobile: string,
|
|
input: { name: string; appId: string; apiUrl: string; configured: { scheme?: string } },
|
|
): void {
|
|
const { name, appId, apiUrl, configured } = input;
|
|
const scheme = configured.scheme ?? slug(name);
|
|
const pkg = {
|
|
name: `${slug(name)}-mobile`,
|
|
version: "0.1.0",
|
|
private: true,
|
|
main: "expo-router/entry",
|
|
wrnexus: { mode: "native" },
|
|
scripts: {
|
|
compile: "cd .. && wrnexus mobile compile",
|
|
prestart: "bun run compile",
|
|
start: "expo start",
|
|
android: "expo run:android",
|
|
ios: "expo run:ios",
|
|
web: "expo start --web",
|
|
prebuild: "expo prebuild",
|
|
},
|
|
dependencies: {
|
|
expo: "^57.0.0",
|
|
"expo-router": "~57.0.4",
|
|
"expo-status-bar": "~57.0.0",
|
|
react: "19.2.3",
|
|
"react-native": "0.86.0",
|
|
"react-native-safe-area-context": "^5.6.0",
|
|
"react-native-screens": "^4.23.0",
|
|
},
|
|
devDependencies: { "@types/react": "^19.2.0", typescript: "^5.9.0" },
|
|
};
|
|
const expo = `import type { ExpoConfig } from "expo/config";\nimport appConfig from "../wrnexus.config.ts";\n\nconst mobile = appConfig.mobile ?? {};\nconst config: ExpoConfig = {\n name: mobile.appName ?? ${JSON.stringify(name)},\n slug: ${JSON.stringify(slug(name))},\n scheme: mobile.scheme ?? ${JSON.stringify(scheme)},\n ios: { bundleIdentifier: mobile.appId ?? ${JSON.stringify(appId)} },\n android: { package: mobile.appId ?? ${JSON.stringify(appId)} },\n plugins: ["expo-router"],\n ...(mobile.expo ?? {}),\n};\nexport default config;\n`;
|
|
const env = `/** Shared connection settings for native screens. */\nexport const API_URL = process.env.EXPO_PUBLIC_WRNEXUS_URL ?? ${JSON.stringify(apiUrl)};\nexport async function api<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await fetch(new URL(path, API_URL), init);\n if (!response.ok) throw new Error(\`WrNexus request failed: \${response.status}\`);\n return response.json() as Promise<T>;\n}\nexport function realtimeUrl(path: string): string {\n const url = new URL(path, API_URL);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n return url.toString();\n}\n`;
|
|
const screen = `import { StyleSheet, Text, View } from "react-native";\nimport { StatusBar } from "expo-status-bar";\n\nexport default function Home() {\n return <View style={styles.container}><StatusBar style="auto" /><Text style={styles.title}>${name}</Text><Text>Fully native WrNexus client</Text></View>;\n}\nconst styles = StyleSheet.create({ container: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, title: { fontSize: 28, fontWeight: "700", marginBottom: 8 } });\n`;
|
|
const readme = `# ${name} native mobile\n\nThis is a fully native Expo/React Native client: it does not use a WebView. Portable pages from \`../app/pages/**/*.wrn\` compile into Expo routes with \`bun run compile\` (also run automatically before \`start\`). You can edit or add native-only TSX screens in \`app/\`, and call the shared WrNexus backend through \`src/wrnexus.ts\`.\n\n\`\`\`bash\nbun install\nbun run compile\nbun run start\nbun run android\n# macOS/Xcode: bun run ios\n\`\`\`\n\nServer API routes, authentication endpoints, uploads, and WebSockets remain reusable. Unsupported DOM-only markup fails compilation with a specific error. Override the backend per environment with \`EXPO_PUBLIC_WRNEXUS_URL\`. Add native modules with \`bunx expo install <package>\`.\n`;
|
|
console.log("Scaffolding fully native Expo mobile app:");
|
|
writeIfAbsent(
|
|
join(mobile, "package.json"),
|
|
`${JSON.stringify(pkg, null, 2)}\n`,
|
|
"mobile/package.json",
|
|
);
|
|
writeIfAbsent(join(mobile, "app.config.ts"), expo, "mobile/app.config.ts");
|
|
writeIfAbsent(join(mobile, "app", "index.tsx"), screen, "mobile/app/index.tsx");
|
|
writeIfAbsent(join(mobile, "src", "wrnexus.ts"), env, "mobile/src/wrnexus.ts");
|
|
writeIfAbsent(
|
|
join(mobile, "tsconfig.json"),
|
|
`${JSON.stringify({ extends: "expo/tsconfig.base", compilerOptions: { strict: true } }, null, 2)}\n`,
|
|
"mobile/tsconfig.json",
|
|
);
|
|
writeIfAbsent(
|
|
join(mobile, ".gitignore"),
|
|
"node_modules\n.expo\nandroid\nios\n",
|
|
"mobile/.gitignore",
|
|
);
|
|
writeIfAbsent(join(mobile, "README.md"), readme, "mobile/README.md");
|
|
console.log("\nNext: cd mobile && bun install && bun run start");
|
|
}
|