Compare commits

..

17 Commits

Author SHA1 Message Date
Youwes09 3dad4bc729 Feat: Re-Arrangement of Folders (#86) 2026-05-19 20:36:44 -05:00
Youwes09 1af21efebd Feat: Download Storage Threshold Warning (#88) 2026-05-19 20:07:21 -05:00
Youwes09 b7197a09a7 Fix: Attempt to Patch UI Login (Not-Working) 2026-05-19 19:25:43 -05:00
Youwes09 50dd8d7e35 Fix: Preserve Token for NONE Path 2026-05-19 18:55:44 -05:00
Youwes09 b2eaea6552 Merge branch 'main' of github.com:moku-project/Moku 2026-05-19 18:53:47 -05:00
Shozikan 35aae6d85a Chore: Merge PR (#78)
Rework authentication for smoother switching between servers and auth mode
2026-05-19 18:52:48 -05:00
Youwes09 28e5f5625e Merge branch 'fix/auth' 2026-05-19 18:15:00 -05:00
Zerebos b99e4d9a3d Cleanup logs 2026-05-19 02:32:34 -04:00
Zerebos 75cc767b58 Expiry formatting change 2026-05-17 04:11:33 -04:00
Zerebos d30c623200 Better formatting for dates 2026-05-17 04:08:46 -04:00
Zerebos 017e9bc6da Authenticated fetch jwt settings 2026-05-17 04:00:34 -04:00
Zerebos 3b8088a2bf Decode ISO-8601 2026-05-17 03:50:39 -04:00
Zerebos 2c5320dd1f Some debug logging 2026-05-17 03:31:17 -04:00
Zerebos 1e35f304b6 Add auth debug in devtools 2026-05-17 03:29:27 -04:00
Zerebos 61339ea006 Implement jwt with refresh 2026-05-17 03:04:23 -04:00
Zerebos bee8117aac Don't introduce a new key 2026-05-16 00:01:21 -04:00
Zerebos 0bea9c22cb Rework auth to allow smooth switching 2026-05-15 23:50:19 -04:00
13 changed files with 954 additions and 267 deletions
+2 -1
View File
@@ -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(())
}
}
+20 -2
View File
@@ -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<T>(
variables?: Record<string, unknown>,
signal?: AbortSignal,
): Promise<T> {
const tryRefreshAndRetry = async (): Promise<T | null> => {
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<T> => {
const res = await fetchWithRetry(
`${getServerUrl()}/api/graphql`,
@@ -111,12 +120,21 @@ export async function gql<T>(
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<T> = 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 ?? "";
+9 -4
View File
@@ -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
}
}
`;
+494 -16
View File
@@ -10,19 +10,282 @@ 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<string | null> | null = null;
let _jwtSettingsBase: string | null = null;
let _jwtSettings: JwtSettings | null = null;
let _jwtSettingsFetchedAt = 0;
function authDebug(event: string, fields?: Record<string, unknown>) {
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<StoredUiAuthSession, "accessExpiresAt" | "refreshExpiresAt"> {
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<JwtSettings | null> {
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<JwtSettings | null> {
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<StoredUiAuthSession, "base">) => {
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(); },
clearTokens() {
_refreshPromise = null;
_jwtSettings = null;
_jwtSettingsBase = null;
_jwtSettingsFetchedAt = 0;
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 +295,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 +387,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<string | null> {
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<string | null> {
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<void> {
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 +560,24 @@ export async function loginUI(user: string, pass: string): Promise<void> {
});
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<void> {
@@ -123,8 +600,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<string, string> = { "Content-Type": "application/json" };
@@ -132,8 +610,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`, {
+5 -4
View File
@@ -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<string, string>();
const inflight = new Map<string, Promise<string>>();
@@ -18,10 +18,10 @@ interface QueueEntry {
const queue: QueueEntry[] = [];
function getAuthHeaders(): Record<string, string> {
async function getAuthHeaders(): Promise<Record<string, string>> {
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<string, string> {
}
async function doFetch(url: string): Promise<string> {
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");
@@ -65,6 +65,18 @@ export function reorderSelectedToEdge(
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
}
const AVG_BYTES_PER_PAGE = 1_500_000;
export function estimateQueueBytes(queue: DownloadQueueItem[]): number {
let total = 0;
for (const item of queue) {
const pages = item.chapter.pageCount ?? 0;
const remaining = pages - Math.round(item.progress * pages);
total += remaining * AVG_BYTES_PER_PAGE;
}
return total;
}
export function formatEta(seconds: number): string {
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
@@ -10,10 +10,11 @@ import { boot } from "@store/boot.svelte";
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
import {
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
isRunning, getErrored, calcSpeed, estimateEta,
isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes,
type SpeedSample,
} from "../lib/downloadQueue";
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
import { invoke } from "@tauri-apps/api/core";
class DownloadStore {
status: DownloadStatus | null = $state(null);
@@ -23,8 +24,11 @@ class DownloadStore {
dequeueing = $state(new Set<number>());
selected = $state(new Set<number>());
batchWorking = $state(false);
pagesPerSec: number | null = $state(null);
eta: number | null = $state(null);
pagesPerSec: number | null = $state(null);
eta: number | null = $state(null);
storageWarning: boolean = $state(false);
private freeBytes: number | null = null;
get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
@@ -82,6 +86,52 @@ class DownloadStore {
this.status = ds;
setActiveDownloads(toActiveDownloads(ds.queue));
this.updateSpeed(ds);
this.fetchFreeBytes(ds);
}
private async fetchFreeBytes(ds: DownloadStatus) {
const path = store.settings.serverDownloadsPath ?? "";
if (!path) return;
try {
const info = await invoke<{ free_bytes: number }>("get_storage_info", { downloadsPath: path });
this.freeBytes = info.free_bytes;
this.storageWarning = estimateQueueBytes(ds.queue) > info.free_bytes * 0.95;
} catch { }
}
private confirmStorageOverrun(): Promise<boolean> {
return new Promise(resolve => {
const backdrop = document.createElement("div");
backdrop.style.cssText = "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;animation:s-fade-in 0.15s ease both";
const panel = document.createElement("div");
panel.style.cssText = "background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 24px 80px rgba(0,0,0,0.7),0 0 0 1px rgba(255,255,255,0.04) inset;width:min(380px,calc(100vw - 40px));overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both";
panel.innerHTML = `
<div style="padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)">
<p style="margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em">Low disk space</p>
</div>
<div style="padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)">
<p style="margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-muted);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)">
The download queue is estimated to exceed 95% of your available storage. Download anyway?
</p>
</div>
<div style="padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end;gap:var(--sp-2)">
<button id="_moku-storage-cancel" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid var(--border-dim);background:none;color:var(--text-muted);cursor:pointer">Cancel</button>
<button id="_moku-storage-confirm" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid color-mix(in srgb,var(--color-error) 40%,transparent);background:color-mix(in srgb,var(--color-error) 10%,transparent);color:var(--color-error);cursor:pointer">Download anyway</button>
</div>
`;
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
function finish(result: boolean) { backdrop.remove(); resolve(result); }
panel.querySelector("#_moku-storage-cancel")!.addEventListener("click", () => finish(false));
panel.querySelector("#_moku-storage-confirm")!.addEventListener("click", () => finish(true));
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) finish(false); });
});
}
private async guardStorage(queueAfter: DownloadQueueItem[]): Promise<boolean> {
if (this.freeBytes === null) return true;
if (estimateQueueBytes(queueAfter) <= this.freeBytes * 0.95) return true;
return this.confirmStorageOverrun();
}
private updateSpeed(ds: DownloadStatus) {
@@ -172,11 +222,21 @@ class DownloadStore {
finally { this.batchWorking = false; }
}
async enqueue(chapterId: number): Promise<boolean> {
const projected = [...this.queue, { chapter: { id: chapterId, pageCount: 0 }, progress: 0, state: "QUEUED" } as any];
if (!(await this.guardStorage(projected))) return false;
try { await gql(ENQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
catch (e) { console.error(e); }
return true;
}
async retryOne(chapterId: number) {
if (this.dequeueing.has(chapterId)) return;
this.dequeueing = new Set(this.dequeueing).add(chapterId);
try {
await gql(DEQUEUE_DOWNLOAD, { chapterId });
const projected = this.queue.filter(i => i.chapter.id !== chapterId);
if (!(await this.guardStorage(projected))) { this.poll(); return; }
await gql(ENQUEUE_DOWNLOAD, { chapterId });
this.poll();
} catch (e) { console.error(e); this.poll(); }
@@ -189,6 +249,8 @@ class DownloadStore {
const ids = [...this.erroredIds];
try {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
const projected = this.queue.filter(i => !this.erroredIds.has(i.chapter.id));
if (!(await this.guardStorage(projected))) { this.poll(); return; }
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
this.poll();
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
@@ -204,6 +266,8 @@ class DownloadStore {
try {
if (ids.length > 0) {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
const projected = this.queue.filter(i => !new Set(ids).has(i.chapter.id));
if (!(await this.guardStorage(projected))) { this.poll(); return; }
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
}
+32 -22
View File
@@ -71,8 +71,8 @@
let activeDragKind: "tab" | null = $state(null);
let dragInsertIdx: number = $state(-1);
let dragTabId: number | null = $state(null);
let dragOverTabId: number | null = $state(null);
let dragTabId: string | null = $state(null);
let dragOverTabId: string | null = $state(null);
const DT_TAB = "application/x-moku-tab";
const anims = $derived(store.settings.qolAnimations ?? true);
@@ -95,7 +95,8 @@
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
const pinned = store.settings.libraryPinnedTabOrder ?? [];
const known = new Set([...BUILTIN_TABS, ...catIds]);
const ordered = [...pinned.filter(id => known.has(id))];
const eligible = pinned.filter(id => known.has(id));
const ordered = [...eligible];
const inOrder = new Set(ordered);
for (const id of [...BUILTIN_TABS, ...catIds]) {
if (!inOrder.has(id)) ordered.push(id);
@@ -517,45 +518,54 @@
});
}
function onTabDragStart(e: DragEvent, cat: Category) {
activeDragKind = "tab"; dragTabId = cat.id;
function onTabDragStart(e: DragEvent, id: string) {
activeDragKind = "tab"; dragTabId = id;
e.dataTransfer!.effectAllowed = "move";
e.dataTransfer!.setData(DT_TAB, String(cat.id));
e.dataTransfer!.setData("text/plain", `tab:${cat.id}`);
e.dataTransfer!.setData(DT_TAB, id);
e.dataTransfer!.setData("text/plain", `tab:${id}`);
}
function onTabDragOver(e: DragEvent, cat: Category, idx: number) {
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return;
function onTabDragOver(e: DragEvent, id: string, idx: number) {
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === id) return;
e.preventDefault(); e.dataTransfer!.dropEffect = "move";
dragOverTabId = cat.id;
dragOverTabId = id;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
}
function onTabDragLeave() { dragOverTabId = null; }
async function onTabDrop(e: DragEvent, dropCat: Category) {
async function onTabDrop(e: DragEvent, dropId: string) {
e.preventDefault(); dragOverTabId = null;
const insertAt = dragInsertIdx;
dragInsertIdx = -1;
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
const dragId = dragTabId; dragTabId = null; activeDragKind = null;
const dragStrId = String(dragId);
const tabs = [...visibleTabIds];
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropId) { dragTabId = null; return; }
const dragStrId = dragTabId; dragTabId = null; activeDragKind = null;
const tabs = [...allTabIds];
const fromIdx = tabs.indexOf(dragStrId);
if (fromIdx < 0) return;
const dropIdx = tabs.indexOf(dropId);
if (fromIdx < 0 || dropIdx < 0) return;
const visibleDrop = visibleTabIds[insertAt] ?? null;
const destIdx = visibleDrop ? tabs.indexOf(visibleDrop) : tabs.length;
tabs.splice(fromIdx, 1);
const dest = Math.max(0, Math.min(insertAt > fromIdx ? insertAt - 1 : insertAt, tabs.length));
tabs.splice(dest, 0, dragStrId);
const adjustedDest = Math.max(0, Math.min(destIdx > fromIdx ? destIdx - 1 : destIdx, tabs.length));
tabs.splice(adjustedDest, 0, dragStrId);
updateSettings({ libraryPinnedTabOrder: tabs });
const catIds = tabs.filter(id => id !== "library" && id !== "downloaded");
const zeroCat = store.categories.filter(c => c.id === 0);
const reordered = catIds.map((id, i) => { const c = store.categories.find(x => String(x.id) === id)!; return { ...c, order: i + 1 }; });
setCategories([...zeroCat, ...reordered]);
const serverPos = catIds.indexOf(dragStrId) + 1;
try {
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: serverPos });
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
const dragIsBuiltin = dragStrId === "library" || dragStrId === "downloaded";
if (!dragIsBuiltin) {
const serverPos = catIds.indexOf(dragStrId) + 1;
try {
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: Number(dragStrId), position: serverPos });
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
}
}
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; }
@@ -24,8 +24,8 @@
search: string;
activeDragKind: "tab" | null;
dragInsertIdx: number;
dragTabId: number | null;
dragOverTabId: number | null;
dragTabId: string | null;
dragOverTabId: string | null;
sortPanelOpen: boolean;
filterPanelOpen: boolean;
tabsEl: HTMLDivElement;
@@ -39,10 +39,10 @@
onSortPanelToggle: () => void;
onFilterPanelToggle: () => void;
onOpenDownloadsFolder: () => void;
onTabDragStart: (e: DragEvent, cat: Category) => void;
onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void;
onTabDragStart: (e: DragEvent, id: string) => void;
onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
onTabDragLeave: () => void;
onTabDrop: (e: DragEvent, cat: Category) => void;
onTabDrop: (e: DragEvent, id: string) => void;
onTabDragEnd: () => void;
}
@@ -100,20 +100,23 @@
{#each visibleTabIds as id, idx}
{@const cat = visibleCategories.find(c => String(c.id) === id)}
{#if id === "library" || id === "downloaded" || cat}
{@const isBuiltin = id === "library" || id === "downloaded"}
{@const isCompleted = cat && id === String(completedCatId)}
{@const isDraggable = true}
{#if activeDragKind === "tab" && dragInsertIdx === idx}
<div class="tab-insert-bar" aria-hidden="true"></div>
{/if}
<button
class="tab"
class:active={tab === id}
class:tab-dragging={cat && dragTabId === cat.id}
draggable={!!cat && id !== String(completedCatId)}
class:tab-dragging={isDraggable && dragTabId === id}
draggable={isDraggable}
onclick={() => onTabChange(id)}
ondragstart={cat && id !== String(completedCatId) ? (e) => onTabDragStart(e, cat) : undefined}
ondragover={cat && id !== String(completedCatId) ? (e) => onTabDragOver(e, cat, idx) : undefined}
ondragleave={cat && id !== String(completedCatId) ? onTabDragLeave : undefined}
ondrop={cat && id !== String(completedCatId) ? (e) => onTabDrop(e, cat) : undefined}
ondragend={cat && id !== String(completedCatId) ? onTabDragEnd : undefined}
ondragstart={isDraggable ? (e) => onTabDragStart(e, id) : undefined}
ondragover={isDraggable ? (e) => onTabDragOver(e, id, idx) : undefined}
ondragleave={isDraggable ? onTabDragLeave : undefined}
ondrop={isDraggable ? (e) => onTabDrop(e, id) : undefined}
ondragend={isDraggable ? onTabDragEnd : undefined}
>
{#if id === "library"}<Books size={11} weight="bold" />
{:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
@@ -10,7 +10,7 @@
import { GET_CHAPTERS } from "@api/queries/chapters";
import { UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations/manga";
import { FETCH_CHAPTERS, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
import { ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
import { cache, CACHE_KEYS, recordSourceAccess } from "@core/cache";
import {
store, addToast, openReader, setActiveManga,
@@ -321,7 +321,7 @@
async function enqueue(ch: Chapter, e: MouseEvent) {
e.stopPropagation();
enqueueing = new Set(enqueueing).add(ch.id);
await gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error);
await downloadStore.enqueue(ch.id);
addToast({ kind: "download", title: "Download queued", body: ch.name });
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
if (store.activeManga) reloadChapters(store.activeManga.id);
@@ -329,7 +329,10 @@
async function enqueueMultiple(chapterIds: number[]) {
if (!chapterIds.length) return;
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
for (const id of chapterIds) {
const allowed = await downloadStore.enqueue(id);
if (!allowed) return;
}
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
if (store.activeManga) reloadChapters(store.activeManga.id);
}
@@ -461,7 +464,7 @@
{ label: "Mark below as read", icon: ArrowFatLinesDown, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
{ label: "Mark below as unread", icon: ArrowFatLineDown, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
{ separator: true },
{ label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : Download, danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error) },
{ label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : Download, danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : downloadStore.enqueue(ch.id) },
{ separator: true },
{ label: "Download next 5 from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.isDownloaded).map(c => c.id)) },
{ label: "Download all from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) },
@@ -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<boolean | null>(null);
let helloBusy = $state(false);
let authStatus = $state<UiAuthDebugStatus | null>(null);
let authRefreshBusy = $state(false);
$effect(() => {
import("@tauri-apps/api/app").then(m => m.getVersion()).then(v => appVersion = v).catch(() => {});
refreshPerfMetrics();
refreshAuthStatus();
invoke<boolean>("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 @@
<div class="s-row">
<div class="s-row-info"><span class="s-label">Fire test toast</span><span class="s-desc">Triggers each kind with realistic content</span></div>
<div class="s-dev-pill-group">
{#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)}
<button class="s-dev-pill {kind}" onclick={() => addToast({
kind,
title: kind === "success" ? "Library updated" : kind === "error" ? "Could not reach server" : kind === "info" ? "Already up to date" : "Download complete",
@@ -122,7 +179,7 @@
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-3)">
<span class="s-desc">3D tilt cards — hover to preview</span>
<div style="display:flex;gap:var(--sp-3)">
{#each [{ title: "Berserk", sub: "Ch. 372", hue: "265" },{ title: "Vinland Saga", sub: "Ch. 208", hue: "200" },{ title: "Dungeon Meshi", sub: "Ch. 97", hue: "140" }] as card}
{#each [{ title: "Berserk", sub: "Ch. 372", hue: "265" },{ title: "Vinland Saga", sub: "Ch. 208", hue: "200" },{ title: "Dungeon Meshi", sub: "Ch. 97", hue: "140" }] as card (card.title)}
<ThreeDCard>
<div style="width:72px;height:100px;border-radius:var(--radius-md);background:hsl({card.hue},40%,18%);display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:var(--sp-2)">
<span style="font-size:var(--text-2xs);color:var(--text-secondary);text-align:center;line-height:1.2">{card.title}</span>
@@ -159,4 +216,32 @@
</div>
</div>
<div class="s-section">
<p class="s-section-title">Auth (UI Login)</p>
<div class="s-section-body">
<div class="s-dev-grid">
<span class="s-dev-key">Mode</span> <span class="s-dev-val">{authStatus?.mode ?? "—"}</span>
<span class="s-dev-key">Session</span> <span class="s-dev-val">{authStatus?.hasSession ? "present" : "none"}</span>
<span class="s-dev-key">Refresh token</span> <span class="s-dev-val">{authStatus?.hasRefreshToken ? "present" : "none"}</span>
<span class="s-dev-key">Access expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.accessExpiresInMs ?? null)}</span>
<span class="s-dev-key">Refresh expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.refreshExpiresInMs ?? null)}</span>
<span class="s-dev-key">Refresh window</span> <span class="s-dev-val">{authStatus?.shouldRefreshSoon ? "open" : "not yet"}</span>
<span class="s-dev-key">Refresh in-flight</span> <span class="s-dev-val">{authStatus?.refreshInFlight ? "yes" : "no"}</span>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-desc">Access expiry at: {fmtTime(authStatus?.accessExpiresAt ?? null)}</span>
<span class="s-desc">Refresh expiry at: {fmtTime(authStatus?.refreshExpiresAt ?? null)}</span>
<span class="s-desc">Skew window: {Math.round((authStatus?.skewMs ?? 0) / 1000)}s before expiry</span>
</div>
<div class="s-btn-row">
<button class="s-btn" onclick={refreshAuthStatus}>Refresh</button>
<button class="s-btn s-btn-accent" onclick={forceTokenRefresh} disabled={authRefreshBusy || authStatus?.mode !== "UI_LOGIN" || !authStatus?.hasRefreshToken}>
{authRefreshBusy ? "Refreshing…" : "Force refresh"}
</button>
</div>
</div>
</div>
</div>
</div>
@@ -6,13 +6,15 @@
import type { Category } from "@types";
import { store, updateSettings, setCategories } from "@store/state.svelte";
const completedCat = $derived(store.categories.find(c => c.name === "Completed" && c.id !== 0) ?? null);
const completedId = $derived(completedCat ? String(completedCat.id) : null);
const sortedCatIds = $derived(store.categories.filter(c => c.id !== 0).map(c => String(c.id)));
const orderedCatIds = $derived.by(() => {
const order = store.settings.libraryPinnedTabOrder ?? [];
const known = new Set(sortedCatIds);
return [...order.filter(id => known.has(id)), ...sortedCatIds.filter(id => !order.includes(id))];
const completedCat = $derived(store.categories.find(c => c.name === "Completed" && c.id !== 0) ?? null);
const completedId = $derived(completedCat ? String(completedCat.id) : null);
const sortedCatIds = $derived(store.categories.filter(c => c.id !== 0).map(c => String(c.id)));
const orderedAllIds = $derived.by(() => {
const order = store.settings.libraryPinnedTabOrder ?? [];
const allIds = ["library", "downloaded", ...sortedCatIds];
const known = new Set(allIds);
return [...new Set([...order.filter(id => known.has(id)), ...allIds])];
});
let catsLoading = $state(false);
@@ -21,9 +23,9 @@
let editingId = $state<number | null>(null);
let editingName = $state("");
let dragId = $state<number | null>(null);
let dragOverId = $state<number | null>(null);
let dropPosition = $state<"above" | "below" | null>(null);
let dragStrId = $state<string | null>(null);
let dragOverStrId = $state<string | null>(null);
let dropPosition = $state<"above" | "below" | null>(null);
function isHidden(id: string) {
return (store.settings.hiddenLibraryTabs ?? []).includes(id);
@@ -92,57 +94,69 @@
}
}
async function applyReorder(fromId: number, toId: number) {
const zeroCat = store.categories.filter(c => c.id === 0);
const sortable = store.categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
const fromIdx = sortable.findIndex(c => c.id === fromId);
const toIdx = sortable.findIndex(c => c.id === toId);
function applyReorder(fromStrId: string, toStrId: string) {
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
const allIds = ["library", "downloaded", ...catIds];
const current = store.settings.libraryPinnedTabOrder ?? [];
const base = [...new Set([...current.filter(id => allIds.includes(id)), ...allIds])];
const fromIdx = base.indexOf(fromStrId);
const toIdx = base.indexOf(toStrId);
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return;
const reordered = [...sortable];
const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, moved);
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
base.splice(fromIdx, 1);
base.splice(toIdx, 0, fromStrId);
updateSettings({ libraryPinnedTabOrder: base });
const catIds = reordered.map(c => String(c.id));
updateSettings({ libraryPinnedTabOrder: ["library", "downloaded", ...catIds] });
try {
const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: fromId, position: toIdx + 1 });
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
setCategories([
...zeroCat,
...updated.sort((a, b) => a.order - b.order).map(fresh => {
const existing = store.categories.find(c => c.id === fresh.id);
return existing ? { ...existing, ...fresh } : fresh;
}),
]);
} catch (e: any) {
catsError = e?.message ?? "Failed to reorder";
await loadCategories();
const fromNumId = Number(fromStrId);
if (!isNaN(fromNumId) && fromStrId !== "library" && fromStrId !== "downloaded") {
const zeroCat = store.categories.filter(c => c.id === 0);
const sortable = store.categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
const sFromIdx = sortable.findIndex(c => c.id === fromNumId);
const sToIdx = sortable.findIndex(c => String(c.id) === toStrId);
if (sFromIdx >= 0 && sToIdx >= 0 && sFromIdx !== sToIdx) {
const reordered = [...sortable];
const [moved] = reordered.splice(sFromIdx, 1);
reordered.splice(sToIdx, 0, moved);
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: fromNumId, position: sToIdx + 1 })
.then(res => {
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
setCategories([
...zeroCat,
...updated.sort((a, b) => a.order - b.order).map(fresh => {
const existing = store.categories.find(c => c.id === fresh.id);
return existing ? { ...existing, ...fresh } : fresh;
}),
]);
})
.catch(async (e: any) => {
catsError = e?.message ?? "Failed to reorder";
await loadCategories();
});
}
}
}
function onDragStart(e: DragEvent, id: number) {
dragId = id;
if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); }
function onDragStart(e: DragEvent, id: string) {
dragStrId = id;
if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", id); }
}
function onDragOver(e: DragEvent, id: number) {
function onDragOver(e: DragEvent, id: string) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (dragId === id) return;
dragOverId = id;
if (dragStrId === id) return;
dragOverStrId = id;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
dropPosition = e.clientY < rect.top + rect.height / 2 ? "above" : "below";
}
function onDrop(e: DragEvent, id: number) {
function onDrop(e: DragEvent, id: string) {
e.preventDefault();
if (dragId !== null && dragId !== id) applyReorder(dragId, id);
dragId = null; dragOverId = null; dropPosition = null;
if (dragStrId !== null && dragStrId !== id) applyReorder(dragStrId, id);
dragStrId = null; dragOverStrId = null; dropPosition = null;
}
function onDragEnd() { dragId = null; dragOverId = null; dropPosition = null; }
function onDragEnd() { dragStrId = null; dragOverStrId = null; dropPosition = null; }
function focusInput(node: HTMLElement) { node.focus(); }
@@ -166,96 +180,94 @@
{#if catsLoading}
<p class="s-empty">Loading folders…</p>
{:else}
<div class="s-folder-row s-folder-row-static">
<span class="s-folder-icon-static"><BookmarkSimple size={14} weight="light" /></span>
<span class="s-folder-name s-folder-name-static">Saved</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={isHidden("library")} onclick={() => toggleHidden("library")} title={isHidden("library") ? "Show tab in library" : "Hide tab from library"}>
{#if isHidden("library")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
</div>
<div class="s-folder-list" class:is-dragging={dragStrId !== null}>
{#each orderedAllIds as id}
{@const isBuiltin = id === "library" || id === "downloaded"}
{@const isCompleted = id === completedId}
{@const cat = isBuiltin ? null : (store.categories.find(c => String(c.id) === id) ?? null)}
{@const hidden = isHidden(id)}
<div class="s-folder-row s-folder-row-static">
<span class="s-folder-icon-static"><DownloadSimple size={14} weight="light" /></span>
<span class="s-folder-name s-folder-name-static">Downloaded</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={isHidden("downloaded")} onclick={() => toggleHidden("downloaded")} title={isHidden("downloaded") ? "Show tab in library" : "Hide tab from library"}>
{#if isHidden("downloaded")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
</div>
{#if completedCat}
<div class="s-folder-row s-folder-row-static">
<span class="s-folder-icon-static"><CheckSquare size={14} weight="light" /></span>
<span class="s-folder-name s-folder-name-static">{completedCat.name}</span>
<span class="s-folder-count">{completedCat.mangas?.nodes.length ?? 0} manga</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={isHidden(String(completedCat.id))} onclick={() => toggleHidden(String(completedCat!.id))} title={isHidden(String(completedCat.id)) ? "Show tab in library" : "Hide tab from library"}>
{#if isHidden(String(completedCat.id))}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
</div>
{/if}
<div class="s-folder-divider" aria-hidden="true"></div>
<div class="s-folder-list" class:is-dragging={dragId !== null}>
{#each orderedCatIds.filter(id => id !== completedId) as id}
{@const cat = store.categories.find(c => String(c.id) === id) ?? null}
{@const hidden = isHidden(id)}
{#if cat}
{#if isBuiltin || cat}
<div
class="s-folder-row"
class:dragging={dragId === cat.id}
class:drop-above={dragOverId === cat.id && dragId !== cat.id && dropPosition === "above"}
class:drop-below={dragOverId === cat.id && dragId !== cat.id && dropPosition === "below"}
ondragover={(e) => onDragOver(e, cat.id)}
ondrop={(e) => onDrop(e, cat.id)}
ondragleave={() => { if (dragOverId === cat.id) { dragOverId = null; dropPosition = null; } }}
class:dragging={dragStrId === id}
class:drop-above={dragOverStrId === id && dragStrId !== id && dropPosition === "above"}
class:drop-below={dragOverStrId === id && dragStrId !== id && dropPosition === "below"}
draggable="true"
ondragstart={(e) => onDragStart(e, id)}
ondragover={(e) => onDragOver(e, id)}
ondragleave={() => { if (dragOverStrId === id) { dragOverStrId = null; dropPosition = null; } }}
ondrop={(e) => onDrop(e, id)}
ondragend={onDragEnd}
>
{#if editingId === cat.id}
<input class="s-input full" bind:value={editingName}
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }}
onblur={commitEdit} use:focusInput />
<button class="s-btn-icon" onclick={commitEdit} title="Save"></button>
{:else}
<div class="s-folder-identity" draggable="true"
ondragstart={(e) => onDragStart(e, cat.id)}
ondragend={onDragEnd}>
<span class="s-folder-icon">
<FolderSimple size={14} weight="light" />
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name); }} title="Click to rename">{cat.name}</span>
</div>
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
{#if isCompleted}
<span class="s-folder-icon">
<CheckSquare size={14} weight="light" />
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name">{cat?.name ?? "Completed"}</span>
<span class="s-folder-count">{cat?.mangas?.nodes.length ?? 0} manga</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:active={(store.settings.defaultLibraryCategoryId ?? null) === cat.id} onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })} title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder"}>
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
</button>
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show in library" : "Hide from library"}>
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show tab in library" : "Hide tab from library"}>
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon" class:active={cat.includeInUpdate !== false} class:inactive={cat.includeInUpdate === false} onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")} title={cat.includeInUpdate !== false ? "Included in updates — click to exclude" : "Excluded from updates — click to include"}>
{#if cat.includeInUpdate !== false}<ArrowsClockwise size={13} weight="bold" />{:else}<ArrowsCounterClockwise size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon" class:active={cat.includeInDownload !== false} class:inactive={cat.includeInDownload === false} onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")} title={cat.includeInDownload !== false ? "Included in auto-downloads — click to exclude" : "Excluded from auto-downloads — click to include"}>
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? "bold" : "light"} />
</button>
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete folder">
<Trash size={12} weight="light" />
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
{:else if isBuiltin}
<span class="s-folder-icon">
{#if id === "library"}<BookmarkSimple size={14} weight="light" />{:else}<DownloadSimple size={14} weight="light" />{/if}
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name">{id === "library" ? "Saved" : "Downloaded"}</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show tab in library" : "Hide tab from library"}>
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
</div>
{:else if cat}
{#if editingId === cat.id}
<input class="s-input full" bind:value={editingName}
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }}
onblur={commitEdit} use:focusInput />
<button class="s-btn-icon" onclick={commitEdit} title="Save"></button>
{:else}
<div class="s-folder-identity" draggable="true"
ondragstart={(e) => onDragStart(e, id)}
ondragend={onDragEnd}>
<span class="s-folder-icon">
<FolderSimple size={14} weight="light" />
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name); }} title="Click to rename">{cat.name}</span>
</div>
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:active={(store.settings.defaultLibraryCategoryId ?? null) === cat.id} onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })} title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder"}>
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
</button>
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show in library" : "Hide from library"}>
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon" class:active={cat.includeInUpdate !== false} class:inactive={cat.includeInUpdate === false} onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")} title={cat.includeInUpdate !== false ? "Included in updates — click to exclude" : "Excluded from updates — click to include"}>
{#if cat.includeInUpdate !== false}<ArrowsClockwise size={13} weight="bold" />{:else}<ArrowsCounterClockwise size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon" class:active={cat.includeInDownload !== false} class:inactive={cat.includeInDownload === false} onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")} title={cat.includeInDownload !== false ? "Included in auto-downloads — click to exclude" : "Excluded from auto-downloads — click to include"}>
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? "bold" : "light"} />
</button>
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete folder">
<Trash size={12} weight="light" />
</button>
</div>
{/if}
{/if}
</div>
{/if}
@@ -314,28 +326,24 @@
.s-folder-row.drop-above::before { top: -1px; }
.s-folder-row.drop-below::after { bottom: -1px; }
.s-folder-identity {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-faint);
flex-shrink: 0;
overflow: hidden;
cursor: grab;
}
.s-folder-row-static {
cursor: default;
}
.s-folder-icon-static {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--text-faint);
color: var(--text-primary);
width: 14px;
}
.s-folder-identity {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
flex-shrink: 0;
overflow: hidden;
cursor: grab;
}
.s-folder-icon {
display: grid;
flex-shrink: 0;
@@ -371,14 +379,6 @@
text-underline-offset: 3px;
}
.s-folder-name-static {
cursor: default;
color: var(--text-secondary);
}
.s-folder-name-static:hover {
text-decoration: none;
}
.s-folder-actions {
display: flex;
@@ -400,12 +400,6 @@
margin-left: 6px;
}
.s-folder-divider {
height: 1px;
background: var(--border-dim);
margin: 2px 0;
}
.s-btn-icon.active {
color: var(--accent, #6c8ef5);
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { store, updateSettings } from "@store/state.svelte";
import { gql } from "@api/client";
import { authSession } from "@core/auth";
import { authSession, loginUI, logout } from "@core/auth";
import { GET_SERVER_SECURITY } from "@api/queries/extensions";
import { SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "@api/mutations/extensions";
@@ -33,72 +33,85 @@
let flareTtl = $state(store.settings.flareSolverrSessionTtl ?? 15);
let flareFallback = $state(store.settings.flareSolverrAsResponseFallback ?? false);
function normalizeAuthMode(mode: string): "NONE" | "BASIC_AUTH" | "UI_LOGIN" {
if (mode === "BASIC_AUTH" || mode === "UI_LOGIN" || mode === "NONE") return mode;
return "NONE";
}
function showSaved(key: string) {
secSaved = key; secError = null;
setTimeout(() => { if (secSaved === key) secSaved = null; }, 2000);
}
$effect(() => {
if (!secLoaded) { secLoaded = true; loadServerSecurity(); }
if (!secLoaded) { secLoaded = true; authSession.clearTokens(); loadServerSecurity(); }
});
async function loadServerSecurity() {
try {
const res = await gql<{ settings: {
authMode: string; authUsername: string;
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string;
socksProxyVersion: number; socksProxyUsername: string;
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number;
flareSolverrSessionName: string; flareSolverrSessionTtl: number;
flareSolverrAsResponseFallback: boolean;
}}>(GET_SERVER_SECURITY);
const s = res.settings;
authMode = store.settings.serverAuthMode ?? "NONE";
authUsername = s.authUsername || store.settings.serverAuthUser || "";
updateSettings({ serverAuthUser: authUsername });
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
socksUsername = s.socksProxyUsername;
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl;
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName;
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback;
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
});
} catch {}
}
try {
const res = await gql<{ settings: {
authMode: string; authUsername: string;
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string;
socksProxyVersion: number; socksProxyUsername: string;
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number;
flareSolverrSessionName: string; flareSolverrSessionTtl: number;
flareSolverrAsResponseFallback: boolean;
}}>(GET_SERVER_SECURITY);
const s = res.settings;
const serverMode = normalizeAuthMode(s.authMode);
if (serverMode !== "UI_LOGIN") authSession.clearTokens();
authMode = serverMode;
authUsername = s.authUsername || "";
updateSettings({ serverAuthMode: serverMode, serverAuthUser: authUsername });
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
socksUsername = s.socksProxyUsername;
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl;
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName;
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback;
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
});
} catch {}
}
async function saveAuth() {
if ((authMode === "BASIC_AUTH" || authMode === "UI_LOGIN") && (!authUsername.trim() || !authPassword.trim())) {
if (authMode === "NONE") { await clearAuth(); return; }
if (!authUsername.trim() || !authPassword.trim()) {
secError = "Username and password are required"; return;
}
secLoading = true; secError = null;
const prev = { mode: store.settings.serverAuthMode, user: store.settings.serverAuthUser, pass: store.settings.serverAuthPass };
try {
const newUser = authMode !== "NONE" ? authUsername.trim() : "";
const newPass = authMode !== "NONE" ? authPassword.trim() : "";
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass });
const newUser = authUsername.trim();
const newPass = authPassword.trim();
authSession.clearTokens();
if (authMode === "UI_LOGIN") {
authSession.clearTokens();
await loginUI(newUser, newPass);
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: newUser, serverAuthPass: "" });
} else if (authMode === "BASIC_AUTH") {
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: newUser, serverAuthPass: newPass });
} else {
authSession.clearTokens();
updateSettings({ serverAuthMode: "NONE", serverAuthUser: "", serverAuthPass: "" });
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: newUser, serverAuthPass: newPass });
}
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass });
authPassword = "";
showSaved("auth");
} catch (e: any) {
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass });
secError = e?.message ?? "Failed to save authentication settings";
const msg = e?.message ?? "Failed to save authentication settings";
const authMismatch = /unauthorized|unauthenticated|authentication|401/i.test(msg);
if (!authMismatch) {
authSession.clearTokens();
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass });
}
secError = authMismatch
? "Saved local auth settings, but the server rejected the update. Verify your new credentials with the current server configuration."
: msg;
} finally { secLoading = false; }
}
@@ -223,7 +236,7 @@
</button>
{/if}
<button class="s-btn s-btn-accent" onclick={saveAuth}
disabled={secLoading || (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim()))}>
disabled={secLoading || ((authMode === "BASIC_AUTH" || authMode === "UI_LOGIN") && (!authUsername.trim() || !authPassword.trim()))}>
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthMode === "BASIC_AUTH" ? "Update" : authMode === "NONE" ? "Save" : "Enable"}
</button>
</div>