diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e5287dc..1be1efd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,14 +11,6 @@ use walkdir::WalkDir; struct ServerState(Mutex>); -#[derive(Default, Clone)] -struct AuthCredentials { - user: String, - pass: String, -} - -struct AuthState(Mutex); - #[derive(Serialize)] pub struct StorageInfo { manga_bytes: u64, @@ -577,13 +569,6 @@ fn restart_app(app: tauri::AppHandle) { tauri::process::restart(&app.env()); } -#[tauri::command] -fn set_auth_credentials(app: tauri::AppHandle, user: String, pass: String) { - let state = app.state::(); - let mut creds = state.0.lock().unwrap(); - creds.user = user; - creds.pass = pass; -} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -595,71 +580,6 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(ServerState(Mutex::new(None))) - .manage(AuthState(Mutex::new(AuthCredentials::default()))) - .register_asynchronous_uri_scheme_protocol("moku", |ctx, request, responder| { - use tauri_plugin_http::reqwest; - - // moku://proxy/ - let raw_uri = request.uri().to_string(); - let encoded = raw_uri - .split_once("://") - .and_then(|(_, rest)| rest.split_once('/')) - .map(|(_, after)| after.to_string()) - .unwrap_or_default(); - let target_url = match urlencoding::decode(&encoded) { - Ok(u) => u.into_owned(), - Err(_) => encoded, - }; - - eprintln!("[moku] target_url={:?}", target_url); - - let auth_state = ctx.app_handle().state::(); - let creds = auth_state.0.lock().unwrap().clone(); - - tokio::spawn(async move { - let result: Result<(Vec, String), String> = async { - let client = reqwest::Client::builder() - .build() - .map_err(|e| e.to_string())?; - - let mut req = client.get(&target_url); - if !creds.user.is_empty() && !creds.pass.is_empty() { - req = req.basic_auth(&creds.user, Some(&creds.pass)); - } - - let resp = req.send().await.map_err(|e| e.to_string())?; - let content_type = resp - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("image/jpeg") - .to_string(); - let bytes = resp.bytes().await.map_err(|e| e.to_string())?; - Ok((bytes.to_vec(), content_type)) - }.await; - - match result { - Ok((bytes, content_type)) => { - responder.respond( - tauri::http::Response::builder() - .header("Content-Type", content_type) - .header("Access-Control-Allow-Origin", "*") - .body(bytes) - .unwrap_or_else(|_| tauri::http::Response::builder().status(500).body(vec![]).unwrap()) - ); - } - Err(e) => { - eprintln!("[moku] error: {}", e); - responder.respond( - tauri::http::Response::builder() - .status(502) - .body(vec![]) - .unwrap() - ); - } - } - }); - }) .invoke_handler(tauri::generate_handler![ get_storage_info, get_default_downloads_path, @@ -672,7 +592,6 @@ pub fn run() { list_releases, download_and_install_update, restart_app, - set_auth_credentials, ]) .setup(|_app| Ok(())) .on_window_event(|window, event| { diff --git a/src/App.svelte b/src/App.svelte index 81dcdbb..650cd75 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -221,6 +221,15 @@ if (result === "auth_required") { serverProbeOk = true; + const savedUser = store.settings.serverAuthUser?.trim() ?? ""; + const savedPass = store.settings.serverAuthPass?.trim() ?? ""; + if (savedUser && savedPass) { + try { + await loginBasic(savedUser, savedPass); + loginRequired = false; + return; + } catch {} + } loginRequired = true; return; } diff --git a/src/components/reader/Reader.svelte b/src/components/reader/Reader.svelte index dced992..0fc973c 100644 --- a/src/components/reader/Reader.svelte +++ b/src/components/reader/Reader.svelte @@ -7,7 +7,7 @@ Bookmark, BookOpen, MonitorPlay, MapPin, Check, } from "phosphor-svelte"; import { gql, thumbUrl, plainThumbUrl } from "../../lib/client"; - import { getBlobUrl } from "../../lib/imageCache"; + import { getBlobUrl, preloadBlobUrls } from "../../lib/imageCache"; import { store as appStore } from "../../store/state.svelte"; import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries"; import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, addMarker, removeMarker, updateMarker } from "../../store/state.svelte"; @@ -36,29 +36,29 @@ const pageCache = new Map(); const inflight = new Map>(); + const isAuth = () => (appStore.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH"; + + function resolveUrl(url: string, priority = 0): Promise { + return isAuth() ? getBlobUrl(url, priority) : Promise.resolve(url); + } + function fetchPages(chapterId: number, signal?: AbortSignal): Promise { const cached = pageCache.get(chapterId); if (cached) return Promise.resolve(cached); if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); + if (!inflight.has(chapterId)) { const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId }) - .then(async d => { - const mode = appStore.settings.serverAuthMode ?? "NONE"; - const rawUrls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p)); - let urls: string[]; - if (mode === "BASIC_AUTH") { - // Pre-fetch all pages via tauri-plugin-http (bypasses CORS + auth headers) - // in parallel so they're cached and ready to display immediately - urls = await Promise.all(rawUrls.map(u => getBlobUrl(u).catch(() => u))); - } else { - urls = rawUrls.map(u => thumbUrl(u)); - } + .then(d => { + const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p)); + if (isAuth()) preloadBlobUrls(urls, urls.length); pageCache.set(chapterId, urls); return urls; }) .finally(() => inflight.delete(chapterId)); inflight.set(chapterId, p); } + const base = inflight.get(chapterId)!; if (!signal) return base; return new Promise((resolve, reject) => { @@ -68,20 +68,19 @@ } const aspectCache = new Map(); - function preloadImage(url: string) { new Image().src = url; } function measureAspect(url: string): Promise { if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!); - return new Promise(res => { + return resolveUrl(url).then(src => new Promise(res => { const img = new Image(); - img.onload = () => { - const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; - aspectCache.set(url, r); - res(r); - }; + img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); }; img.onerror = () => res(0.67); - img.src = url; - }); + img.src = src; + })); + } + + function preloadImage(url: string) { + resolveUrl(url).then(src => { new Image().src = src; }).catch(() => {}); } interface StripChapter { chapterId: number; chapterName: string; urls: string[]; } @@ -461,9 +460,22 @@ $effect(() => { const ahead = store.settings.preloadPages ?? 3; - for (let i = 1; i <= ahead; i++) { const url = store.pageUrls[store.pageNumber - 1 + i]; if (url) preloadImage(url); } - const behind = store.pageUrls[store.pageNumber - 2]; - if (behind) preloadImage(behind); + const current = store.pageUrls[store.pageNumber - 1]; + if (!current) return; + if (isAuth()) { + getBlobUrl(current, 999); + const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[]; + const behind = store.pageUrls[store.pageNumber - 2]; + preloadBlobUrls(upcoming, ahead); + if (behind) preloadBlobUrls([behind], 0); + } else { + for (let i = 1; i <= ahead; i++) { + const url = store.pageUrls[store.pageNumber - 1 + i]; + if (url) preloadImage(url); + } + const behind = store.pageUrls[store.pageNumber - 2]; + if (behind) preloadImage(behind); + } }); $effect(() => { @@ -959,39 +971,31 @@ {#if style === "longstrip"} {#each stripToRender as chunk} {#each chunk.urls as url, i} - {chunk.chapterName} – Page {i + 1} + {#await resolveUrl(url, chunk.urls.length - i)} + {chunk.chapterName} – Page {i + 1} + {:then src} + {chunk.chapterName} – Page {i + 1} + {/await} {/each} {/each}
{:else if style === "fade" && pageReady} - Page {store.pageNumber} + {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} + Page {store.pageNumber} + {:then src} + Page {store.pageNumber} + {/await} {:else if style === "double" && pageReady} {#if pageGroups.length}
{#each currentGroup as pg, i} - Page {pg} + {#await resolveUrl(store.pageUrls[pg - 1], 999)} + Page {pg} + {:then src} + Page {pg} + {/await} {/each}
{:else} @@ -999,7 +1003,11 @@ {/if} {:else if pageReady} - Page {store.pageNumber} + {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} + Page {store.pageNumber} + {:then src} + Page {store.pageNumber} + {/await} {/if} @@ -1148,13 +1156,6 @@ .marker-popover { position: absolute; top: calc(100% + 8px); right: 0; width: 240px; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 12px 32px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4); z-index: 100; animation: scaleIn 0.1s ease both; transform-origin: top right; } .marker-pop-header { display: flex; align-items: center; justify-content: space-between; } .marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; } - .marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--color-error); opacity: 0.55; transition: opacity var(--t-fast), background var(--t-fast); } - .marker-delete-btn:hover { opacity: 1; background: var(--color-error-bg); } - - .marker-color-row { display: flex; gap: 5px; } - .marker-swatch { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; padding: 6px 4px 5px; border-radius: var(--radius-md); border: 1px solid transparent; background: none; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); } - .marker-swatch:hover { background: var(--bg-raised); } - .marker-swatch-active { background: var(--bg-overlay); border-color: var(--border-strong); } .swatch-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--swatch); box-shadow: 0 0 0 0 var(--swatch); transition: box-shadow var(--t-fast), transform var(--t-fast); flex-shrink: 0; } .marker-swatch:hover .swatch-dot { transform: scale(1.15); } .marker-swatch-active .swatch-dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--swatch) 30%, transparent); transform: scale(1.1); } diff --git a/src/components/shared/Thumbnail.svelte b/src/components/shared/Thumbnail.svelte index 4144f99..85baa34 100644 --- a/src/components/shared/Thumbnail.svelte +++ b/src/components/shared/Thumbnail.svelte @@ -5,38 +5,39 @@ let { src, - alt = "", - class: className = "", - loading = "lazy", - decoding = "async", - onerror = undefined, + alt = "", + class: cls = "", + loading = "lazy", + decoding = "async", + priority = 0, + onerror = undefined, ...rest }: { - src: string; - alt?: string; - class?: string; - loading?: string; + src: string; + alt?: string; + class?: string; + loading?: string; decoding?: string; - onerror?: ((e: Event) => void) | undefined; + priority?: number; + onerror?: ((e: Event) => void) | undefined; [key: string]: any; } = $props(); const isAuth = $derived(store.settings.serverAuthMode === "BASIC_AUTH"); - // Plain URL for non-auth users — fast, no overhead - const plainResolved = $derived(src ? thumbUrl(src) : ""); - - // Blob URL for auth users — fetched with Authorization header let blobUrl = $state(""); $effect(() => { if (!isAuth || !src) { blobUrl = ""; return; } - const fullUrl = plainThumbUrl(src); - getBlobUrl(fullUrl) + getBlobUrl(plainThumbUrl(src), priority) .then(u => { blobUrl = u; }) .catch(() => { blobUrl = ""; }); }); - const resolved = $derived(isAuth ? blobUrl || undefined : plainResolved || undefined); + const resolved = $derived( + isAuth + ? (blobUrl || undefined) + : (src ? thumbUrl(src) : undefined) + ); - + diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 357d810..e5008d1 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -16,27 +16,25 @@ function basicHeader(user: string, pass: string): Record { return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` }; } -function buildRequestInit(init: RequestInit, extraHeaders: Record = {}): RequestInit { - return { - ...init, - credentials: "include", - headers: { ...(init.headers as Record ?? {}), ...extraHeaders }, - }; -} - -export async function fetchAuthenticated( +export function fetchAuthenticated( url: string, init: RequestInit, signal?: AbortSignal, ): Promise { const mode = store.settings.serverAuthMode ?? "NONE"; - const s = store.settings; if (mode === "BASIC_AUTH") { - const user = s.serverAuthUser?.trim() ?? ""; - const pass = s.serverAuthPass?.trim() ?? ""; - const headers = user && pass ? basicHeader(user, pass) : {}; - return fetch(url, buildRequestInit({ ...init, signal }, headers)); + const user = store.settings.serverAuthUser?.trim() ?? ""; + const pass = store.settings.serverAuthPass?.trim() ?? ""; + return fetch(url, { + ...init, + signal, + credentials: "include", + headers: { + ...(init.headers as Record ?? {}), + ...(user && pass ? basicHeader(user, pass) : {}), + }, + }); } return fetch(url, { ...init, signal }); @@ -80,7 +78,6 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport }); if (res.ok) { - if (mode !== "NONE") updateSettings({ serverAuthMode: "NONE" }); return "ok"; } diff --git a/src/lib/client.ts b/src/lib/client.ts index cd16fc2..fd3f94a 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -10,15 +10,12 @@ function getServerUrl(): string { function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; } -// Returns a clean absolute URL with no embedded credentials. export function plainThumbUrl(path: string): string { if (!path) return ""; if (path.startsWith("http")) return path; return `${getServerUrl()}${path}`; } -// Same as plainThumbUrl — credentials are never embedded in URLs. -// Auth users load images via getBlobUrl (imageCache.ts) instead. export function thumbUrl(path: string): string { return plainThumbUrl(path); } diff --git a/src/lib/imageCache.ts b/src/lib/imageCache.ts index 9883944..5cdfb67 100644 --- a/src/lib/imageCache.ts +++ b/src/lib/imageCache.ts @@ -1,49 +1,91 @@ import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { store } from "../store/state.svelte"; -const cache = new Map(); +const cache = new Map(); const inflight = new Map>(); -function getAuthHeaders(): Record { - const mode = store.settings.serverAuthMode; - if (mode === "BASIC_AUTH") { - const user = store.settings.serverAuthUser?.trim() ?? ""; - const pass = store.settings.serverAuthPass?.trim() ?? ""; - if (user && pass) { - return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` }; - } - } - return {}; +const MAX_CONCURRENT = 6; +let active = 0; + +interface QueueEntry { + url: string; + priority: number; + resolve: (v: string) => void; + reject: (e: unknown) => void; } -export async function getBlobUrl(url: string): Promise { - if (!url) return ""; +const queue: QueueEntry[] = []; - const cached = cache.get(url); - if (cached) return cached; +function getAuthHeaders(): Record { + const user = store.settings.serverAuthUser?.trim() ?? ""; + const pass = store.settings.serverAuthPass?.trim() ?? ""; + return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {}; +} - const existing = inflight.get(url); - if (existing) return existing; +async function doFetch(url: string): Promise { + const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() }); + if (!res.ok) throw new Error(`${res.status}`); + const blobUrl = URL.createObjectURL(await res.blob()); + cache.set(url, blobUrl); + return blobUrl; +} - const promise = tauriFetch(url, { - method: "GET", - headers: getAuthHeaders(), - }) - .then(res => { - if (!res.ok) throw new Error(`${res.status}`); - return res.blob(); - }) - .then(blob => { - const blobUrl = URL.createObjectURL(blob); - cache.set(url, blobUrl); - inflight.delete(url); - return blobUrl; - }) - .catch(err => { - inflight.delete(url); - throw err; - }); +function drain() { + while (active < MAX_CONCURRENT && queue.length > 0) { + queue.sort((a, b) => b.priority - a.priority); + const entry = queue.shift()!; + active++; + doFetch(entry.url) + .then(entry.resolve, entry.reject) + .finally(() => { + inflight.delete(entry.url); + active--; + drain(); + }); + } +} +function enqueue(url: string, priority: number): Promise { + const promise = new Promise((resolve, reject) => { + queue.push({ url, priority, resolve, reject }); + }); inflight.set(url, promise); + drain(); return promise; } + +export function getBlobUrl(url: string, priority = 0): Promise { + if (!url) return Promise.resolve(""); + + const cached = cache.get(url); + if (cached) return Promise.resolve(cached); + + const existing = inflight.get(url); + if (existing) { + const entry = queue.find(e => e.url === url); + if (entry && priority > entry.priority) entry.priority = priority; + return existing; + } + + return enqueue(url, priority); +} + +export function preloadBlobUrls(urls: string[], basePriority = 0): void { + urls.forEach((url, i) => { + if (!url || cache.has(url) || inflight.has(url)) return; + enqueue(url, basePriority - i); + }); +} + +export function revokeBlobUrl(url: string): void { + const blob = cache.get(url); + if (blob) { + URL.revokeObjectURL(blob); + cache.delete(url); + } +} + +export function clearBlobCache(): void { + cache.forEach(blob => URL.revokeObjectURL(blob)); + cache.clear(); +}