Fix: Persistent Security State

This commit is contained in:
Youwes09
2026-04-05 00:59:27 -05:00
parent 8005c82654
commit ee708d85d0
7 changed files with 173 additions and 207 deletions
-81
View File
@@ -11,14 +11,6 @@ use walkdir::WalkDir;
struct ServerState(Mutex<Option<CommandChild>>); struct ServerState(Mutex<Option<CommandChild>>);
#[derive(Default, Clone)]
struct AuthCredentials {
user: String,
pass: String,
}
struct AuthState(Mutex<AuthCredentials>);
#[derive(Serialize)] #[derive(Serialize)]
pub struct StorageInfo { pub struct StorageInfo {
manga_bytes: u64, manga_bytes: u64,
@@ -577,13 +569,6 @@ fn restart_app(app: tauri::AppHandle) {
tauri::process::restart(&app.env()); tauri::process::restart(&app.env());
} }
#[tauri::command]
fn set_auth_credentials(app: tauri::AppHandle, user: String, pass: String) {
let state = app.state::<AuthState>();
let mut creds = state.0.lock().unwrap();
creds.user = user;
creds.pass = pass;
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@@ -595,71 +580,6 @@ pub fn run() {
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.manage(ServerState(Mutex::new(None))) .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/<percent-encoded-absolute-url>
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::<AuthState>();
let creds = auth_state.0.lock().unwrap().clone();
tokio::spawn(async move {
let result: Result<(Vec<u8>, 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![ .invoke_handler(tauri::generate_handler![
get_storage_info, get_storage_info,
get_default_downloads_path, get_default_downloads_path,
@@ -672,7 +592,6 @@ pub fn run() {
list_releases, list_releases,
download_and_install_update, download_and_install_update,
restart_app, restart_app,
set_auth_credentials,
]) ])
.setup(|_app| Ok(())) .setup(|_app| Ok(()))
.on_window_event(|window, event| { .on_window_event(|window, event| {
+9
View File
@@ -221,6 +221,15 @@
if (result === "auth_required") { if (result === "auth_required") {
serverProbeOk = true; 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; loginRequired = true;
return; return;
} }
+56 -55
View File
@@ -7,7 +7,7 @@
Bookmark, BookOpen, MonitorPlay, MapPin, Check, Bookmark, BookOpen, MonitorPlay, MapPin, Check,
} from "phosphor-svelte"; } from "phosphor-svelte";
import { gql, thumbUrl, plainThumbUrl } from "../../lib/client"; 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 { store as appStore } from "../../store/state.svelte";
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries"; 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"; 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<number, string[]>(); const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>(); const inflight = new Map<number, Promise<string[]>>();
const isAuth = () => (appStore.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH";
function resolveUrl(url: string, priority = 0): Promise<string> {
return isAuth() ? getBlobUrl(url, priority) : Promise.resolve(url);
}
function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> { function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> {
const cached = pageCache.get(chapterId); const cached = pageCache.get(chapterId);
if (cached) return Promise.resolve(cached); if (cached) return Promise.resolve(cached);
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (!inflight.has(chapterId)) { if (!inflight.has(chapterId)) {
const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId }) const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
.then(async d => { .then(d => {
const mode = appStore.settings.serverAuthMode ?? "NONE"; const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p));
const rawUrls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p)); if (isAuth()) preloadBlobUrls(urls, urls.length);
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));
}
pageCache.set(chapterId, urls); pageCache.set(chapterId, urls);
return urls; return urls;
}) })
.finally(() => inflight.delete(chapterId)); .finally(() => inflight.delete(chapterId));
inflight.set(chapterId, p); inflight.set(chapterId, p);
} }
const base = inflight.get(chapterId)!; const base = inflight.get(chapterId)!;
if (!signal) return base; if (!signal) return base;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -68,20 +68,19 @@
} }
const aspectCache = new Map<string, number>(); const aspectCache = new Map<string, number>();
function preloadImage(url: string) { new Image().src = url; }
function measureAspect(url: string): Promise<number> { function measureAspect(url: string): Promise<number> {
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!); 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(); const img = new Image();
img.onload = () => { img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); };
const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67;
aspectCache.set(url, r);
res(r);
};
img.onerror = () => res(0.67); 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[]; } interface StripChapter { chapterId: number; chapterName: string; urls: string[]; }
@@ -461,9 +460,22 @@
$effect(() => { $effect(() => {
const ahead = store.settings.preloadPages ?? 3; 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 current = store.pageUrls[store.pageNumber - 1];
const behind = store.pageUrls[store.pageNumber - 2]; if (!current) return;
if (behind) preloadImage(behind); 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(() => { $effect(() => {
@@ -959,39 +971,31 @@
{#if style === "longstrip"} {#if style === "longstrip"}
{#each stripToRender as chunk} {#each stripToRender as chunk}
{#each chunk.urls as url, i} {#each chunk.urls as url, i}
<img {#await resolveUrl(url, chunk.urls.length - i)}
src={url} <img src="" alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
alt="{chunk.chapterName} Page {i + 1}" {:then src}
data-local-page={i + 1} <img {src} alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
data-chapter={chunk.chapterId} {/await}
data-total={chunk.urls.length}
class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
loading={i < 5 ? "eager" : "lazy"}
decoding="async"
/>
{/each} {/each}
{/each} {/each}
<div style="height:1px;flex-shrink:0"></div> <div style="height:1px;flex-shrink:0"></div>
{:else if style === "fade" && pageReady} {:else if style === "fade" && pageReady}
<img {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
src={store.pageUrls[store.pageNumber - 1]} <img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
alt="Page {store.pageNumber}" {:then src}
class={imgCls} <img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
decoding="async" {/await}
style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;"
/>
{:else if style === "double" && pageReady} {:else if style === "double" && pageReady}
{#if pageGroups.length} {#if pageGroups.length}
<div class="double-wrap"> <div class="double-wrap">
{#each currentGroup as pg, i} {#each currentGroup as pg, i}
<img {#await resolveUrl(store.pageUrls[pg - 1], 999)}
src={store.pageUrls[pg - 1]} <img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
alt="Page {pg}" {:then src}
class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" <img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
decoding="async" {/await}
/>
{/each} {/each}
</div> </div>
{:else} {:else}
@@ -999,7 +1003,11 @@
{/if} {/if}
{:else if pageReady} {:else if pageReady}
<img src={store.pageUrls[store.pageNumber - 1]} alt="Page {store.pageNumber}" class={imgCls} decoding="async" /> {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{/await}
{/if} {/if}
</div> </div>
@@ -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-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-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-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; } .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: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); } .marker-swatch-active .swatch-dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--swatch) 30%, transparent); transform: scale(1.1); }
+19 -18
View File
@@ -5,38 +5,39 @@
let { let {
src, src,
alt = "", alt = "",
class: className = "", class: cls = "",
loading = "lazy", loading = "lazy",
decoding = "async", decoding = "async",
onerror = undefined, priority = 0,
onerror = undefined,
...rest ...rest
}: { }: {
src: string; src: string;
alt?: string; alt?: string;
class?: string; class?: string;
loading?: string; loading?: string;
decoding?: string; decoding?: string;
onerror?: ((e: Event) => void) | undefined; priority?: number;
onerror?: ((e: Event) => void) | undefined;
[key: string]: any; [key: string]: any;
} = $props(); } = $props();
const isAuth = $derived(store.settings.serverAuthMode === "BASIC_AUTH"); 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(""); let blobUrl = $state("");
$effect(() => { $effect(() => {
if (!isAuth || !src) { blobUrl = ""; return; } if (!isAuth || !src) { blobUrl = ""; return; }
const fullUrl = plainThumbUrl(src); getBlobUrl(plainThumbUrl(src), priority)
getBlobUrl(fullUrl)
.then(u => { blobUrl = u; }) .then(u => { blobUrl = u; })
.catch(() => { blobUrl = ""; }); .catch(() => { blobUrl = ""; });
}); });
const resolved = $derived(isAuth ? blobUrl || undefined : plainResolved || undefined); const resolved = $derived(
isAuth
? (blobUrl || undefined)
: (src ? thumbUrl(src) : undefined)
);
</script> </script>
<img src={resolved} {alt} class={className} {loading} {decoding} {onerror} {...rest} /> <img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
+12 -15
View File
@@ -16,27 +16,25 @@ function basicHeader(user: string, pass: string): Record<string, string> {
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` }; return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
} }
function buildRequestInit(init: RequestInit, extraHeaders: Record<string, string> = {}): RequestInit { export function fetchAuthenticated(
return {
...init,
credentials: "include",
headers: { ...(init.headers as Record<string, string> ?? {}), ...extraHeaders },
};
}
export async function fetchAuthenticated(
url: string, url: string,
init: RequestInit, init: RequestInit,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<Response> { ): Promise<Response> {
const mode = store.settings.serverAuthMode ?? "NONE"; const mode = store.settings.serverAuthMode ?? "NONE";
const s = store.settings;
if (mode === "BASIC_AUTH") { if (mode === "BASIC_AUTH") {
const user = s.serverAuthUser?.trim() ?? ""; const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = s.serverAuthPass?.trim() ?? ""; const pass = store.settings.serverAuthPass?.trim() ?? "";
const headers = user && pass ? basicHeader(user, pass) : {}; return fetch(url, {
return fetch(url, buildRequestInit({ ...init, signal }, headers)); ...init,
signal,
credentials: "include",
headers: {
...(init.headers as Record<string, string> ?? {}),
...(user && pass ? basicHeader(user, pass) : {}),
},
});
} }
return fetch(url, { ...init, signal }); return fetch(url, { ...init, signal });
@@ -80,7 +78,6 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
}); });
if (res.ok) { if (res.ok) {
if (mode !== "NONE") updateSettings({ serverAuthMode: "NONE" });
return "ok"; return "ok";
} }
-3
View File
@@ -10,15 +10,12 @@ function getServerUrl(): string {
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; } function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
// Returns a clean absolute URL with no embedded credentials.
export function plainThumbUrl(path: string): string { export function plainThumbUrl(path: string): string {
if (!path) return ""; if (!path) return "";
if (path.startsWith("http")) return path; if (path.startsWith("http")) return path;
return `${getServerUrl()}${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 { export function thumbUrl(path: string): string {
return plainThumbUrl(path); return plainThumbUrl(path);
} }
+77 -35
View File
@@ -1,49 +1,91 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { store } from "../store/state.svelte"; import { store } from "../store/state.svelte";
const cache = new Map<string, string>(); const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>(); const inflight = new Map<string, Promise<string>>();
function getAuthHeaders(): Record<string, string> { const MAX_CONCURRENT = 6;
const mode = store.settings.serverAuthMode; let active = 0;
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? ""; interface QueueEntry {
const pass = store.settings.serverAuthPass?.trim() ?? ""; url: string;
if (user && pass) { priority: number;
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` }; resolve: (v: string) => void;
} reject: (e: unknown) => void;
}
return {};
} }
export async function getBlobUrl(url: string): Promise<string> { const queue: QueueEntry[] = [];
if (!url) return "";
const cached = cache.get(url); function getAuthHeaders(): Record<string, string> {
if (cached) return cached; 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); async function doFetch(url: string): Promise<string> {
if (existing) return existing; 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, { function drain() {
method: "GET", while (active < MAX_CONCURRENT && queue.length > 0) {
headers: getAuthHeaders(), queue.sort((a, b) => b.priority - a.priority);
}) const entry = queue.shift()!;
.then(res => { active++;
if (!res.ok) throw new Error(`${res.status}`); doFetch(entry.url)
return res.blob(); .then(entry.resolve, entry.reject)
}) .finally(() => {
.then(blob => { inflight.delete(entry.url);
const blobUrl = URL.createObjectURL(blob); active--;
cache.set(url, blobUrl); drain();
inflight.delete(url); });
return blobUrl; }
}) }
.catch(err => {
inflight.delete(url);
throw err;
});
function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => {
queue.push({ url, priority, resolve, reject });
});
inflight.set(url, promise); inflight.set(url, promise);
drain();
return promise; return promise;
} }
export function getBlobUrl(url: string, priority = 0): Promise<string> {
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();
}