diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index f2282d5..e858648 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -3,6 +3,7 @@ use crate::server::resolve::strip_unc; #[cfg(target_os = "windows")] use std::path::PathBuf; use tauri::Manager; +use std::path::PathBuf; #[tauri::command] pub fn get_platform_ui_scale(window: tauri::Window) -> f64 { @@ -167,4 +168,4 @@ pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> { } } Ok(()) -} \ No newline at end of file +} diff --git a/src/api/client.ts b/src/api/client.ts index f891aa3..1925765 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,5 @@ import { store } from "@store/state.svelte"; -import { fetchAuthenticated, AuthRequiredError, uiAuth } from "../core/auth"; +import { fetchAuthenticated, AuthRequiredError, refreshUiAccessToken } from "../core/auth"; import { boot } from "@store/boot.svelte"; import { getBlobUrl } from "@core/cache/imageCache"; @@ -104,6 +104,15 @@ export async function gql( variables?: Record, signal?: AbortSignal, ): Promise { + const tryRefreshAndRetry = async (): Promise => { + const mode = store.settings.serverAuthMode ?? "NONE"; + if (mode !== "UI_LOGIN" || boot.skipped) return null; + const refreshed = await refreshUiAccessToken(true); + if (!refreshed) return null; + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + return attempt(); + }; + const attempt = async (): Promise => { const res = await fetchWithRetry( `${getServerUrl()}/api/graphql`, @@ -111,12 +120,21 @@ export async function gql( signal, ); if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`); + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + const retried = await tryRefreshAndRetry(); + if (retried) return retried; + } + throw new Error(`Suwayomi HTTP ${res.status}`); + } const json: GQLResponse = await res.json(); if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (json.errors?.length) { const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message)); if (isAuthError && !boot.skipped) { + const retried = await tryRefreshAndRetry(); + if (retried) return retried; + boot.sessionExpired = true; boot.loginRequired = true; boot.loginUser = store.settings.serverAuthUser ?? ""; diff --git a/src/api/mutations/tracking.ts b/src/api/mutations/tracking.ts index ad7939a..5d9effb 100644 --- a/src/api/mutations/tracking.ts +++ b/src/api/mutations/tracking.ts @@ -108,15 +108,20 @@ export const PUSH_KOSYNC_PROGRESS = ` `; export const LOGIN_USER = ` - mutation Login($username: String!, $password: String!) { - login(input: { username: $username, password: $password }) { + mutation Login($username: String!, $password: String!, $clientMutationId: String) { + login(input: { username: $username, password: $password, clientMutationId: $clientMutationId }) { accessToken + refreshToken + clientMutationId } } `; export const REFRESH_TOKEN = ` - mutation RefreshToken { - refreshToken(input: {}) { accessToken } + mutation RefreshToken($refreshToken: String!, $clientMutationId: String) { + refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) { + accessToken + clientMutationId + } } `; \ No newline at end of file diff --git a/src/core/auth.ts b/src/core/auth.ts index 0747ec6..5c5ee23 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -10,19 +10,276 @@ export class AuthRequiredError extends Error { } const TOKEN_KEY = "moku_access_token"; -let _accessToken: string | null = sessionStorage.getItem(TOKEN_KEY); +const UI_SESSION_KEY = "moku_ui_auth_session"; +const TOKEN_REFRESH_SKEW_MS = 30_000; +const AUTH_DEBUG = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV); + +interface StoredAccessToken { + base: string; + token: string; +} + +interface StoredUiAuthSession { + base: string; + accessToken: string; + refreshToken?: string; + clientMutationId?: string; + accessExpiresAt?: number | null; + refreshExpiresAt?: number | null; +} + +interface JwtSettings { + jwtAudience?: string | null; + jwtRefreshExpiry?: string | null; + jwtTokenExpiry?: string | null; +} + +export interface UiAuthDebugStatus { + mode: AuthMode; + serverBase: string; + hasSession: boolean; + hasRefreshToken: boolean; + accessExpiresAt: number | null; + refreshExpiresAt: number | null; + accessExpiresInMs: number | null; + refreshExpiresInMs: number | null; + shouldRefreshSoon: boolean; + refreshInFlight: boolean; + skewMs: number; +} + +let _accessToken: string | null = null; +let _accessTokenBase: string | null = null; +let _uiSession: StoredUiAuthSession | null = null; +let _refreshPromise: Promise | null = null; +let _jwtSettingsBase: string | null = null; +let _jwtSettings: JwtSettings | null = null; +let _jwtSettingsFetchedAt = 0; + +function authDebug(event: string, fields?: Record) { + if (!AUTH_DEBUG) return; + if (fields) { + console.debug(`[auth] ${event}`, fields); + return; + } + console.debug(`[auth] ${event}`); +} + +function parseIsoDuration(duration: string): number | null { + try { + const match = duration.match( + /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/ + ); + if (!match) return null; + const [, years, months, days, hours, minutes, seconds] = match; + let ms = 0; + if (years) ms += parseInt(years) * 365.25 * 24 * 60 * 60 * 1000; + if (months) ms += parseInt(months) * 30.44 * 24 * 60 * 60 * 1000; + if (days) ms += parseInt(days) * 24 * 60 * 60 * 1000; + if (hours) ms += parseInt(hours) * 60 * 60 * 1000; + if (minutes) ms += parseInt(minutes) * 60 * 1000; + if (seconds) ms += parseFloat(seconds) * 1000; + return ms; + } catch { + return null; + } +} + +function decodeJwtExpiryMs(token: string): number | null { + try { + const payload = token.split(".")[1]; + if (!payload) return null; + const normalized = payload.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "="); + const decoded = atob(padded); + const json = JSON.parse(decoded) as { exp?: number }; + return typeof json.exp === "number" ? json.exp * 1000 : null; + } catch { + return null; + } +} + +function isExpired(expiresAt?: number | null, skewMs = TOKEN_REFRESH_SKEW_MS): boolean { + if (!expiresAt || !Number.isFinite(expiresAt)) return false; + return Date.now() >= expiresAt - skewMs; +} + +function withExpiryFromSettings( + accessToken: string, + jwt: JwtSettings | null, +): Pick { + const now = Date.now(); + const accessExpiresAt = + decodeJwtExpiryMs(accessToken) + ?? (typeof jwt?.jwtTokenExpiry === "string" ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null); + const refreshExpiresAt = + typeof jwt?.jwtRefreshExpiry === "string" ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null; + return { accessExpiresAt, refreshExpiresAt }; +} + +async function fetchJwtSettings(base: string): Promise { + const res = await fetchAuthenticated( + `${base}/api/graphql`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: gqlBody( + `query GetJWTSettings { + settings { + jwtAudience + jwtRefreshExpiry + jwtTokenExpiry + } + }`, + ), + }, + timeoutSignal(5000), + ); + + if (!res.ok) { + authDebug("JWT settings fetch failed", { status: res.status }); + return null; + } + + const json = await res.json(); + if (json?.errors?.length) { + authDebug("JWT settings query error", { errors: json.errors }); + return null; + } + + const settings = json?.data?.settings; + if (!settings || typeof settings !== "object") { + authDebug("JWT settings missing or invalid", { settings }); + return null; + } + + authDebug("JWT settings fetched", { + hasAudience: !!settings.jwtAudience, + tokenExpiry: settings.jwtTokenExpiry, + refreshExpiry: settings.jwtRefreshExpiry, + }); + + return { + jwtAudience: typeof settings.jwtAudience === "string" ? settings.jwtAudience : null, + jwtRefreshExpiry: typeof settings.jwtRefreshExpiry === "string" ? settings.jwtRefreshExpiry : null, + jwtTokenExpiry: typeof settings.jwtTokenExpiry === "string" ? settings.jwtTokenExpiry : null, + }; +} + +async function getJwtSettings(force = false): Promise { + const base = getServerBase(); + const freshEnough = Date.now() - _jwtSettingsFetchedAt < 60_000; + if (!force && _jwtSettingsBase === base && _jwtSettings && freshEnough) return _jwtSettings; + + const jwt = await fetchJwtSettings(base); + _jwtSettingsBase = base; + _jwtSettings = jwt; + _jwtSettingsFetchedAt = Date.now(); + return jwt; +} export const uiAuth = { - getToken: () => _accessToken, - setToken: (t: string) => { _accessToken = t; sessionStorage.setItem(TOKEN_KEY, t); }, - clearToken: () => { _accessToken = null; sessionStorage.removeItem(TOKEN_KEY); }, + getSession: () => { + const base = getServerBase(); + if (_uiSession && _uiSession.base === base) return _uiSession; + + const stored = readStoredSession(); + if (!stored) return null; + if (stored.base !== base) { + sessionStorage.removeItem(UI_SESSION_KEY); + sessionStorage.removeItem(TOKEN_KEY); + _uiSession = null; + _accessToken = null; + _accessTokenBase = null; + return null; + } + + _uiSession = stored; + _accessToken = stored.accessToken; + _accessTokenBase = stored.base; + return _uiSession; + }, + setSession: (session: Omit) => { + const base = getServerBase(); + _uiSession = { ...session, base }; + _accessToken = session.accessToken; + _accessTokenBase = base; + sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_uiSession)); + sessionStorage.removeItem(TOKEN_KEY); + }, + getToken: () => { + const session = uiAuth.getSession(); + if (!session) return null; + + if (isExpired(session.accessExpiresAt, 0)) return null; + + const base = getServerBase(); + if (_accessToken && _accessTokenBase === base) return _accessToken; + const stored = readStoredToken(); + if (!stored) return null; + if (stored.base !== base) { + sessionStorage.removeItem(TOKEN_KEY); + sessionStorage.removeItem(UI_SESSION_KEY); + _accessToken = null; + _accessTokenBase = null; + _uiSession = null; + return null; + } + _accessToken = stored.token; + _accessTokenBase = stored.base; + return _accessToken; + }, + setToken: (t: string) => { + const existing = uiAuth.getSession(); + if (existing?.refreshToken) { + uiAuth.setSession({ + ...existing, + accessToken: t, + ...withExpiryFromSettings(t, _jwtSettings), + }); + return; + } + const base = getServerBase(); + _accessToken = t; + _accessTokenBase = base; + sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base, token: t })); + }, + setLoginSession: (payload: { accessToken: string; refreshToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => { + uiAuth.setSession({ + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + clientMutationId: payload.clientMutationId, + ...withExpiryFromSettings(payload.accessToken, jwt), + }); + }, + updateAccessToken: (payload: { accessToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => { + const existing = uiAuth.getSession(); + if (!existing?.refreshToken) { + uiAuth.setToken(payload.accessToken); + return; + } + uiAuth.setSession({ + ...existing, + accessToken: payload.accessToken, + clientMutationId: payload.clientMutationId ?? existing.clientMutationId, + ...withExpiryFromSettings(payload.accessToken, jwt), + refreshToken: existing.refreshToken, + }); + }, + clearToken: () => { + _accessToken = null; + _accessTokenBase = null; + _uiSession = null; + sessionStorage.removeItem(TOKEN_KEY); + sessionStorage.removeItem(UI_SESSION_KEY); + }, }; export const authSession = { clearTokens() { uiAuth.clearToken(); }, hasSession(): boolean { const mode = store.settings.serverAuthMode ?? "NONE"; - if (mode === "UI_LOGIN") return _accessToken !== null; + if (mode === "UI_LOGIN") return uiAuth.getSession() !== null; return true; }, }; @@ -32,6 +289,61 @@ function getServerBase(): string { return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567"; } +function readStoredToken(): StoredAccessToken | null { + const session = readStoredSession(); + if (session) return { base: session.base, token: session.accessToken }; + + const raw = sessionStorage.getItem(TOKEN_KEY); + if (raw?.trim()) { + try { + const parsed = JSON.parse(raw); + if (typeof parsed?.base === "string" && typeof parsed?.token === "string") + return { base: parsed.base, token: parsed.token }; + } catch {} + + const migrated = { base: getServerBase(), token: raw.trim() }; + sessionStorage.setItem(TOKEN_KEY, JSON.stringify(migrated)); + return migrated; + } + + return null; +} + +function readStoredSession(): StoredUiAuthSession | null { + const raw = sessionStorage.getItem(UI_SESSION_KEY); + if (raw?.trim()) { + try { + const parsed = JSON.parse(raw); + if (typeof parsed?.base === "string" && typeof parsed?.accessToken === "string") { + return { + base: parsed.base, + accessToken: parsed.accessToken, + refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : undefined, + clientMutationId: typeof parsed.clientMutationId === "string" ? parsed.clientMutationId : undefined, + accessExpiresAt: typeof parsed.accessExpiresAt === "number" ? parsed.accessExpiresAt : null, + refreshExpiresAt: typeof parsed.refreshExpiresAt === "number" ? parsed.refreshExpiresAt : null, + }; + } + } catch {} + } + + const legacy = sessionStorage.getItem(TOKEN_KEY); + if (!legacy?.trim()) return null; + + try { + const parsed = JSON.parse(legacy); + if (typeof parsed?.base === "string" && typeof parsed?.token === "string") { + const migrated: StoredUiAuthSession = { base: parsed.base, accessToken: parsed.token }; + sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated)); + return migrated; + } + } catch {} + + const migrated: StoredUiAuthSession = { base: getServerBase(), accessToken: legacy.trim() }; + sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated)); + return migrated; +} + function timeoutSignal(ms: number): AbortSignal { const controller = new AbortController(); setTimeout(() => controller.abort(), ms); @@ -69,27 +381,172 @@ export async function fetchAuthenticated( } if (mode === "UI_LOGIN") { - const token = uiAuth.getToken(); + const token = await getUIAccessToken(); if (!token) { if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders }); throw new AuthRequiredError(); } - return fetch(url, { + + let res = await fetch(url, { ...init, signal, credentials: "omit", headers: { ...baseHeaders, ...bearerHeader(token) }, }); + + if (res.status !== 401 || skipped) return res; + + const refreshed = await refreshUiAccessToken(true); + if (!refreshed) return res; + + res = await fetch(url, { + ...init, signal, credentials: "omit", + headers: { ...baseHeaders, ...bearerHeader(refreshed) }, + }); + return res; } return fetch(url, { ...init, signal, credentials: "omit" }); } +export async function getUIAccessToken(forceRefresh = false): Promise { + const session = uiAuth.getSession(); + if (!session) return null; + if (forceRefresh || isExpired(session.accessExpiresAt)) { + return refreshUiAccessToken(true); + } + return session.accessToken; +} + +export async function refreshUiAccessToken(force = false): Promise { + const session = uiAuth.getSession(); + if (!session) return null; + if (!session.refreshToken) { + if (force && isExpired(session.accessExpiresAt, 0)) return null; + return session.accessToken; + } + + if (!force && !isExpired(session.accessExpiresAt)) return session.accessToken; + if (isExpired(session.refreshExpiresAt)) { + authDebug("refresh skipped: refresh token expired", { + force, + refreshExpiresAt: session.refreshExpiresAt ?? null, + }); + uiAuth.clearToken(); + return null; + } + + if (_refreshPromise) { + authDebug("refresh joined existing request"); + return _refreshPromise; + } + + authDebug("refresh start", { + force, + accessExpiresAt: session.accessExpiresAt ?? null, + refreshExpiresAt: session.refreshExpiresAt ?? null, + }); + + _refreshPromise = (async () => { + const base = getServerBase(); + const jwt = await getJwtSettings().catch(() => null); + + const res = await fetch(`${base}/api/graphql`, { + method: "POST", + credentials: "omit", + headers: { "Content-Type": "application/json" }, + body: gqlBody( + `mutation RefreshToken($refreshToken: String!, $clientMutationId: String) { + refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) { + accessToken + clientMutationId + } + }`, + { refreshToken: session.refreshToken, clientMutationId: session.clientMutationId ?? undefined }, + ), + signal: timeoutSignal(5000), + }); + + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + authDebug("refresh rejected by server", { status: res.status }); + uiAuth.clearToken(); + return null; + } + authDebug("refresh failed with HTTP error", { status: res.status }); + throw new Error(`Token refresh failed (${res.status})`); + } + + const json = await res.json(); + const refreshed = json?.data?.refreshToken; + const nextAccessToken: string | undefined = refreshed?.accessToken; + if (!nextAccessToken) { + const msg = json?.errors?.[0]?.message; + if (msg && /unauthorized|unauthenticated|forbidden/i.test(msg)) { + authDebug("refresh rejected by GraphQL error", { message: msg }); + uiAuth.clearToken(); + return null; + } + authDebug("refresh returned no access token", { message: msg ?? null }); + throw new Error(msg ?? "Token refresh failed"); + } + + uiAuth.updateAccessToken( + { + accessToken: nextAccessToken, + clientMutationId: typeof refreshed?.clientMutationId === "string" + ? refreshed.clientMutationId + : session.clientMutationId, + }, + jwt, + ); + authDebug("refresh success", { + nextAccessExpiresAt: uiAuth.getSession()?.accessExpiresAt ?? null, + }); + return nextAccessToken; + })() + .catch((e: unknown) => { + authDebug("refresh threw error", { + message: e instanceof Error ? e.message : String(e), + }); + throw e; + }) + .finally(() => { + _refreshPromise = null; + }); + + return _refreshPromise; +} + +export function getUiAuthDebugStatus(now = Date.now()): UiAuthDebugStatus { + const session = uiAuth.getSession(); + const accessExpiresAt = session?.accessExpiresAt ?? null; + const refreshExpiresAt = session?.refreshExpiresAt ?? null; + + return { + mode: (store.settings.serverAuthMode ?? "NONE") as AuthMode, + serverBase: getServerBase(), + hasSession: !!session, + hasRefreshToken: !!session?.refreshToken, + accessExpiresAt, + refreshExpiresAt, + accessExpiresInMs: accessExpiresAt ? accessExpiresAt - now : null, + refreshExpiresInMs: refreshExpiresAt ? refreshExpiresAt - now : null, + shouldRefreshSoon: isExpired(accessExpiresAt), + refreshInFlight: _refreshPromise !== null, + skewMs: TOKEN_REFRESH_SKEW_MS, + }; +} + export async function loginUI(user: string, pass: string): Promise { const res = await fetch(`${getServerBase()}/api/graphql`, { method: "POST", credentials: "omit", headers: { "Content-Type": "application/json" }, body: gqlBody( `mutation Login($username: String!, $password: String!) { - login(input: { username: $username, password: $password }) { accessToken } + login(input: { username: $username, password: $password }) { + accessToken + refreshToken + clientMutationId + } }`, { username: user, password: pass }, ), @@ -97,10 +554,24 @@ export async function loginUI(user: string, pass: string): Promise { }); if (!res.ok) throw new Error(`Login request failed (${res.status})`); const json = await res.json(); - const token: string | undefined = json?.data?.login?.accessToken; - if (!token) throw new Error(json?.errors?.[0]?.message ?? "Login failed"); - uiAuth.setToken(token); - updateSettings({ serverAuthMode: "UI_LOGIN" }); + const payload = json?.data?.login; + const accessToken: string | undefined = payload?.accessToken; + const refreshToken: string | undefined = payload?.refreshToken; + if (!accessToken || !refreshToken) throw new Error(json?.errors?.[0]?.message ?? "Login failed"); + + authDebug("login success", { user }); + + const preliminarySession = { + accessToken, + refreshToken, + clientMutationId: typeof payload?.clientMutationId === "string" ? payload.clientMutationId : undefined, + }; + + uiAuth.setLoginSession(preliminarySession, null); + updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: user, serverAuthPass: "" }); + + const jwt = await getJwtSettings(true).catch(() => null); + uiAuth.setLoginSession(preliminarySession, jwt); } export async function loginBasic(user: string, pass: string): Promise { @@ -123,8 +594,9 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unreachab const base = getServerBase(); const mode = store.settings.serverAuthMode ?? "NONE"; const s = store.settings; + const token = mode === "UI_LOGIN" ? await getUIAccessToken() : null; - if (mode === "UI_LOGIN" && !_accessToken) return "auth_required"; + if (mode === "UI_LOGIN" && !token) return "auth_required"; try { const headers: Record = { "Content-Type": "application/json" }; @@ -132,8 +604,8 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unreachab const user = s.serverAuthUser?.trim() ?? ""; const pass = s.serverAuthPass?.trim() ?? ""; if (user && pass) Object.assign(headers, basicHeader(user, pass)); - } else if (mode === "UI_LOGIN" && _accessToken) { - Object.assign(headers, bearerHeader(_accessToken)); + } else if (mode === "UI_LOGIN" && token) { + Object.assign(headers, bearerHeader(token)); } const res = await fetch(`${base}/api/graphql`, { diff --git a/src/core/cache/imageCache.ts b/src/core/cache/imageCache.ts index ca990f2..b162185 100644 --- a/src/core/cache/imageCache.ts +++ b/src/core/cache/imageCache.ts @@ -1,6 +1,6 @@ import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { store } from "@store/state.svelte"; -import { uiAuth } from "@core/auth"; +import { getUIAccessToken } from "@core/auth"; const cache = new Map(); const inflight = new Map>(); @@ -18,10 +18,10 @@ interface QueueEntry { const queue: QueueEntry[] = []; -function getAuthHeaders(): Record { +async function getAuthHeaders(): Promise> { const mode = store.settings.serverAuthMode ?? "NONE"; if (mode === "UI_LOGIN") { - const token = uiAuth.getToken(); + const token = await getUIAccessToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } if (mode === "BASIC_AUTH") { @@ -33,7 +33,8 @@ function getAuthHeaders(): Record { } async function doFetch(url: string): Promise { - const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() }); + const headers = await getAuthHeaders(); + const res = await tauriFetch(url, { method: "GET", headers }); if (!res.ok) throw new Error(`${res.status}`); const blob = await res.blob(); if (clearing) throw new DOMException("Cancelled", "AbortError"); diff --git a/src/features/settings/sections/DevtoolsSettings.svelte b/src/features/settings/sections/DevtoolsSettings.svelte index 6e6fb36..b9c2a50 100644 --- a/src/features/settings/sections/DevtoolsSettings.svelte +++ b/src/features/settings/sections/DevtoolsSettings.svelte @@ -2,6 +2,7 @@ import ThreeDCard from "@shared/manga/ThreeDCard.svelte"; import { store, addToast } from "@store/state.svelte"; import { cache } from "@core/cache/index"; + import { getUiAuthDebugStatus, refreshUiAccessToken, type UiAuthDebugStatus } from "@core/auth"; import { invoke } from "@tauri-apps/api/core"; interface PerfSnapshot { cacheEntries: number; cacheKeys: string[]; oldestEntryMs: number | null; newestEntryMs: number | null; } @@ -12,13 +13,69 @@ let appVersion = $state("…"); let helloAvailable = $state(null); let helloBusy = $state(false); + let authStatus = $state(null); + let authRefreshBusy = $state(false); $effect(() => { import("@tauri-apps/api/app").then(m => m.getVersion()).then(v => appVersion = v).catch(() => {}); refreshPerfMetrics(); + refreshAuthStatus(); invoke("windows_hello_available").then(v => helloAvailable = v).catch(() => helloAvailable = false); + + const timer = setInterval(() => refreshAuthStatus(), 1000); + return () => clearInterval(timer); }); + function refreshAuthStatus() { + authStatus = getUiAuthDebugStatus(); + } + + function fmtCountdown(ms: number | null): string { + if (ms === null) return "—"; + if (ms <= 0) return "expired"; + + const total = Math.floor(ms / 1000); + const month = 30 * 24 * 60 * 60; + const day = 24 * 60 * 60; + const hour = 60 * 60; + const minute = 60; + + const months = Math.floor(total / month); + const days = Math.floor((total % month) / day); + const hours = Math.floor(total / 3600); + const remainingHours = Math.floor((total % day) / hour); + const mins = Math.floor((total % hour) / minute); + const secs = total % 60; + + if (months > 0) return days > 0 ? `${months}mo ${days}d` : `${months}mo`; + if (days > 0) return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`; + if (hours > 0) return `${hours}h ${mins}m ${secs}s`; + if (mins > 0) return `${mins}m ${secs}s`; + return `${secs}s`; + } + + function fmtTime(ts: number | null): string { + if (ts === null) return "—"; + return new Date(ts).toLocaleString([], { dateStyle: "medium", timeStyle: "medium" }); + } + + async function forceTokenRefresh() { + authRefreshBusy = true; + try { + const token = await refreshUiAccessToken(true); + addToast({ + kind: token ? "success" : "info", + title: "UI auth refresh", + body: token ? "Refresh succeeded" : "No refreshed token available", + }); + } catch (e: any) { + addToast({ kind: "error", title: "UI auth refresh", body: String(e?.message ?? e) }); + } finally { + authRefreshBusy = false; + refreshAuthStatus(); + } + } + function refreshPerfMetrics() { let entries = 0, oldest: number | null = null, newest: number | null = null; const foundKeys: string[] = []; @@ -75,7 +132,7 @@
Fire test toastTriggers each kind with realistic content
- {#each ([["success","S"],["error","E"],["info","I"],["download","D"]] as const) as [kind, label]} + {#each ([["success","S"],["error","E"],["info","I"],["download","D"]] as const) as [kind, label] (kind)} + +
+
+ + + \ No newline at end of file diff --git a/src/features/settings/sections/SecuritySettings.svelte b/src/features/settings/sections/SecuritySettings.svelte index aa98806..c171ce9 100644 --- a/src/features/settings/sections/SecuritySettings.svelte +++ b/src/features/settings/sections/SecuritySettings.svelte @@ -1,7 +1,7 @@