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:
@@ -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
|
||||
|
||||
|
||||
25
packages/dashboard/app/__tests__/build-output.test.ts
Normal file
25
packages/dashboard/app/__tests__/build-output.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
46
packages/dashboard/app/__tests__/mobile-scripts.test.ts
Normal file
46
packages/dashboard/app/__tests__/mobile-scripts.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
40
packages/dashboard/app/__tests__/pwa.test.ts
Normal file
40
packages/dashboard/app/__tests__/pwa.test.ts
Normal 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+/);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
BIN
packages/dashboard/app/public/icons/icon-192.png
Normal file
BIN
packages/dashboard/app/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 450 B |
BIN
packages/dashboard/app/public/icons/icon-512.png
Normal file
BIN
packages/dashboard/app/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
21
packages/dashboard/app/public/manifest.json
Normal file
21
packages/dashboard/app/public/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
90
packages/dashboard/app/public/sw.js
Normal file
90
packages/dashboard/app/public/sw.js
Normal 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);
|
||||
}
|
||||
})());
|
||||
});
|
||||
@@ -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"]
|
||||
|
||||
@@ -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: {
|
||||
|
||||
17
packages/mobile/capacitor.config.ts
Normal file
17
packages/mobile/capacitor.config.ts
Normal 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;
|
||||
23
packages/mobile/package.json
Normal file
23
packages/mobile/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
118
packages/mobile/scripts/live-reload.ts
Normal file
118
packages/mobile/scripts/live-reload.ts
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user