TRACES: | DR-204 484 ungated `console.*` calls across 63 non-test frontend files shipped to end users with no way to turn them off. Mechanical substitution, no control flow, error handling or message semantics changed: console.log / console.debug -> log.debug console.info -> log.info console.warn -> log.warn console.error -> log.error Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope now carries them; scope names that already existed are preserved verbatim (`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename where a file had none. `src/routes/player/[id]/+page.svelte` keeps its `NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than flattening them into the page scope. `grep -rn 'console\.' src/` now matches nothing outside the tests and the facade itself.
577 lines
18 KiB
TypeScript
577 lines
18 KiB
TypeScript
// 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<AuthState>(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<void> {
|
|
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<ServerInfo> {
|
|
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<string> {
|
|
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);
|