feat(FN-3403): add shell multi-profile support with connection manager onbo
Merges shell multi-profile support for desktop (FN-3403) — spanning new connection-manager flows, shell onboarding interoperability, mobile connection profiles, desktop IPC/shell-settings plumbing, and tokenized titlebar styles — with a secondary migration of the WhatsApp plugin to the Baileys pairi Fusion-Task-Id: FN-3403
This commit is contained in:
@@ -6,7 +6,8 @@ Mobile uses a shell-level onboarding flow for first-run connection setup before
|
||||
|
||||
- **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.
|
||||
- **Saved profiles:** multiple remote profiles are persisted in shell-local storage and can be added via QR/manual entry, edited, switched, and deleted later from dashboard connection management.
|
||||
- **Active-profile fallback:** deleting the active profile automatically promotes the first remaining profile; deleting the last profile resets to an empty state (`activeProfileId: null`, `profiles: []`) so onboarding/manager recovery can reopen cleanly.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
vi.mock("@capacitor/preferences", () => ({
|
||||
Preferences: {
|
||||
get: vi.fn(async ({ key }: { key: string }) => ({ value: store.get(key) ?? null })),
|
||||
set: vi.fn(async ({ key, value }: { key: string; value: string }) => {
|
||||
store.set(key, value);
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("connection-profiles", () => {
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("recovers from invalid payloads", async () => {
|
||||
store.set("fusion.shell.connections.v1", "{bad-json");
|
||||
const { loadShellProfiles } = await import("../connection-profiles.js");
|
||||
await expect(loadShellProfiles()).resolves.toEqual({ activeProfileId: null, profiles: [] });
|
||||
});
|
||||
|
||||
it("normalizes empty and duplicate names", async () => {
|
||||
const { saveShellProfile, listShellProfiles } = await import("../connection-profiles.js");
|
||||
const first = await saveShellProfile({ name: "", serverUrl: "https://fusion.example.com" });
|
||||
const second = await saveShellProfile({ name: "Remote Server", serverUrl: "https://fusion-two.example.com" });
|
||||
|
||||
expect(first.name).toBe("Remote Server");
|
||||
expect(second.name).toBe("Remote Server (2)");
|
||||
const profiles = await listShellProfiles();
|
||||
expect(profiles).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("deleting active profile picks a fallback and deleting final profile clears state", async () => {
|
||||
const { saveShellProfile, setActiveShellProfile, deleteShellProfile, loadShellProfiles } = await import("../connection-profiles.js");
|
||||
const first = await saveShellProfile({ name: "Prod", serverUrl: "https://fusion.example.com" });
|
||||
const second = await saveShellProfile({ name: "Staging", serverUrl: "https://staging.example.com" });
|
||||
await setActiveShellProfile(second.id);
|
||||
|
||||
await deleteShellProfile(second.id);
|
||||
const afterFirstDelete = await loadShellProfiles();
|
||||
expect(afterFirstDelete.activeProfileId).toBe(first.id);
|
||||
|
||||
await deleteShellProfile(first.id);
|
||||
const afterSecondDelete = await loadShellProfiles();
|
||||
expect(afterSecondDelete).toEqual({ activeProfileId: null, profiles: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Preferences } from "@capacitor/preferences";
|
||||
import type { ShellConnectionProfile, ShellConnectionProfileInput } from "../types.js";
|
||||
|
||||
@@ -12,8 +13,9 @@ function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createId(): string {
|
||||
return `profile_${Math.random().toString(36).slice(2, 10)}`;
|
||||
function normalizeName(name: string): string {
|
||||
const normalized = name.trim().replace(/\s+/g, " ");
|
||||
return normalized.length > 0 ? normalized : "Remote Server";
|
||||
}
|
||||
|
||||
function normalizeUrl(serverUrl: string): string {
|
||||
@@ -30,15 +32,97 @@ function normalizeUrl(serverUrl: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function deterministicBaseId(name: string, serverUrl: string): string {
|
||||
const hash = createHash("sha1").update(`${name}|${serverUrl}`).digest("hex").slice(0, 10);
|
||||
return `profile_${hash}`;
|
||||
}
|
||||
|
||||
function ensureUniqueName(name: string, profiles: ShellConnectionProfile[], skipId?: string): string {
|
||||
const used = new Set(profiles.filter((profile) => profile.id !== skipId).map((profile) => profile.name.toLocaleLowerCase()));
|
||||
if (!used.has(name.toLocaleLowerCase())) {
|
||||
return name;
|
||||
}
|
||||
let suffix = 2;
|
||||
let candidate = `${name} (${suffix})`;
|
||||
while (used.has(candidate.toLocaleLowerCase())) {
|
||||
suffix += 1;
|
||||
candidate = `${name} (${suffix})`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function ensureUniqueId(name: string, serverUrl: string, profiles: ShellConnectionProfile[], skipId?: string): string {
|
||||
const base = deterministicBaseId(name, serverUrl);
|
||||
const used = new Set(profiles.filter((profile) => profile.id !== skipId).map((profile) => profile.id));
|
||||
if (!used.has(base)) {
|
||||
return base;
|
||||
}
|
||||
let suffix = 2;
|
||||
let candidate = `${base}_${suffix}`;
|
||||
while (used.has(candidate)) {
|
||||
suffix += 1;
|
||||
candidate = `${base}_${suffix}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function normalizePersistedProfile(input: unknown, index: number): ShellConnectionProfile | null {
|
||||
if (!input || typeof input !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = input as Partial<ShellConnectionProfile>;
|
||||
if (typeof candidate.serverUrl !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let serverUrl: string;
|
||||
try {
|
||||
serverUrl = normalizeUrl(candidate.serverUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = normalizeName(typeof candidate.name === "string" ? candidate.name : "");
|
||||
const createdAt = typeof candidate.createdAt === "string" && candidate.createdAt.length > 0 ? candidate.createdAt : nowIso();
|
||||
const updatedAt = typeof candidate.updatedAt === "string" && candidate.updatedAt.length > 0 ? candidate.updatedAt : createdAt;
|
||||
|
||||
return {
|
||||
id: typeof candidate.id === "string" && candidate.id.length > 0 ? candidate.id : `profile_imported_${index}`,
|
||||
name,
|
||||
serverUrl,
|
||||
authToken: typeof candidate.authToken === "string" ? candidate.authToken : null,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
lastUsedAt: typeof candidate.lastUsedAt === "string" ? candidate.lastUsedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
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[] : [];
|
||||
const source = Array.isArray(candidate.profiles) ? candidate.profiles : [];
|
||||
const profiles: ShellConnectionProfile[] = [];
|
||||
for (const [index, value] of source.entries()) {
|
||||
const normalized = normalizePersistedProfile(value, index);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const uniqueName = ensureUniqueName(normalized.name, profiles);
|
||||
const idAlreadyUsed = profiles.some((profile) => profile.id === normalized.id);
|
||||
const id = idAlreadyUsed ? ensureUniqueId(uniqueName, normalized.serverUrl, profiles) : normalized.id;
|
||||
profiles.push({ ...normalized, name: uniqueName, id });
|
||||
}
|
||||
|
||||
const activeProfileId =
|
||||
typeof candidate.activeProfileId === "string" && profiles.some((profile) => profile.id === candidate.activeProfileId)
|
||||
? candidate.activeProfileId
|
||||
: null;
|
||||
|
||||
return {
|
||||
activeProfileId: typeof candidate.activeProfileId === "string" ? candidate.activeProfileId : null,
|
||||
activeProfileId,
|
||||
profiles,
|
||||
};
|
||||
}
|
||||
@@ -69,11 +153,14 @@ export async function saveShellProfile(input: ShellConnectionProfileInput): Prom
|
||||
const state = await loadShellProfiles();
|
||||
const existing = input.id ? state.profiles.find((p) => p.id === input.id) : undefined;
|
||||
const timestamp = nowIso();
|
||||
const serverUrl = normalizeUrl(input.serverUrl);
|
||||
const normalizedName = normalizeName(input.name);
|
||||
const name = ensureUniqueName(normalizedName, state.profiles, existing?.id);
|
||||
|
||||
const profile: ShellConnectionProfile = {
|
||||
id: existing?.id ?? input.id ?? createId(),
|
||||
name: input.name.trim(),
|
||||
serverUrl: normalizeUrl(input.serverUrl),
|
||||
id: existing?.id ?? ensureUniqueId(name, serverUrl, state.profiles, existing?.id),
|
||||
name,
|
||||
serverUrl,
|
||||
authToken: input.authToken ?? null,
|
||||
createdAt: existing?.createdAt ?? timestamp,
|
||||
updatedAt: timestamp,
|
||||
@@ -91,7 +178,12 @@ export async function saveShellProfile(input: ShellConnectionProfileInput): Prom
|
||||
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;
|
||||
const activeProfileId =
|
||||
state.activeProfileId !== profileId
|
||||
? state.activeProfileId
|
||||
: profiles.length > 0
|
||||
? profiles[0]?.id ?? null
|
||||
: null;
|
||||
await saveShellState({ activeProfileId, profiles });
|
||||
}
|
||||
|
||||
@@ -102,9 +194,10 @@ export async function setActiveShellProfile(profileId: string | null): Promise<P
|
||||
? profileId
|
||||
: null;
|
||||
|
||||
const timestamp = nowIso();
|
||||
const profiles = state.profiles.map((profile) =>
|
||||
profile.id === activeProfileId
|
||||
? { ...profile, lastUsedAt: nowIso(), updatedAt: nowIso() }
|
||||
? { ...profile, lastUsedAt: timestamp, updatedAt: timestamp }
|
||||
: profile,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user