// Authentication state store with Rust backend // // All business logic (session management, verification, credential storage) is handled by Rust. // This file is a thin Svelte store wrapper that calls Rust commands and listens to events. // // TRACES: UR-009, UR-012 | IR-009, IR-014 import { writable, derived, get } from "svelte/store"; import { listen } from "@tauri-apps/api/event"; import { commands } from "$lib/api/bindings"; import { RepositoryClient } from "$lib/api/repository-client"; import type { User, AuthResult } from "$lib/api/types"; import type { Session, AuthServerInfo as ServerInfo } from "$lib/api/bindings"; import { connectivity } from "./connectivity"; import { getDeviceId, clearCache as clearDeviceIdCache } from "$lib/services/deviceId"; import { createLogger } from "$lib/utils/logger"; const log = createLogger("Auth"); interface AuthState { isAuthenticated: boolean; isLoading: boolean; user: User | null; serverUrl: string | null; serverName: string | null; error: string | null; securityWarning: string | null; /** Whether session needs re-authentication (e.g., token expired) */ needsReauth: boolean; /** Whether session verification is in progress */ isVerifying: boolean; /** Whether the session is known to be valid (verified with server) */ sessionVerified: boolean; } function createAuthStore() { const initialState: AuthState = { isAuthenticated: false, isLoading: true, user: null, serverUrl: null, serverName: null, error: null, securityWarning: null, needsReauth: false, isVerifying: false, sessionVerified: false, }; const { subscribe, set, update } = writable(initialState); // RepositoryClient provides cache-first access with automatic background refresh via Rust let repository: RepositoryClient | null = null; // Store unlisten functions for cleanup let unlistenSessionVerified: (() => void) | null = null; let unlistenNeedsReauth: (() => void) | null = null; let unlistenNetworkError: (() => void) | null = null; function getRepository(): RepositoryClient { if (!repository) { throw new Error("Not connected to a server"); } return repository; } /** * Initialize event listeners from Rust backend. * These should be called once during app initialization. */ async function initializeEventListeners(): Promise { if (typeof window === "undefined") return; try { unlistenSessionVerified = await listen<{ user: User }>("auth:session-verified", (event) => { log.debug("Session verified:", event.payload.user.name); update((s) => ({ ...s, sessionVerified: true, needsReauth: false, isVerifying: false, user: event.payload.user, })); }); } catch (e) { log.error("Failed to listen to session-verified event:", e); } try { unlistenNeedsReauth = await listen<{ reason: string }>("auth:needs-reauth", (event) => { log.debug("Session needs re-authentication:", event.payload.reason); update((s) => ({ ...s, sessionVerified: false, needsReauth: true, isVerifying: false, error: event.payload.reason, })); }); } catch (e) { log.error("Failed to listen to needs-reauth event:", e); } try { unlistenNetworkError = await listen<{ message: string }>("auth:network-error", (event) => { log.debug("Network error during verification:", event.payload.message); // Network errors don't trigger re-auth - just log them update((s) => ({ ...s, isVerifying: false })); }); } catch (e) { log.error("Failed to listen to network-error event:", e); } } /** * Cleanup event listeners. * Should be called when the app is destroyed. */ function cleanupEventListeners(): void { if (unlistenSessionVerified) { unlistenSessionVerified(); unlistenSessionVerified = null; } if (unlistenNeedsReauth) { unlistenNeedsReauth(); unlistenNeedsReauth = null; } if (unlistenNetworkError) { unlistenNetworkError(); unlistenNetworkError = null; } } /** * Initialize auth state from Rust backend. * This function does NOT require network access - session is restored immediately. */ async function initialize() { // Initialize event listeners first await initializeEventListeners(); update((s) => ({ ...s, isLoading: true, error: null })); try { // Check security status — fire-and-forget. It only sets a warning banner, // so it must not sit in front of session restore (and thus first paint). void (async () => { try { const securityStatus = await commands.storageGetSecurityStatus(); log.debug("Security status:", securityStatus); if (!securityStatus.usingKeyring) { update((s) => ({ ...s, securityWarning: "Credentials are stored with reduced security (encrypted file instead of system keyring).", })); } } catch (error) { log.warn("Failed to get security status:", error); } })(); // Initialize auth manager and get session log.debug("Initializing auth manager..."); const session = await commands.authInitialize(); log.debug("Session retrieval result:", session ? "Session found" : "No session found"); if (session) { log.debug("Restoring session for user:", session.username, "on server:", session.serverUrl); // Create RepositoryClient for cache-first access. This IS required before // we mark authenticated — the first screen (library overview) reads // through it — so keep it awaited. repository = new RepositoryClient(); await repository.create(session.serverUrl, session.userId, session.accessToken, session.serverId); // Configure the Rust player for playback reporting. This is NOT needed to // render the first screen (it only matters once playback starts), so run // it fire-and-forget instead of blocking first paint on two more IPC // round-trips (getDeviceId + playerConfigureJellyfin). void (async () => { try { const deviceId = await getDeviceId(); await commands.playerConfigureJellyfin( session.serverUrl, session.accessToken, session.userId, deviceId ); log.debug("Rust player configured for automatic playback reporting"); } catch (error) { log.error("Failed to configure Rust player:", error); } })(); // Set authenticated immediately (offline-first) set({ isAuthenticated: true, isLoading: false, user: { id: session.userId, name: session.username, serverId: session.serverId } as User, serverUrl: session.serverUrl, serverName: session.serverName, error: null, securityWarning: initialState.securityWarning, needsReauth: session.needsReauth, isVerifying: false, sessionVerified: session.verified, }); // Start connectivity monitoring early to avoid appearing offline on startup log.debug("Starting early connectivity monitoring..."); connectivity.startMonitoring(session.serverUrl, { onServerReconnected: () => { // Retry session verification when server becomes reachable retryVerification(); // Resume downloads queued while offline, then refresh the catalog. // Lazy import to avoid an auth <-> offlineCatalog import cycle. import("$lib/services/offlineCatalog") .then((m) => m.onReconnected()) .catch((err) => log.warn("Catalog reconnect failed:", err)); }, }).catch((error) => { log.error("Failed to start connectivity monitoring:", error); }); // Start background session verification — fire-and-forget. This is // already asynchronous work (results arrive via the auth:* events wired // above), so awaiting getDeviceId + authStartVerification here only // delayed first paint by two IPC round-trips for no UI benefit. void (async () => { try { const verifyDeviceId = await getDeviceId(); await commands.authStartVerification(verifyDeviceId); log.debug("Background verification started"); } catch (error) { log.error("Failed to start verification:", error); } })(); } else { // No stored session log.debug("No active session found"); set({ isAuthenticated: false, isLoading: false, user: null, serverUrl: null, serverName: null, error: null, securityWarning: initialState.securityWarning, needsReauth: false, isVerifying: false, sessionVerified: false, }); } } catch (error) { log.error("Failed to initialize:", error); update((s) => ({ ...s, isLoading: false, error: error instanceof Error ? error.message : String(error), })); } } /** * Connect to a Jellyfin server and retrieve server info. * Rust will normalize the URL (add https:// if missing, remove trailing slash). * * TRACES: UR-009 | IR-009 */ async function connectToServer(serverUrl: string): Promise { update((s) => ({ ...s, isLoading: true, error: null })); try { log.debug("Connecting to server:", serverUrl); const serverInfo = await commands.authConnectToServer(serverUrl); log.debug("Connected to server:", serverInfo.name, serverInfo.version); log.debug("Normalized URL:", serverInfo.normalizedUrl); update((s) => ({ ...s, isLoading: false })); return serverInfo; } catch (error) { log.error("Failed to connect to server:", error); update((s) => ({ ...s, isLoading: false, error: error instanceof Error ? error.message : String(error), })); throw error; } } /** * Login with username and password. * * TRACES: UR-009, UR-012 | IR-009, IR-014 */ async function login(username: string, password: string, serverUrl: string, serverName: string) { update((s) => ({ ...s, isLoading: true, error: null })); try { const deviceId = await getDeviceId(); log.debug("Logging in as:", username); const authResult = await commands.authLogin(serverUrl, username, password, deviceId); log.debug("Login successful:", authResult.user); // Save to storage await commands.storageSaveServer(authResult.serverId, serverName, serverUrl, null); await commands.storageSaveUser( authResult.user.id, authResult.serverId, authResult.user.name, authResult.accessToken ); await commands.storageSetActiveUser(authResult.user.id, authResult.serverId); // Set session in auth manager with server name await commands.authSetSession({ userId: authResult.user.id, username: authResult.user.name, serverId: authResult.serverId, serverUrl, serverName, accessToken: authResult.accessToken, verified: true, needsReauth: false, }); // Create RepositoryClient repository = new RepositoryClient(); await repository.create(serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId); // Configure Rust player try { const playerDeviceId = await getDeviceId(); await commands.playerConfigureJellyfin( serverUrl, authResult.accessToken, authResult.user.id, playerDeviceId ); log.debug("Rust player configured for playback reporting"); } catch (error) { log.error("Failed to configure Rust player:", error); } // Update state set({ isAuthenticated: true, isLoading: false, user: authResult.user, serverUrl, serverName, error: null, securityWarning: initialState.securityWarning, needsReauth: false, isVerifying: false, sessionVerified: true, }); // Start background verification try { const verifyDeviceId = await getDeviceId(); await commands.authStartVerification(verifyDeviceId); } catch (error) { log.error("Failed to start verification:", error); } return authResult; } catch (error) { log.error("Login failed:", error); const errorMessage = error instanceof Error ? error.message : String(error); update((s) => ({ ...s, isLoading: false, error: errorMessage })); throw error; } } /** * Re-authenticate with password (when session expired). */ async function reauthenticate(password: string) { update((s) => ({ ...s, isLoading: true, error: null, needsReauth: false })); try { const deviceId = await getDeviceId(); log.debug("Re-authenticating..."); const authResult = await commands.authReauthenticate(password, deviceId); log.debug("Re-authentication successful"); // Update storage await commands.storageSaveUser( authResult.user.id, authResult.serverId, authResult.user.name, authResult.accessToken ); // Recreate repository with new credentials if (repository) { await repository.destroy(); const session = await commands.authGetSession(); if (session) { await repository.create(session.serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId); } } // Reconfigure player try { const playerDeviceId = await getDeviceId(); await commands.playerConfigureJellyfin( repository ? await getCurrentSessionServerUrl() : "", authResult.accessToken, authResult.user.id, playerDeviceId ); } catch (error) { log.error("Failed to reconfigure player:", error); } // Update state update((s) => ({ ...s, isLoading: false, needsReauth: false, sessionVerified: true, user: authResult.user, error: null, })); return authResult; } catch (error) { log.error("Re-authentication failed:", error); const errorMessage = error instanceof Error ? error.message : String(error); update((s) => ({ ...s, isLoading: false, error: errorMessage })); throw error; } } /** * Logout and clear session. * * TRACES: UR-012 | IR-014 */ async function logout() { try { const session = await commands.authGetSession(); if (session) { const deviceId = await getDeviceId(); await commands.authLogout(session.serverUrl, session.accessToken, deviceId); } // Disable Jellyfin reporting in player try { await commands.playerDisableJellyfin(); } catch (error) { log.error("Failed to disable player reporting:", error); } // Clear repository if (repository) { await repository.destroy(); } repository = null; set({ isAuthenticated: false, isLoading: false, user: null, serverUrl: null, serverName: null, error: null, securityWarning: null, needsReauth: false, isVerifying: false, sessionVerified: false, }); // Clear device ID cache on logout clearDeviceIdCache(); } catch (error) { log.error("Logout error (continuing anyway):", error); set(initialState); clearDeviceIdCache(); } } /** * Clear error state. */ function clearError() { update((s) => ({ ...s, error: null })); } /** * Get current session from Rust backend. */ async function getCurrentSession() { try { return await commands.authGetSession(); } catch (error) { log.error("Failed to get current session:", error); return null; } } /** * Get current user ID. */ function getUserId(): string | null { const state = get({ subscribe }); return state.user?.id || null; } /** * Get server URL. */ function getServerUrl(): string | null { const state = get({ subscribe }); return state.serverUrl; } /** * Helper to get server URL from current session. */ async function getCurrentSessionServerUrl(): Promise { const session = await commands.authGetSession(); return session?.serverUrl || ""; } /** * Retry session verification (called when server becomes reachable again). */ async function retryVerification() { try { const deviceId = await getDeviceId(); log.debug("Retrying session verification after reconnection"); await commands.authStartVerification(deviceId); } catch (error) { log.error("Failed to retry verification:", error); } } return { subscribe, initialize, connectToServer, login, reauthenticate, logout, clearError, getRepository, getCurrentSession, getUserId, getServerUrl, retryVerification, cleanupEventListeners, }; } export const auth = createAuthStore(); export const isAuthenticated = derived(auth, ($auth) => $auth.isAuthenticated); export const isLoading = derived(auth, ($auth) => $auth.isLoading); export const currentUser = derived(auth, ($auth) => $auth.user); export const needsReauth = derived(auth, ($auth) => $auth.needsReauth); export const securityWarning = derived(auth, ($auth) => $auth.securityWarning); export const authError = derived(auth, ($auth) => $auth.error); export const isVerifying = derived(auth, ($auth) => $auth.isVerifying); export const sessionVerified = derived(auth, ($auth) => $auth.sessionVerified); export const serverName = derived(auth, ($auth) => $auth.serverName); export const serverUrl = derived(auth, ($auth) => $auth.serverUrl);