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:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
45
packages/mobile/src/__tests__/connection-profiles.test.ts
Normal file
45
packages/mobile/src/__tests__/connection-profiles.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
62
packages/mobile/src/__tests__/native-shell.test.ts
Normal file
62
packages/mobile/src/__tests__/native-shell.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
19
packages/mobile/src/__tests__/qr-scanner.test.ts
Normal file
19
packages/mobile/src/__tests__/qr-scanner.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
104
packages/mobile/src/plugins/connection-profiles.ts
Normal file
104
packages/mobile/src/plugins/connection-profiles.ts
Normal 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;
|
||||
}
|
||||
82
packages/mobile/src/plugins/native-shell.ts
Normal file
82
packages/mobile/src/plugins/native-shell.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
53
packages/mobile/src/plugins/qr-scanner.ts
Normal file
53
packages/mobile/src/plugins/qr-scanner.ts
Normal 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 };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user