/** * Profile switching — a thin wrapper over the Rust `profiles_*` commands. * * There is deliberately no logic here worth the name. This store does not * compare a PIN, count an attempt, decide whether a profile is locked, or work * out whether the picker should appear at startup. All of that is backend state * and arrives as an opaque `unlockMethod`, an `UnlockOutcome` or a * `StartupTarget`. A store that re-derived any of it would be a gate the webview * could skip. * * TRACES: UR-082, UR-083, UR-084 | DR-267, DR-269, DR-274, DR-276 */ import { writable, get } from "svelte/store"; import { commands } from "$lib/api/bindings"; import type { Profile, StartupTarget, UnlockOutcome } from "$lib/api/bindings"; import { getDeviceId } from "$lib/services/deviceId"; import { auth } from "./auth"; import { createLogger } from "$lib/utils/logger"; const log = createLogger("Profiles"); interface ProfilesState { profiles: Profile[]; isLoading: boolean; error: string | null; } function createProfilesStore() { const { subscribe, set, update } = writable({ profiles: [], isLoading: false, error: null, }); async function refresh(): Promise { update((s) => ({ ...s, isLoading: true, error: null })); try { const profiles = await commands.profilesList(); set({ profiles, isLoading: false, error: null }); return profiles; } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error("Failed to list profiles:", error); set({ profiles: [], isLoading: false, error: message }); return []; } } /** * Where the app should go on launch. Asked, never computed — the answer * depends on PIN presence and a stored setting, neither of which the frontend * should be reasoning about. */ async function startupTarget(): Promise { return await commands.profilesStartupTarget(); } /** * Enter a profile. On success the backend has already switched; this only * rebuilds the frontend's repository handle. */ async function unlock(userId: string, pin: string | null): Promise { const outcome = await commands.profilesUnlock(userId, pin); if (outcome.type === "ok") { await auth.adoptSwitchedSession(); await refresh(); } return outcome; } /** * The way back in for someone who has forgotten their PIN. Their ordinary * Jellyfin password is the authority over their own account, so there is * nothing else to reset. */ async function unlockWithPassword(userId: string, password: string): Promise { const deviceId = await getDeviceId(); const outcome = await commands.profilesUnlockWithPassword(userId, password, deviceId); if (outcome.type === "ok") { await auth.adoptSwitchedSession(); await refresh(); } return outcome; } /** Add another account from the server already connected. */ async function add(username: string, password: string, pin: string | null): Promise { const deviceId = await getDeviceId(); const profile = await commands.profilesAdd(username, password, pin, deviceId); await refresh(); return profile; } async function setPin(userId: string, currentPin: string | null, newPin: string | null) { await commands.profilesSetPin(userId, currentPin, newPin); await refresh(); } async function remove(userId: string) { await commands.profilesRemove(userId); await refresh(); } async function setAskOnStart(enabled: boolean) { await commands.profilesSetAskOnStart(enabled); } /** Whether this device has more than one account — what makes the UI worth showing at all. */ function isShared(): boolean { return get({ subscribe }).profiles.length > 1; } return { subscribe, refresh, startupTarget, unlock, unlockWithPassword, add, setPin, remove, setAskOnStart, isShared, }; } export const profiles = createProfilesStore();