refactor(FN-1240): remove dashboard Capacitor coupling in favor of @fusion/mobile

- Remove legacy dashboard Capacitor config, scripts, and package dependencies now managed by @fusion/mobile
- Rewrite network, splash-screen, and status-bar plugin managers to use guarded global Capacitor plugin adapters without direct @capacitor imports
- Update plugin tests to stub Capacitor globals and drop obsolete capacitor config test coverage
- Simplify dashboard mobile README guidance and add a @gsxdsm/fusion patch changeset documenting the migration
This commit is contained in:
gsxdsm
2026-04-08 08:10:17 -07:00
parent b97d9fc3ee
commit 8e61566ad1
13 changed files with 185 additions and 274 deletions

View File

@@ -479,51 +479,15 @@ The dashboard includes several runtime safeguards to stay responsive during long
## Mobile Development (Capacitor)
Capacitor wraps the existing Fusion dashboard web build into native iOS and Android shells, so mobile uses the same React UI and API surface as desktop web.
Mobile builds use the dedicated `@fusion/mobile` package. See [`../../MOBILE.md`](../../MOBILE.md) for the full workflow.
### Prerequisites
- **iOS**: Xcode + CocoaPods installed
- **Android**: Android Studio + Android SDK installed
### Setup
Quick start:
```bash
pnpm install
pnpm mobile:build
pnpm mobile:ios # or pnpm mobile:android
```
`mobile:build` builds `@fusion/dashboard` and syncs web assets into the `@fusion/mobile` Capacitor project.
### Running / Opening Native Projects
```bash
pnpm mobile:ios
pnpm mobile:android
```
### Development with Live Reload
```bash
pnpm mobile:dev:ios
pnpm mobile:dev:android
```
The live-reload helper sets `FUSION_LIVE_RELOAD=true` automatically and defaults `FUSION_SERVER_URL` to `http://localhost:5173`.
For the full workflow (CI pipeline, PWA details, troubleshooting), see [`../../MOBILE.md`](../../MOBILE.md).
### Mobile Scripts
Workspace (`package.json`):
- `mobile:build`
- `mobile:ios`
- `mobile:android`
- `mobile:dev:ios`
- `mobile:dev:android`
- `mobile:sync`
## Development
```bash

View File

@@ -1,35 +0,0 @@
import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.fusion.dashboard",
appName: "Fusion",
webDir: "dist/client",
server: {
// In development, override url to connect to the Fusion backend.
// Set FUSION_BACKEND_URL env var before running cap sync/run.
// Example: FUSION_BACKEND_URL=http://192.168.1.100:4040 pnpm cap:run:ios
//
// In production, the app serves static assets from webDir and connects
// to the backend at the configured server url.
url: process.env.FUSION_BACKEND_URL || undefined,
cleartext: true, // Allow HTTP connections to local dev servers
iosScheme: "fusion",
androidScheme: "fusion",
},
plugins: {
SplashScreen: {
launchAutoHide: false,
backgroundColor: "#0a0a0a",
showSpinner: true,
spinnerColor: "#6366f1",
androidSplashAssetName: "splash",
androidScaleType: "CENTER_CROP",
},
StatusBar: {
style: "DARK",
backgroundColor: "#0a0a0a",
},
},
};
export default config;

View File

@@ -22,26 +22,13 @@
"scripts": {
"build": "vite build && tsc",
"build:client": "vite build",
"cap:open:android": "cap open android",
"cap:open:ios": "cap open ios",
"cap:run:android": "cap run android",
"cap:run:ios": "cap run ios",
"cap:sync": "cap sync",
"dev": "pnpm build && pnpm typecheck && pnpm dev:serve",
"dev:serve": "vite dev",
"mobile:prepare": "pnpm build:client && pnpm cap:sync",
"postinstall": "chmod +x node_modules/.pnpm/node-pty*/node_modules/node-pty/prebuilds/darwin-*/spawn-helper node_modules/.pnpm/node-pty*/node_modules/node-pty/prebuilds/darwin-*/*.node 2>/dev/null || true",
"test": "vitest run",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
},
"dependencies": {
"@capacitor/app": "^7.1.2",
"@capacitor/core": "^7.0.0",
"@capacitor/network": "^7.0.4",
"@capacitor/push-notifications": "^7.0.6",
"@capacitor/share": "^7.0.4",
"@capacitor/splash-screen": "^7.0.5",
"@capacitor/status-bar": "^7.0.6",
"@codemirror/basic-setup": "^0.20.0",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.3",
@@ -71,9 +58,6 @@
"ws": "^8.18.0"
},
"devDependencies": {
"@capacitor/android": "^7.0.0",
"@capacitor/cli": "^7.0.0",
"@capacitor/ios": "^7.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.5.0",

View File

@@ -1,60 +0,0 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, it, expect } from "vitest";
import type { CapacitorConfig } from "@capacitor/cli";
const configPath = resolve(__dirname, "../../capacitor.config.ts");
function evaluateConfig(env: NodeJS.ProcessEnv = {}): CapacitorConfig {
const content = readFileSync(configPath, "utf8");
const executableSource = content
.replace('import type { CapacitorConfig } from "@capacitor/cli";\n\n', "")
.replace("const config: CapacitorConfig = {", "const config = {")
.replace("export default config;", "return config;");
const fn = new Function("process", executableSource) as (processLike: { env: NodeJS.ProcessEnv }) => CapacitorConfig;
return fn({ env });
}
describe("capacitor.config", () => {
it("exists and exports a TypeScript Capacitor config", () => {
const content = readFileSync(configPath, "utf8");
expect(content.length).toBeGreaterThan(0);
expect(content).toContain("import type { CapacitorConfig } from \"@capacitor/cli\"");
expect(content).toContain("const config: CapacitorConfig = {");
expect(content).toContain("export default config;");
});
it("sets webDir to dist/client", () => {
const config = evaluateConfig();
expect(config.webDir).toBe("dist/client");
});
it("uses the expected app identity", () => {
const config = evaluateConfig();
expect(config.appId).toBe("com.fusion.dashboard");
expect(config.appName).toBe("Fusion");
});
it("enables cleartext server access for local development", () => {
const config = evaluateConfig();
expect(config.server?.cleartext).toBe(true);
});
it("defaults server.url to undefined when FUSION_BACKEND_URL is unset", () => {
const config = evaluateConfig({});
expect(config.server?.url).toBeUndefined();
});
it("uses FUSION_BACKEND_URL when provided", () => {
const config = evaluateConfig({ FUSION_BACKEND_URL: "http://192.168.1.100:4040" });
expect(config.server?.url).toBe("http://192.168.1.100:4040");
});
it("references the FUSION_BACKEND_URL env variable in source", () => {
const content = readFileSync(configPath, "utf8");
expect(content).toContain("process.env.FUSION_BACKEND_URL || undefined");
});
});

View File

@@ -1,24 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Network } from "@capacitor/network";
import { NetworkManager } from "../plugins/network.js";
const { getStatusMock, addListenerMock, removeListenerMock } = vi.hoisted(() => ({
getStatusMock: vi.fn(),
addListenerMock: vi.fn(),
removeListenerMock: vi.fn(),
}));
const getStatusMock = vi.fn();
const addListenerMock = vi.fn();
const removeListenerMock = vi.fn();
let networkStatusChangeHandler:
| ((status: { connected: boolean; connectionType: "wifi" | "cellular" | "none" | "unknown" }) => void)
| null = null;
vi.mock("@capacitor/network", () => ({
Network: {
getStatus: getStatusMock,
addListener: addListenerMock,
},
}));
describe("NetworkManager", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -40,6 +30,15 @@ describe("NetworkManager", () => {
}
return { remove: removeListenerMock };
});
vi.stubGlobal("Capacitor", {
Plugins: {
Network: {
getStatus: getStatusMock,
addListener: addListenerMock,
},
},
});
});
it("initialize() queries current network status", async () => {
@@ -47,7 +46,7 @@ describe("NetworkManager", () => {
await manager.initialize();
expect(Network.getStatus).toHaveBeenCalledTimes(1);
expect(getStatusMock).toHaveBeenCalledTimes(1);
expect(manager.getStatus()).toEqual({ connected: true, connectionType: "wifi" });
});
@@ -56,8 +55,8 @@ describe("NetworkManager", () => {
await manager.startMonitoring();
expect(Network.addListener).toHaveBeenCalledTimes(1);
expect(Network.addListener).toHaveBeenCalledWith("networkStatusChange", expect.any(Function));
expect(addListenerMock).toHaveBeenCalledTimes(1);
expect(addListenerMock).toHaveBeenCalledWith("networkStatusChange", expect.any(Function));
expect(manager.isMonitoring).toBe(true);
});

View File

@@ -1,23 +1,28 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { SplashScreen } from "@capacitor/splash-screen";
import { SplashScreenManager } from "../plugins/splash-screen.js";
vi.mock("@capacitor/splash-screen", () => ({
SplashScreen: {
hide: vi.fn(),
show: vi.fn(),
},
}));
const hideMock = vi.fn();
const showMock = vi.fn();
describe("SplashScreenManager", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(SplashScreen.hide).mockResolvedValue(undefined);
vi.mocked(SplashScreen.show).mockResolvedValue(undefined);
hideMock.mockResolvedValue(undefined);
showMock.mockResolvedValue(undefined);
vi.stubGlobal("Capacitor", {
Plugins: {
SplashScreen: {
hide: hideMock,
show: showMock,
},
},
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("initialize() with autoHide=true triggers hide after delay", async () => {
@@ -25,12 +30,12 @@ describe("SplashScreenManager", () => {
const manager = new SplashScreenManager({ autoHide: true, hideDelay: 100 });
await manager.initialize();
expect(SplashScreen.hide).not.toHaveBeenCalled();
expect(hideMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(100);
expect(SplashScreen.hide).toHaveBeenCalledTimes(1);
expect(SplashScreen.hide).toHaveBeenCalledWith({ fadeOutDuration: 300 });
expect(hideMock).toHaveBeenCalledTimes(1);
expect(hideMock).toHaveBeenCalledWith({ fadeOutDuration: 300 });
});
it("initialize() with autoHide=false does not auto-hide", async () => {
@@ -40,7 +45,7 @@ describe("SplashScreenManager", () => {
await manager.initialize();
await vi.advanceTimersByTimeAsync(500);
expect(SplashScreen.hide).not.toHaveBeenCalled();
expect(hideMock).not.toHaveBeenCalled();
});
it("hide() delegates to SplashScreen.hide()", async () => {
@@ -48,8 +53,8 @@ describe("SplashScreenManager", () => {
await manager.hide();
expect(SplashScreen.hide).toHaveBeenCalledTimes(1);
expect(SplashScreen.hide).toHaveBeenCalledWith({ fadeOutDuration: 300 });
expect(hideMock).toHaveBeenCalledTimes(1);
expect(hideMock).toHaveBeenCalledWith({ fadeOutDuration: 300 });
});
it("show() delegates to SplashScreen.show()", async () => {
@@ -57,8 +62,8 @@ describe("SplashScreenManager", () => {
await manager.show();
expect(SplashScreen.show).toHaveBeenCalledTimes(1);
expect(SplashScreen.show).toHaveBeenCalledWith({ autoHide: false });
expect(showMock).toHaveBeenCalledTimes(1);
expect(showMock).toHaveBeenCalledWith({ autoHide: false });
});
it("initialize() is idempotent", async () => {
@@ -69,11 +74,11 @@ describe("SplashScreenManager", () => {
await manager.initialize();
await vi.advanceTimersByTimeAsync(50);
expect(SplashScreen.hide).toHaveBeenCalledTimes(1);
expect(hideMock).toHaveBeenCalledTimes(1);
});
it("hide() swallows errors gracefully", async () => {
vi.mocked(SplashScreen.hide).mockRejectedValue(new Error("unavailable"));
hideMock.mockRejectedValue(new Error("unavailable"));
const manager = new SplashScreenManager();
await expect(manager.hide()).resolves.toBeUndefined();

View File

@@ -1,26 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StatusBar, Style } from "@capacitor/status-bar";
import { StatusBarManager } from "../plugins/status-bar.js";
vi.mock("@capacitor/status-bar", () => ({
StatusBar: {
setStyle: vi.fn(),
},
Style: {
Dark: "DARK",
Light: "LIGHT",
},
}));
const setStyleMock = vi.fn();
describe("StatusBarManager", () => {
const originalMatchMedia = globalThis.window?.matchMedia;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(StatusBar.setStyle).mockResolvedValue(undefined);
setStyleMock.mockResolvedValue(undefined);
vi.stubGlobal("Capacitor", {
Plugins: {
StatusBar: {
setStyle: setStyleMock,
},
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
if (globalThis.window) {
Object.defineProperty(globalThis.window, "matchMedia", {
configurable: true,
@@ -35,8 +36,8 @@ describe("StatusBarManager", () => {
await manager.initialize();
expect(StatusBar.setStyle).toHaveBeenCalledTimes(1);
expect(StatusBar.setStyle).toHaveBeenCalledWith({ style: Style.Dark });
expect(setStyleMock).toHaveBeenCalledTimes(1);
expect(setStyleMock).toHaveBeenCalledWith({ style: "DARK" });
});
it("setTheme('dark') sets dark style", async () => {
@@ -44,7 +45,7 @@ describe("StatusBarManager", () => {
await manager.setTheme("dark");
expect(StatusBar.setStyle).toHaveBeenCalledWith({ style: Style.Dark });
expect(setStyleMock).toHaveBeenCalledWith({ style: "DARK" });
expect(manager.getTheme()).toBe("dark");
});
@@ -53,7 +54,7 @@ describe("StatusBarManager", () => {
await manager.setTheme("light");
expect(StatusBar.setStyle).toHaveBeenCalledWith({ style: Style.Light });
expect(setStyleMock).toHaveBeenCalledWith({ style: "LIGHT" });
expect(manager.getTheme()).toBe("light");
});
@@ -68,7 +69,7 @@ describe("StatusBarManager", () => {
await manager.setTheme("system");
expect(StatusBar.setStyle).toHaveBeenLastCalledWith({ style: Style.Dark });
expect(setStyleMock).toHaveBeenLastCalledWith({ style: "DARK" });
Object.defineProperty(window, "matchMedia", {
configurable: true,
@@ -78,7 +79,7 @@ describe("StatusBarManager", () => {
await manager.setTheme("system");
expect(StatusBar.setStyle).toHaveBeenLastCalledWith({ style: Style.Light });
expect(setStyleMock).toHaveBeenLastCalledWith({ style: "LIGHT" });
});
it("onThemeChange callback fires on theme change", async () => {
@@ -105,7 +106,7 @@ describe("StatusBarManager", () => {
});
it("initialize() swallows errors", async () => {
vi.mocked(StatusBar.setStyle).mockRejectedValue(new Error("not available"));
setStyleMock.mockRejectedValue(new Error("not available"));
const manager = new StatusBarManager({ themeMode: "dark" });
await expect(manager.initialize()).resolves.toBeUndefined();

View File

@@ -1,4 +1,3 @@
import { Network, type ConnectionType } from "@capacitor/network";
import type {
PluginManager,
NetworkStatus,
@@ -6,6 +5,38 @@ import type {
PluginNetworkListenerHandle,
} from "./types.js";
type NativeConnectionType = "wifi" | "cellular" | "none" | "unknown";
interface NativeNetworkStatus {
connected: boolean;
connectionType: NativeConnectionType;
}
interface NativeNetworkPlugin {
getStatus: () => Promise<NativeNetworkStatus>;
addListener: (
eventName: "networkStatusChange",
callback: (status: NativeNetworkStatus) => void,
) => PluginNetworkListenerHandle | Promise<PluginNetworkListenerHandle>;
}
interface CapacitorGlobal {
Capacitor?: {
Plugins?: Record<string, unknown>;
};
}
function getNativeNetworkPlugin(): NativeNetworkPlugin | null {
const plugins = (globalThis as CapacitorGlobal).Capacitor?.Plugins;
const candidate = plugins?.Network as Partial<NativeNetworkPlugin> | undefined;
if (!candidate || typeof candidate.getStatus !== "function" || typeof candidate.addListener !== "function") {
return null;
}
return candidate as NativeNetworkPlugin;
}
export class NetworkManager implements PluginManager {
private status: NetworkStatus;
private listeners: Array<NetworkStatusCallback> = [];
@@ -22,9 +53,15 @@ export class NetworkManager implements PluginManager {
return;
}
const networkPlugin = getNativeNetworkPlugin();
try {
const currentStatus = await Network.getStatus();
this.status = this.toNetworkStatus(currentStatus.connected, currentStatus.connectionType);
if (networkPlugin) {
const currentStatus = await networkPlugin.getStatus();
this.status = this.toNetworkStatus(currentStatus.connected, currentStatus.connectionType);
} else {
this.status = { connected: true, connectionType: "unknown" };
}
} catch {
// Network plugin may not be available in browser context
this.status = { connected: true, connectionType: "unknown" };
@@ -39,10 +76,17 @@ export class NetworkManager implements PluginManager {
return;
}
const networkPlugin = getNativeNetworkPlugin();
if (!networkPlugin) {
this.networkListenerHandle = null;
this.monitoring = false;
return;
}
try {
this.networkListenerHandle = await Network.addListener(
const listenerHandle = networkPlugin.addListener(
"networkStatusChange",
(status) => {
(status: NativeNetworkStatus) => {
const nextStatus = this.toNetworkStatus(status.connected, status.connectionType);
const previousConnected = this.status.connected;
this.status = nextStatus;
@@ -58,6 +102,8 @@ export class NetworkManager implements PluginManager {
this.emit("network:change", nextStatus);
},
);
this.networkListenerHandle = await Promise.resolve(listenerHandle);
this.monitoring = true;
} catch {
// Network plugin may not be available in browser context
@@ -108,7 +154,7 @@ export class NetworkManager implements PluginManager {
}
}
private toNetworkStatus(connected: boolean, connectionType: ConnectionType): NetworkStatus {
private toNetworkStatus(connected: boolean, connectionType: NativeConnectionType): NetworkStatus {
return {
connected,
connectionType: connectionType as NetworkStatus["connectionType"],

View File

@@ -1,6 +1,27 @@
import { SplashScreen } from "@capacitor/splash-screen";
import type { PluginManager } from "./types.js";
interface NativeSplashScreenPlugin {
hide: (options: { fadeOutDuration: number }) => Promise<void>;
show: (options: { autoHide: boolean }) => Promise<void>;
}
interface CapacitorGlobal {
Capacitor?: {
Plugins?: Record<string, unknown>;
};
}
function getNativeSplashScreenPlugin(): NativeSplashScreenPlugin | null {
const plugins = (globalThis as CapacitorGlobal).Capacitor?.Plugins;
const candidate = plugins?.SplashScreen as Partial<NativeSplashScreenPlugin> | undefined;
if (!candidate || typeof candidate.hide !== "function" || typeof candidate.show !== "function") {
return null;
}
return candidate as NativeSplashScreenPlugin;
}
export interface SplashScreenOptions {
autoHide?: boolean;
hideDelay?: number;
@@ -34,16 +55,26 @@ export class SplashScreenManager implements PluginManager {
}
async hide(): Promise<void> {
const splashScreenPlugin = getNativeSplashScreenPlugin();
if (!splashScreenPlugin) {
return;
}
try {
await SplashScreen.hide({ fadeOutDuration: 300 });
await splashScreenPlugin.hide({ fadeOutDuration: 300 });
} catch {
// Ignore errors — splash screen may not be available in browser/web context
}
}
async show(): Promise<void> {
const splashScreenPlugin = getNativeSplashScreenPlugin();
if (!splashScreenPlugin) {
return;
}
try {
await SplashScreen.show({ autoHide: false });
await splashScreenPlugin.show({ autoHide: false });
} catch {
// Ignore errors — splash screen may not be available in browser/web context
}

View File

@@ -1,10 +1,30 @@
import { StatusBar, Style } from "@capacitor/status-bar";
import type {
PluginManager,
ThemeMode,
ThemeChangeCallback,
} from "./types.js";
interface NativeStatusBarPlugin {
setStyle: (options: { style: "DARK" | "LIGHT" }) => Promise<void>;
}
interface CapacitorGlobal {
Capacitor?: {
Plugins?: Record<string, unknown>;
};
}
function getNativeStatusBarPlugin(): NativeStatusBarPlugin | null {
const plugins = (globalThis as CapacitorGlobal).Capacitor?.Plugins;
const candidate = plugins?.StatusBar as Partial<NativeStatusBarPlugin> | undefined;
if (!candidate || typeof candidate.setStyle !== "function") {
return null;
}
return candidate as NativeStatusBarPlugin;
}
export interface StatusBarOptions {
themeMode?: ThemeMode;
}
@@ -50,11 +70,16 @@ export class StatusBarManager implements PluginManager {
}
private async applyTheme(mode: ThemeMode): Promise<void> {
const statusBarPlugin = getNativeStatusBarPlugin();
if (!statusBarPlugin) {
return;
}
const isDark = mode === "dark" || (mode === "system" && this.isSystemDark());
try {
await StatusBar.setStyle({
style: isDark ? Style.Dark : Style.Light,
await statusBarPlugin.setStyle({
style: isDark ? "DARK" : "LIGHT",
});
} catch {
// StatusBar plugin may not be available in browser context

View File

@@ -1,5 +1,3 @@
import type { PluginListenerHandle } from "@capacitor/core";
/** Network connectivity status */
export interface NetworkStatus {
connected: boolean;
@@ -38,6 +36,11 @@ export interface PluginManager {
destroy(): Promise<void>;
}
/** Minimal listener handle contract used by network plugin adapters. */
export interface PluginListenerHandle {
remove: () => void | Promise<void>;
}
/** Shared network listener handle type for manager implementations. */
export type PluginNetworkListenerHandle = PluginListenerHandle;