// 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"; 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) => { console.log("[Auth] Session verified:", event.payload.user.name); update((s) => ({ ...s, sessionVerified: true, needsReauth: false, isVerifying: false, user: event.payload.user, })); }); } catch (e) { console.error("[Auth] Failed to listen to session-verified event:", e); } try { unlistenNeedsReauth = await listen<{ reason: string }>("auth:needs-reauth", (event) => { console.log("[Auth] Session needs re-authentication:", event.payload.reason); update((s) => ({ ...s, sessionVerified: false, needsReauth: true, isVerifying: false, error: event.payload.reason, })); }); } catch (e) { console.error("[Auth] Failed to listen to needs-reauth event:", e); } try { unlistenNetworkError = await listen<{ message: string }>("auth:network-error", (event) => { console.log("[Auth] Network error during verification:", event.payload.message); // Network errors don't trigger re-auth - just log them update((s) => ({ ...s, isVerifying: false })); }); } catch (e) { console.error("[Auth] 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 try { const securityStatus = await commands.storageGetSecurityStatus(); console.log("[Auth] Security status:", securityStatus); if (!securityStatus.usingKeyring) { update((s) => ({ ...s, securityWarning: "Credentials are stored with reduced security (encrypted file instead of system keyring).", })); } } catch (error) { console.warn("[Auth] Failed to get security status:", error); } // Initialize auth manager and get session console.log("[Auth] Initializing auth manager..."); const session = await commands.authInitialize(); console.log("[Auth] Session retrieval result:", session ? "Session found" : "No session found"); if (session) { console.log("[Auth] Restoring session for user:", session.username, "on server:", session.serverUrl); // Create RepositoryClient for cache-first access repository = new RepositoryClient(); await repository.create(session.serverUrl, session.userId, session.accessToken, session.serverId); // Configure Jellyfin client in Rust player for automatic playback reporting const deviceId = await getDeviceId(); try { console.log("[Auth] Configuring Rust player with restored session..."); await commands.playerConfigureJellyfin( session.serverUrl, session.accessToken, session.userId, deviceId ); console.log("[Auth] Rust player configured for automatic playback reporting"); } catch (error) { console.error("[Auth] 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 console.log("[Auth] Starting early connectivity monitoring..."); connectivity.startMonitoring(session.serverUrl, { onServerReconnected: () => { // Retry session verification when server becomes reachable retryVerification(); }, }).catch((error) => { console.error("[Auth] Failed to start connectivity monitoring:", error); }); // Start background session verification try { const verifyDeviceId = await getDeviceId(); await commands.authStartVerification(verifyDeviceId); console.log("[Auth] Background verification started"); } catch (error) { console.error("[Auth] Failed to start verification:", error); } } else { // No stored session console.log("[Auth] 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) { console.error("[Auth] 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 { console.log("[Auth] Connecting to server:", serverUrl); const serverInfo = await commands.authConnectToServer(serverUrl); console.log("[Auth] Connected to server:", serverInfo.name, serverInfo.version); console.log("[Auth] Normalized URL:", serverInfo.normalizedUrl); update((s) => ({ ...s, isLoading: false })); return serverInfo; } catch (error) { console.error("[Auth] 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(); console.log("[Auth] Logging in as:", username); const authResult = await commands.authLogin(serverUrl, username, password, deviceId); console.log("[Auth] 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 ); console.log("[Auth] Rust player configured for playback reporting"); } catch (error) { console.error("[Auth] 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) { console.error("[Auth] Failed to start verification:", error); } return authResult; } catch (error) { console.error("[Auth] 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(); console.log("[Auth] Re-authenticating..."); const authResult = await commands.authReauthenticate(password, deviceId); console.log("[Auth] 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) { console.error("[Auth] 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) { console.error("[Auth] 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) { console.error("[Auth] 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) { console.error("[Auth] 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) { console.error("[Auth] 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(); console.log("[Auth] Retrying session verification after reconnection"); await commands.authStartVerification(deviceId); } catch (error) { console.error("[Auth] 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);