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 95ccf50397
commit 025eb7b3ae
43 changed files with 2025 additions and 398 deletions

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 };