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:
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 };
|
||||
Reference in New Issue
Block a user