feat(FN-1093): add mobile build, PWA, and live reload workflow

- Optimize dashboard Vite output for mobile and include vite/client types in app typecheck
- Add PWA support with manifest, service worker, icons, and client registration hooks
- Add a mobile workspace package with Capacitor config and live-reload scripts for local development
- Add a dedicated GitHub Actions mobile pipeline and update mobile workflow documentation
- Add dashboard tests for build output, mobile scripts, and PWA asset coverage
This commit is contained in:
gsxdsm
2026-04-07 23:25:34 -07:00
parent 55969f0d6b
commit 1b3296d91f
20 changed files with 835 additions and 21 deletions

View File

@@ -399,41 +399,39 @@ Capacitor wraps the existing Fusion dashboard web build into native iOS and Andr
```bash
pnpm install
pnpm --filter @fusion/dashboard mobile:prepare
pnpm mobile:build
```
`mobile:prepare` runs `build:client` and then `cap:sync`, copying `dist/client` assets into native platform projects.
`mobile:build` builds `@fusion/dashboard` and syncs web assets into the `@fusion/mobile` Capacitor project.
### Running / Opening Native Projects
```bash
pnpm --filter @fusion/dashboard cap:open:ios
pnpm --filter @fusion/dashboard cap:open:android
pnpm mobile:ios
pnpm mobile:android
```
You can also run directly:
### Development with Live Reload
```bash
pnpm --filter @fusion/dashboard cap:run:ios
pnpm --filter @fusion/dashboard cap:run:android
pnpm mobile:dev:ios
pnpm mobile:dev:android
```
### Development with a Live Backend
The live-reload helper sets `FUSION_LIVE_RELOAD=true` automatically and defaults `FUSION_SERVER_URL` to `http://localhost:5173`.
Set `FUSION_BACKEND_URL` before sync/run commands so the mobile shell connects to a running Fusion backend (default dashboard backend port is `4040`):
```bash
FUSION_BACKEND_URL=http://YOUR_IP:4040 pnpm --filter @fusion/dashboard cap:sync
```
For the full workflow (CI pipeline, PWA details, troubleshooting), see [`../../MOBILE.md`](../../MOBILE.md).
### Mobile Scripts
- `cap:sync`
- `cap:open:ios`
- `cap:open:android`
- `cap:run:ios`
- `cap:run:android`
- `mobile:prepare`
Workspace (`package.json`):
- `mobile:build`
- `mobile:ios`
- `mobile:android`
- `mobile:dev:ios`
- `mobile:dev:android`
- `mobile:sync`
## Development

View File

@@ -0,0 +1,25 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
const distDir = resolve(__dirname, "../../dist/client");
const assetsDir = resolve(distDir, "assets");
const distExists = existsSync(distDir) && existsSync(assetsDir);
describe("mobile build output chunking", () => {
test.skipIf(!distExists)("creates vendor chunk files for core dependencies", () => {
const files = readdirSync(assetsDir);
const jsFiles = files.filter((file) => file.endsWith(".js"));
expect(jsFiles.length).toBeGreaterThan(2);
expect(jsFiles.some((file) => file.includes("vendor-react"))).toBe(true);
expect(jsFiles.some((file) => file.includes("vendor-xterm"))).toBe(true);
});
test.skipIf(!distExists)("index.html references chunked asset scripts", () => {
const indexHtml = readFileSync(resolve(distDir, "index.html"), "utf8");
expect(indexHtml).toContain("<script");
expect(indexHtml).toMatch(/assets\/.+-[A-Za-z0-9_-]+\.js/);
});
});

View File

@@ -0,0 +1,46 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
interface WorkspacePackageJson {
scripts?: Record<string, string | undefined>;
}
describe("mobile pipeline scripts", () => {
const rootPackagePath = resolve(__dirname, "../../../../package.json");
it("defines required root mobile scripts", () => {
const packageJson = JSON.parse(readFileSync(rootPackagePath, "utf8")) as WorkspacePackageJson;
const scripts = packageJson.scripts ?? {};
const requiredScriptNames = [
"mobile:build",
"mobile:ios",
"mobile:android",
"mobile:dev:ios",
"mobile:dev:android",
"mobile:sync",
];
for (const scriptName of requiredScriptNames) {
expect(typeof scripts[scriptName]).toBe("string");
expect((scripts[scriptName] ?? "").trim().length).toBeGreaterThan(0);
}
});
it("configures mobile:build to run dashboard build and cap sync", () => {
const packageJson = JSON.parse(readFileSync(rootPackagePath, "utf8")) as WorkspacePackageJson;
const mobileBuild = packageJson.scripts?.["mobile:build"] ?? "";
expect(mobileBuild).toContain("dashboard");
expect(mobileBuild).toContain("build");
expect(mobileBuild).toContain("cap sync");
});
it("includes platform-specific open commands", () => {
const packageJson = JSON.parse(readFileSync(rootPackagePath, "utf8")) as WorkspacePackageJson;
expect(packageJson.scripts?.["mobile:ios"] ?? "").toContain("ios");
expect(packageJson.scripts?.["mobile:android"] ?? "").toContain("android");
});
});

View File

@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
describe("PWA configuration", () => {
it("manifest defines required PWA fields and icon sizes", () => {
const manifestPath = resolve(__dirname, "../public/manifest.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
name?: string;
short_name?: string;
start_url?: string;
display?: string;
icons?: Array<{ sizes?: string }>;
};
expect(manifest.name).toBe("Fusion");
expect(manifest.short_name).toBe("Fusion");
expect(manifest.start_url).toBe("/");
expect(manifest.display).toBe("standalone");
expect(Array.isArray(manifest.icons)).toBe(true);
expect(manifest.icons?.some((icon) => icon.sizes?.includes("192"))).toBe(true);
expect(manifest.icons?.some((icon) => icon.sizes?.includes("512"))).toBe(true);
});
it("index.html includes required PWA meta tags", () => {
const indexHtml = readFileSync(resolve(__dirname, "../index.html"), "utf8");
expect(indexHtml).toContain('<link rel="manifest"');
expect(indexHtml).toContain("apple-mobile-web-app-capable");
});
it("service worker contains lifecycle handlers and versioned cache name", () => {
const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");
expect(swSource).toContain('addEventListener("install"');
expect(swSource).toContain('addEventListener("fetch"');
expect(swSource).toContain('addEventListener("activate"');
expect(swSource).toMatch(/fusion-cache-v\d+/);
});
});

View File

@@ -5,6 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Fusion</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#1a1a2e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<script>
// Theme initialization - runs before React to prevent flash
(function() {

View File

@@ -8,3 +8,14 @@ createRoot(document.getElementById("root")!).render(
<App />
</StrictMode>,
);
if (import.meta.env.PROD && "serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("SW registered:", registration.scope);
})
.catch((error) => {
console.log("SW registration failed:", error);
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "Fusion",
"short_name": "Fusion",
"description": "AI-orchestrated task board",
"start_url": "/",
"display": "standalone",
"theme_color": "#1a1a2e",
"background_color": "#1a1a2e",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -0,0 +1,90 @@
const CACHE_NAME = "fusion-cache-v1";
const APP_SHELL_URLS = [
"/",
"/index.html",
"/manifest.json",
"/logo.svg",
"/icons/icon-192.png",
"/icons/icon-512.png",
];
self.addEventListener("install", (event) => {
event.waitUntil((async () => {
try {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(APP_SHELL_URLS);
} catch (error) {
console.warn("[sw] install cache warmup failed", error);
}
})());
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
try {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
);
await self.clients.claim();
} catch (error) {
console.warn("[sw] activate cleanup failed", error);
}
})());
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") {
return;
}
const url = new URL(request.url);
const isApiRequest = url.pathname.startsWith("/api/");
if (isApiRequest) {
event.respondWith((async () => {
try {
const networkResponse = await fetch(request);
try {
const cache = await caches.open(CACHE_NAME);
await cache.put(request, networkResponse.clone());
} catch (cacheError) {
console.warn("[sw] api cache put failed", cacheError);
}
return networkResponse;
} catch (networkError) {
try {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
} catch (cacheError) {
console.warn("[sw] api cache lookup failed", cacheError);
}
throw networkError;
}
})());
return;
}
event.respondWith((async () => {
try {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
const networkResponse = await fetch(request);
await cache.put(request, networkResponse.clone());
return networkResponse;
} catch (error) {
console.warn("[sw] static cache flow failed", error);
return fetch(request);
}
})());
});

View File

@@ -6,7 +6,7 @@
"moduleResolution": "bundler",
"module": "ESNext",
"noEmit": true,
"types": ["vitest/globals", "@testing-library/jest-dom", "node"]
"types": ["vitest/globals", "@testing-library/jest-dom", "node", "vite/client"]
},
"include": ["app/**/*"],
"exclude": ["app/**/*.test.ts", "app/**/*.test.tsx"]

View File

@@ -21,6 +21,32 @@ export default defineConfig({
build: {
outDir: "../dist/client",
emptyOutDir: true,
target: "es2022",
cssCodeSplit: true,
sourcemap: false,
assetsInlineLimit: 4096,
rollupOptions: {
output: {
entryFileNames: "assets/[name]-[hash].js",
chunkFileNames: "assets/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash][extname]",
manualChunks: (id) => {
if (id.includes("/node_modules/react/") || id.includes("/node_modules/react-dom/")) {
return "vendor-react";
}
if (id.includes("/node_modules/@xterm/xterm/")) {
return "vendor-xterm";
}
if (id.includes("/node_modules/@codemirror/")) {
return "vendor-codemirror";
}
return undefined;
},
},
},
},
server: {
proxy: {

View File

@@ -0,0 +1,17 @@
import type { CapacitorConfig } from "@capacitor/cli";
const liveReloadEnabled = process.env.FUSION_LIVE_RELOAD === "true";
const config: CapacitorConfig = {
appId: "com.fusion.mobile",
appName: "Fusion",
webDir: "../dashboard/dist/client",
server: {
url: liveReloadEnabled
? process.env.FUSION_SERVER_URL || "http://localhost:5173"
: undefined,
cleartext: liveReloadEnabled,
},
};
export default config;

View File

@@ -0,0 +1,23 @@
{
"name": "@fusion/mobile",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"cap": "cap",
"dev:ios": "tsx scripts/live-reload.ts --platform ios",
"dev:android": "tsx scripts/live-reload.ts --platform android",
"build:mobile": "pnpm --filter @fusion/dashboard build && npx cap sync"
},
"dependencies": {
"@capacitor/core": "^7.0.0"
},
"devDependencies": {
"@capacitor/android": "^7.0.0",
"@capacitor/cli": "^7.0.0",
"@capacitor/ios": "^7.0.0",
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,118 @@
import { spawn, type ChildProcess } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
type MobilePlatform = "ios" | "android";
interface Args {
platform: MobilePlatform;
serverUrl: string;
}
function parseArgs(argv: string[]): Args {
let platform: MobilePlatform | undefined;
let serverUrl = process.env.FUSION_SERVER_URL || "http://localhost:5173";
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--platform") {
const value = argv[i + 1];
if (value !== "ios" && value !== "android") {
throw new Error("--platform must be either 'ios' or 'android'");
}
platform = value;
i += 1;
continue;
}
if (arg === "--server-url") {
const value = argv[i + 1];
if (!value) {
throw new Error("--server-url requires a value");
}
serverUrl = value;
i += 1;
}
}
if (!platform) {
throw new Error("Missing required --platform argument");
}
return { platform, serverUrl };
}
async function waitForServer(serverUrl: string, timeoutMs = 120_000): Promise<void> {
const start = Date.now();
const healthUrl = new URL("/", serverUrl).toString();
while (Date.now() - start < timeoutMs) {
try {
const response = await fetch(healthUrl, { method: "GET" });
if (response.ok || response.status === 404) {
return;
}
} catch {
// server not ready yet
}
await delay(1_000);
}
throw new Error(`Timed out waiting for dev server at ${serverUrl}`);
}
function command(name: string): string {
return process.platform === "win32" ? `${name}.cmd` : name;
}
function spawnWithInheritedIo(bin: string, args: string[], env: NodeJS.ProcessEnv): ChildProcess {
return spawn(bin, args, {
stdio: "inherit",
env,
});
}
async function run(): Promise<void> {
const { platform, serverUrl } = parseArgs(process.argv.slice(2));
const env: NodeJS.ProcessEnv = {
...process.env,
FUSION_LIVE_RELOAD: "true",
FUSION_SERVER_URL: serverUrl,
};
const devServer = spawnWithInheritedIo(command("pnpm"), ["--filter", "@fusion/dashboard", "dev:serve"], env);
const cleanup = () => {
if (!devServer.killed) {
devServer.kill("SIGTERM");
}
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
try {
await waitForServer(serverUrl);
const capRun = spawnWithInheritedIo(command("npx"), ["cap", "run", platform], env);
await new Promise<void>((resolve, reject) => {
capRun.on("error", reject);
capRun.on("exit", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`cap run ${platform} exited with code ${code ?? "unknown"}`));
});
});
} finally {
cleanup();
}
}
run().catch((error) => {
console.error("[mobile live-reload]", error instanceof Error ? error.message : error);
process.exitCode = 1;
});