diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 997687e..edd00fb 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -96,13 +96,43 @@ fn wait_until_deletable(path: &std::path::Path, timeout_secs: u64) -> bool { #[tauri::command] pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> { let window = app.get_webview_window("main").ok_or("no main window")?; - window.clear_all_browsing_data().map_err(|e| e.to_string())?; + + let (tx, rx) = tokio::sync::oneshot::channel::>(); + + window + .with_webview(move |wv| { + #[cfg(target_os = "windows")] + { + use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2_2; + use windows::core::Interface; + let core = wv.controller().CoreWebView2().map_err(|e| e.to_string()); + let result = core.and_then(|c| { + c.cast::() + .map_err(|e| e.to_string()) + }) + .and_then(|c2| { + unsafe { + c2.ClearBrowsingDataAll(None).map_err(|e| e.to_string()) + } + }); + let _ = tx.send(result); + } + #[cfg(not(target_os = "windows"))] + { + let _ = tx.send(Ok(())); + } + }) + .map_err(|e| e.to_string())?; + + rx.await.map_err(|e| e.to_string())??; let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?; if cache_dir.exists() { + wait_until_deletable(&cache_dir, 3); remove_dir_best_effort(&cache_dir); std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?; } + Ok(()) } @@ -135,7 +165,6 @@ pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> { let data_dir = suwayomi_data_dir(); let targets = ["database.mv.db", "extensions", "settings", "logs", "local"]; - // Wait up to 10s for the JVM to release file locks for entry_name in &targets { let p = data_dir.join(entry_name); if p.exists() { diff --git a/src/core/cache/imageCache.ts b/src/core/cache/imageCache.ts index a019bc5..ca990f2 100644 --- a/src/core/cache/imageCache.ts +++ b/src/core/cache/imageCache.ts @@ -5,8 +5,9 @@ import { uiAuth } from "@core/auth"; const cache = new Map(); const inflight = new Map>(); const MAX_CONCURRENT = 6; -let active = 0; +let active = 0; let drainScheduled = false; +let clearing = false; interface QueueEntry { url: string; @@ -34,7 +35,9 @@ function getAuthHeaders(): Record { 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()); + const blob = await res.blob(); + if (clearing) throw new DOMException("Cancelled", "AbortError"); + const blobUrl = URL.createObjectURL(blob); cache.set(url, blobUrl); return blobUrl; } @@ -121,8 +124,10 @@ export function cancelQueuedFetches(): void { } export function clearBlobCache(): void { + clearing = true; cancelQueuedFetches(); cache.forEach(blob => URL.revokeObjectURL(blob)); cache.clear(); inflight.clear(); + clearing = false; } \ No newline at end of file diff --git a/src/core/cache/index.ts b/src/core/cache/index.ts index 8b3d3fe..410c37d 100644 --- a/src/core/cache/index.ts +++ b/src/core/cache/index.ts @@ -1,4 +1,4 @@ export * from './memoryCache'; export * from './pageCache'; export * from './imageCache'; -export * from './queryCache'; +export * from './queryCache'; \ No newline at end of file diff --git a/src/core/cache/memoryCache.ts b/src/core/cache/memoryCache.ts index e69de29..1cc6431 100644 --- a/src/core/cache/memoryCache.ts +++ b/src/core/cache/memoryCache.ts @@ -0,0 +1,44 @@ +interface MemEntry { + value: T; + expiresAt: number; + key: string; +} + +export class MemoryCache { + readonly #cap: number; + readonly #ttl: number; + readonly #map = new Map>(); + + constructor(capacity: number, ttlMs: number) { + this.#cap = capacity; + this.#ttl = ttlMs; + } + + get(key: string): T | undefined { + const entry = this.#map.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { this.#map.delete(key); return undefined; } + this.#map.delete(key); + this.#map.set(key, entry); + return entry.value; + } + + set(key: string, value: T): void { + if (this.#map.has(key)) this.#map.delete(key); + else if (this.#map.size >= this.#cap) this.#map.delete(this.#map.keys().next().value!); + this.#map.set(key, { value, expiresAt: Date.now() + this.#ttl, key }); + } + + has(key: string): boolean { + const entry = this.#map.get(key); + if (!entry) return false; + if (Date.now() > entry.expiresAt) { this.#map.delete(key); return false; } + return true; + } + + delete(key: string): void { this.#map.delete(key); } + + clear(): void { this.#map.clear(); } + + get size(): number { return this.#map.size; } +} \ No newline at end of file diff --git a/src/core/cache/pageCache.ts b/src/core/cache/pageCache.ts index 4647849..46f61f3 100644 --- a/src/core/cache/pageCache.ts +++ b/src/core/cache/pageCache.ts @@ -62,10 +62,7 @@ export function measureAspect(url: string, useBlob: boolean): Promise { } export function preloadImage(url: string, useBlob: boolean): void { - if (useBlob) { - preloadBlobUrls([url], 0); - return; - } + if (useBlob) { preloadBlobUrls([url], 0); return; } resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {}); } diff --git a/src/core/cache/queryCache.ts b/src/core/cache/queryCache.ts index b2fbd08..4eae1cf 100644 --- a/src/core/cache/queryCache.ts +++ b/src/core/cache/queryCache.ts @@ -1,11 +1,14 @@ interface Entry { promise: Promise; fetchedAt: number; + fetcher?: () => Promise; + ttl?: number; } const store = new Map>(); const subs = new Map void>>(); -const groups = new Map>(); +const keyToGroups = new Map>(); +const groups = new Map>(); export const DEFAULT_TTL_MS = 5 * 60 * 1_000; @@ -16,6 +19,16 @@ function registerGroups(key: string, group?: string | string[]) { for (const tag of Array.isArray(group) ? group : [group]) { if (!groups.has(tag)) groups.set(tag, new Set()); groups.get(tag)!.add(key); + if (!keyToGroups.has(key)) keyToGroups.set(key, new Set()); + keyToGroups.get(key)!.add(tag); + } +} + +function unregisterKey(key: string) { + const tags = keyToGroups.get(key); + if (tags) { + for (const tag of tags) groups.get(tag)?.delete(key); + keyToGroups.delete(key); } } @@ -27,14 +40,20 @@ export const cache = { if (err?.name !== "AbortError") store.delete(key); return Promise.reject(err); }) as Promise; - store.set(key, { promise, fetchedAt: Date.now() }); + store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise, ttl }); registerGroups(key, group); promise.then(() => notify(key)).catch(() => {}); return promise; }, set(key: string, value: T, group?: string | string[]) { - store.set(key, { promise: Promise.resolve(value), fetchedAt: Date.now() }); + const existing = store.get(key) as Entry | undefined; + store.set(key, { + promise: Promise.resolve(value), + fetchedAt: Date.now(), + fetcher: existing?.fetcher, + ttl: existing?.ttl, + }); registerGroups(key, group); notify(key); }, @@ -43,10 +62,38 @@ export const cache = { const existing = store.get(key) as Entry | undefined; if (!existing) return; const next = existing.promise.then(fn); - store.set(key, { promise: next, fetchedAt: Date.now() }); + store.set(key, { ...existing, promise: next, fetchedAt: Date.now() }); next.then(() => notify(key)).catch(() => {}); }, + refresh(key: string): Promise | undefined { + const existing = store.get(key) as Entry | undefined; + if (!existing?.fetcher) return undefined; + const promise = (existing.fetcher as () => Promise)().catch(err => { + if (err?.name !== "AbortError") store.delete(key); + return Promise.reject(err); + }); + store.set(key, { ...existing, promise: promise as Promise, fetchedAt: Date.now() }); + promise.then(() => notify(key)).catch(() => {}); + return promise; + }, + + refreshGroup(tag: string): void { + const keys = groups.get(tag); + if (!keys) return; + for (const key of [...keys]) { + const existing = store.get(key); + if (existing?.fetcher) { + const promise = existing.fetcher().catch(err => { + if (err?.name !== "AbortError") store.delete(key); + return Promise.reject(err); + }); + store.set(key, { ...existing, promise, fetchedAt: Date.now() }); + promise.then(() => notify(key)).catch(() => {}); + } + } + }, + has(key: string): boolean { return store.has(key); }, ageOf(key: string): number | undefined { @@ -54,18 +101,35 @@ export const cache = { return e ? Date.now() - e.fetchedAt : undefined; }, - clear(key: string) { store.delete(key); notify(key); }, + isStale(key: string): boolean { + const e = store.get(key); + if (!e) return true; + return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS); + }, + + clear(key: string) { + unregisterKey(key); + store.delete(key); + notify(key); + }, clearGroup(tag: string) { const keys = groups.get(tag); if (!keys) return; - for (const key of keys) { store.delete(key); notify(key); } + for (const key of [...keys]) { + keyToGroups.get(key)?.delete(tag); + if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key); + store.delete(key); + notify(key); + } groups.delete(tag); }, clearAll() { const allKeys = [...store.keys()]; - store.clear(); groups.clear(); + store.clear(); + groups.clear(); + keyToGroups.clear(); allKeys.forEach(notify); }, @@ -161,7 +225,9 @@ export function getTopSources(sources: T[]): T[] { } export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise { - cache.clear(CACHE_KEYS.MANGA(mangaId)); + const didRefresh = cache.refresh(CACHE_KEYS.MANGA(mangaId)); + if (!didRefresh) cache.clear(CACHE_KEYS.MANGA(mangaId)); + cache.clear(CACHE_KEYS.CHAPTERS(mangaId)); cache.clear(CACHE_KEYS.LIBRARY); cache.clear(CACHE_KEYS.ALL_MANGA); diff --git a/src/features/settings/sections/StorageSettings.svelte b/src/features/settings/sections/StorageSettings.svelte index bef46b9..380be68 100644 --- a/src/features/settings/sections/StorageSettings.svelte +++ b/src/features/settings/sections/StorageSettings.svelte @@ -13,17 +13,18 @@ import type { BackupEntry } from "@core/persistence/persist"; import { DEFAULT_SETTINGS } from "@types/settings"; import { DEFAULT_READING_STATS } from "@types/history"; + import { clearBlobCache } from "@core/cache/imageCache"; + import { clearPageCache } from "@core/cache/pageCache"; + import { cache as queryCache } from "@core/cache/queryCache"; type ResetState = "idle" | "busy" | "done" | "error"; interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; } let resetItems = $state([ - { key: "moku-cache", label: "Clear Moku cache", desc: "Removes image cache and temporary files stored by Moku.", state: "idle", error: null, confirm: false }, - { key: "suwayomi-cache", label: "Clear Suwayomi cache", desc: "Deletes the Suwayomi cache and KCEF directories inside the data folder.", state: "idle", error: null, confirm: false }, - { key: "server-cache", label: "Clear server image cache", desc: "Removes cached chapter pages and thumbnails stored on the Suwayomi server.", state: "idle", error: null, confirm: false }, - { key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true }, - { key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true }, - { key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true }, + { key: "all-cache", label: "Clear all caches", desc: "Flushes the image blob cache, page cache, query cache, Moku disk cache, Suwayomi disk cache, and server image/thumbnail cache in one pass.", state: "idle", error: null, confirm: false }, + { key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true }, + { key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true }, + { key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true }, ]); let confirming = $state(null); @@ -73,19 +74,24 @@ }); } + async function clearAllCaches(): Promise { + clearBlobCache(); + clearPageCache(); + queryCache.clearAll(); + await Promise.all([ + invoke("clear_moku_cache"), + invoke("clear_suwayomi_cache"), + gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }), + ]); + } + async function runReset(key: string) { confirming = null; patchReset(key, { state: "busy", error: null }); try { switch (key) { - case "moku-cache": - await invoke("clear_moku_cache"); - break; - case "suwayomi-cache": - await invoke("clear_suwayomi_cache"); - break; - case "server-cache": - await gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }); + case "all-cache": + await clearAllCaches(); break; case "reading-history": store.clearHistory();