feat(FN-3398): update desktop README with multi-project setup and troublesh

The merge lands Step 7 of FN-3398, adding documentation and delivery artifacts across the mobile and desktop packages with updated READMEs, mobile-specific docs, and architecture references.

Fusion-Task-Id: FN-3398
This commit is contained in:
Fusion
2026-05-04 21:41:52 -07:00
committed by gsxdsm
parent 9699c3f2b6
commit 3feeb9026a
43 changed files with 2025 additions and 398 deletions

View File

@@ -1,5 +1,17 @@
# @fusion/mobile
## Native Shell Onboarding & Remote Connections
Mobile uses a shell-level onboarding flow for first-run connection setup before dashboard onboarding.
- **Remote-first flow:** mobile onboarding goes directly to remote server connection.
- **Connection setup options:** QR scan (`startQrScan`) or manual server URL entry, with optional auth token.
- **Saved profiles:** multiple remote profiles are persisted in shell-local storage and can be edited/switched later from dashboard connection management.
- **Storage boundary:** profile/mode state is stored only in mobile shell-local storage (via native plugin wrappers), not in Fusion project settings/local dashboard project storage.
- **Bridge contract:** mobile exposes `window.fusionShell` (`getState`, `listProfiles`, `saveProfile`, `deleteProfile`, `setActiveProfile`, `startQrScan`, `openConnectionManager`, `subscribe`) so shared dashboard code can run host-neutrally.
Native wrappers are isolated under `src/plugins/native-shell.ts`, `src/plugins/connection-profiles.ts`, and `src/plugins/qr-scanner.ts` so dashboard code never calls vendor-specific APIs directly.
## Push Notifications
`PushNotificationManager` supports two complementary notification channels:

View File

@@ -25,6 +25,7 @@
"dependencies": {
"@capacitor/app": "^7.1.2",
"@capacitor/core": "^7.0.0",
"@capacitor/preferences": "^7.0.0",
"@capacitor/push-notifications": "^7.0.0",
"@capacitor/share": "^7.0.4"
},

View File

@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const storage = new Map<string, string>();
vi.mock("@capacitor/preferences", () => ({
Preferences: {
get: vi.fn(async ({ key }: { key: string }) => ({ value: storage.get(key) ?? null })),
set: vi.fn(async ({ key, value }: { key: string; value: string }) => {
storage.set(key, value);
}),
},
}));
describe("connection-profiles", () => {
beforeEach(() => {
storage.clear();
vi.resetModules();
});
it("persists and lists profiles", async () => {
const { saveShellProfile, listShellProfiles } = await import("../plugins/connection-profiles.js");
await saveShellProfile({ name: "Prod", serverUrl: "https://fusion.example.com/", authToken: "token" });
const profiles = await listShellProfiles();
expect(profiles).toHaveLength(1);
expect(profiles[0]).toMatchObject({
name: "Prod",
serverUrl: "https://fusion.example.com",
authToken: "token",
});
});
it("clears active profile when deleted", async () => {
const { saveShellProfile, setActiveShellProfile, loadShellProfiles, deleteShellProfile } = await import("../plugins/connection-profiles.js");
const profile = await saveShellProfile({ name: "Prod", serverUrl: "https://fusion.example.com" });
await setActiveShellProfile(profile.id);
await deleteShellProfile(profile.id);
const state = await loadShellProfiles();
expect(state.activeProfileId).toBeNull();
expect(state.profiles).toHaveLength(0);
});
});

View File

@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = {
activeProfileId: null as string | null,
profiles: [] as Array<{ id: string; name: string; serverUrl: string; authToken?: string | null; createdAt: string; updatedAt: string; lastUsedAt?: string | null }>,
};
vi.mock("../plugins/connection-profiles.js", () => ({
loadShellProfiles: vi.fn(async () => state),
listShellProfiles: vi.fn(async () => state.profiles),
saveShellProfile: vi.fn(async (profile: { name: string; serverUrl: string }) => {
const saved = {
id: "p1",
name: profile.name,
serverUrl: profile.serverUrl,
authToken: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: null,
};
state.profiles = [saved];
return saved;
}),
deleteShellProfile: vi.fn(async () => {
state.profiles = [];
state.activeProfileId = null;
}),
setActiveShellProfile: vi.fn(async (profileId: string | null) => {
state.activeProfileId = profileId;
return state;
}),
}));
describe("MobileNativeShellBridge", () => {
const scanner = { scanConnection: vi.fn(async () => ({ serverUrl: "https://fusion.example.com", authToken: null })) };
beforeEach(() => {
state.activeProfileId = null;
state.profiles = [];
scanner.scanConnection.mockClear();
vi.resetModules();
});
it("emits state updates to subscribers", async () => {
const { MobileNativeShellBridge } = await import("../plugins/native-shell.js");
const bridge = new MobileNativeShellBridge(scanner as never);
const listener = vi.fn();
const unsubscribe = bridge.subscribe(listener);
await bridge.saveProfile({ name: "Prod", serverUrl: "https://fusion.example.com" });
expect(listener).toHaveBeenCalled();
unsubscribe();
});
it("rejects desktop mode switch", async () => {
const { MobileNativeShellBridge } = await import("../plugins/native-shell.js");
const bridge = new MobileNativeShellBridge(scanner as never);
await expect(bridge.setDesktopMode("local")).rejects.toThrow("Desktop mode is not supported");
});
});

View File

@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { QrScanner, parseQrConnectionPayload } from "../plugins/qr-scanner.js";
describe("qr-scanner", () => {
it("parses JSON payload", () => {
const parsed = parseQrConnectionPayload('{"serverUrl":"https://fusion.example.com","authToken":"abc"}');
expect(parsed).toEqual({ serverUrl: "https://fusion.example.com", authToken: "abc" });
});
it("parses URL payload", () => {
const parsed = parseQrConnectionPayload("https://fusion.example.com/dashboard?authToken=abc");
expect(parsed).toEqual({ serverUrl: "https://fusion.example.com", authToken: "abc" });
});
it("uses adapter scanning", async () => {
const scanner = new QrScanner({ scan: vi.fn(async () => "https://fusion.example.com") });
await expect(scanner.scanConnection()).resolves.toEqual({ serverUrl: "https://fusion.example.com", authToken: null });
});
});

View File

@@ -7,6 +7,7 @@ import {
type PushNotificationManagerOptions,
} from "./plugins/push-notifications.js";
import { ShareManager, type ShareManagerOptions } from "./plugins/share.js";
import { MobileNativeShellBridge } from "./plugins/native-shell.js";
export { DeepLinkManager } from "./plugins/deep-links.js";
export type {
@@ -20,12 +21,28 @@ export type {
PushNotificationManagerOptions,
} from "./plugins/push-notifications.js";
export { ShareManager } from "./plugins/share.js";
export { MobileNativeShellBridge } from "./plugins/native-shell.js";
export { QrScanner, parseQrConnectionPayload } from "./plugins/qr-scanner.js";
export {
loadShellProfiles,
listShellProfiles,
saveShellProfile,
deleteShellProfile,
setActiveShellProfile,
} from "./plugins/connection-profiles.js";
export type {
ShareEventMap,
ShareManagerOptions,
ShareTaskPayload,
} from "./plugins/share.js";
export type { MobilePluginManager, PluginEventMap } from "./types.js";
export type {
FusionShellApi,
MobilePluginManager,
PluginEventMap,
ShellConnectionProfile,
ShellConnectionProfileInput,
ShellConnectionState,
} from "./types.js";
interface LifecycleManager {
initialize?: () => Promise<void>;
@@ -58,6 +75,14 @@ async function initializeManager(manager: LifecycleManager): Promise<void> {
}
}
export function installMobileShellBridge(
target: Window & typeof globalThis = window,
): MobileNativeShellBridge {
const bridge = new MobileNativeShellBridge();
(target as Window & { fusionShell?: MobileNativeShellBridge }).fusionShell = bridge;
return bridge;
}
export async function initializePlugins(
options: InitializePluginsOptions = {},
): Promise<InitializePluginsResult> {

View File

@@ -0,0 +1,104 @@
import { Preferences } from "@capacitor/preferences";
import type { ShellConnectionProfile, ShellConnectionProfileInput } from "../types.js";
const STORAGE_KEY = "fusion.shell.connections.v1";
interface PersistedShellState {
activeProfileId: string | null;
profiles: ShellConnectionProfile[];
}
function nowIso(): string {
return new Date().toISOString();
}
function createId(): string {
return `profile_${Math.random().toString(36).slice(2, 10)}`;
}
function normalizeUrl(serverUrl: string): string {
return serverUrl.trim().replace(/\/$/, "");
}
function toPersisted(input: unknown): PersistedShellState {
if (!input || typeof input !== "object") {
return { activeProfileId: null, profiles: [] };
}
const candidate = input as Partial<PersistedShellState>;
const profiles = Array.isArray(candidate.profiles) ? candidate.profiles.filter((profile) => profile && typeof profile === "object") as ShellConnectionProfile[] : [];
return {
activeProfileId: typeof candidate.activeProfileId === "string" ? candidate.activeProfileId : null,
profiles,
};
}
export async function loadShellProfiles(): Promise<PersistedShellState> {
const { value } = await Preferences.get({ key: STORAGE_KEY });
if (!value) {
return { activeProfileId: null, profiles: [] };
}
try {
return toPersisted(JSON.parse(value));
} catch {
return { activeProfileId: null, profiles: [] };
}
}
async function saveShellState(state: PersistedShellState): Promise<void> {
await Preferences.set({ key: STORAGE_KEY, value: JSON.stringify(state) });
}
export async function listShellProfiles(): Promise<ShellConnectionProfile[]> {
const state = await loadShellProfiles();
return state.profiles;
}
export async function saveShellProfile(input: ShellConnectionProfileInput): Promise<ShellConnectionProfile> {
const state = await loadShellProfiles();
const existing = input.id ? state.profiles.find((p) => p.id === input.id) : undefined;
const timestamp = nowIso();
const profile: ShellConnectionProfile = {
id: existing?.id ?? input.id ?? createId(),
name: input.name.trim(),
serverUrl: normalizeUrl(input.serverUrl),
authToken: input.authToken ?? null,
createdAt: existing?.createdAt ?? timestamp,
updatedAt: timestamp,
lastUsedAt: existing?.lastUsedAt ?? null,
};
const profiles = existing
? state.profiles.map((item) => (item.id === existing.id ? profile : item))
: [...state.profiles, profile];
await saveShellState({ ...state, profiles });
return profile;
}
export async function deleteShellProfile(profileId: string): Promise<void> {
const state = await loadShellProfiles();
const profiles = state.profiles.filter((profile) => profile.id !== profileId);
const activeProfileId = state.activeProfileId === profileId ? null : state.activeProfileId;
await saveShellState({ activeProfileId, profiles });
}
export async function setActiveShellProfile(profileId: string | null): Promise<PersistedShellState> {
const state = await loadShellProfiles();
const activeProfileId =
profileId && state.profiles.some((profile) => profile.id === profileId)
? profileId
: null;
const profiles = state.profiles.map((profile) =>
profile.id === activeProfileId
? { ...profile, lastUsedAt: nowIso(), updatedAt: nowIso() }
: profile,
);
const next = { activeProfileId, profiles };
await saveShellState(next);
return next;
}

View File

@@ -0,0 +1,82 @@
import type {
FusionShellApi,
ShellConnectionProfile,
ShellConnectionProfileInput,
ShellConnectionState,
} from "../types.js";
import {
deleteShellProfile,
listShellProfiles,
loadShellProfiles,
saveShellProfile,
setActiveShellProfile,
} from "./connection-profiles.js";
import { QrScanner, type QrScanResult } from "./qr-scanner.js";
type Listener = (state: ShellConnectionState) => void;
export class MobileNativeShellBridge implements FusionShellApi {
private listeners = new Set<Listener>();
constructor(private readonly qrScanner: QrScanner = new QrScanner()) {}
private async buildState(): Promise<ShellConnectionState> {
const persisted = await loadShellProfiles();
return {
host: "mobile-shell",
activeProfileId: persisted.activeProfileId,
profiles: persisted.profiles,
};
}
private async emitState(): Promise<ShellConnectionState> {
const state = await this.buildState();
for (const listener of this.listeners) {
listener(state);
}
return state;
}
getState(): Promise<ShellConnectionState> {
return this.buildState();
}
listProfiles(): Promise<ShellConnectionProfile[]> {
return listShellProfiles();
}
async saveProfile(profile: ShellConnectionProfileInput): Promise<ShellConnectionProfile> {
const saved = await saveShellProfile(profile);
await this.emitState();
return saved;
}
async deleteProfile(profileId: string): Promise<void> {
await deleteShellProfile(profileId);
await this.emitState();
}
setActiveProfile(profileId: string | null): Promise<ShellConnectionState> {
return setActiveShellProfile(profileId).then(() => this.emitState());
}
setDesktopMode(): Promise<ShellConnectionState> {
return Promise.reject(new Error("Desktop mode is not supported in mobile shell"));
}
startQrScan(): Promise<QrScanResult> {
return this.qrScanner.scanConnection();
}
async openConnectionManager(): Promise<void> {
// Handled by dashboard shell context state.
}
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
void this.getState().then(listener);
return () => {
this.listeners.delete(listener);
};
}
}

View File

@@ -0,0 +1,53 @@
export interface QrScanResult {
serverUrl: string;
authToken?: string | null;
}
export interface QrScannerAdapter {
scan(): Promise<string>;
}
function parsePayload(raw: string): QrScanResult {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("QR scan returned empty payload");
}
try {
const parsed = JSON.parse(trimmed) as Partial<QrScanResult>;
if (typeof parsed.serverUrl === "string" && parsed.serverUrl.trim().length > 0) {
return {
serverUrl: parsed.serverUrl.trim(),
authToken: parsed.authToken ?? null,
};
}
} catch {
// Fall through to URL parsing.
}
try {
const url = new URL(trimmed);
const authToken = url.searchParams.get("authToken");
return {
serverUrl: `${url.protocol}//${url.host}`,
authToken,
};
} catch {
throw new Error("QR payload is not a valid Fusion connection payload");
}
}
export class QrScanner {
constructor(private readonly adapter?: QrScannerAdapter) {}
async scanConnection(): Promise<QrScanResult> {
if (!this.adapter) {
throw new Error("QR scanner is not available on this platform");
}
const raw = await this.adapter.scan();
return parsePayload(raw);
}
}
export { parsePayload as parseQrConnectionPayload };

View File

@@ -6,3 +6,46 @@ export interface MobilePluginManager {
start(): Promise<void>;
destroy(): void | Promise<void>;
}
export interface ShellConnectionProfile {
id: string;
name: string;
serverUrl: string;
authToken?: string | null;
createdAt: string;
updatedAt: string;
lastUsedAt?: string | null;
}
export interface ShellConnectionProfileInput {
id?: string;
name: string;
serverUrl: string;
authToken?: string | null;
}
export type ShellHost = "web" | "mobile-shell" | "desktop-shell";
export interface ShellConnectionState {
host: ShellHost;
desktopMode?: "local" | "remote";
activeProfileId: string | null;
profiles: ShellConnectionProfile[];
localServer?: {
status: "idle" | "starting" | "ready" | "error";
port?: number;
error?: string | null;
};
}
export interface FusionShellApi {
getState(): Promise<ShellConnectionState>;
listProfiles(): Promise<ShellConnectionProfile[]>;
saveProfile(profile: ShellConnectionProfileInput): Promise<ShellConnectionProfile>;
deleteProfile(profileId: string): Promise<void>;
setActiveProfile(profileId: string | null): Promise<ShellConnectionState>;
setDesktopMode(mode: "local" | "remote"): Promise<ShellConnectionState>;
startQrScan(): Promise<{ serverUrl: string; authToken?: string | null }>;
openConnectionManager(): Promise<void>;
subscribe(listener: (state: ShellConnectionState) => void): () => void;
}