mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Fix: Persistent Security State
This commit is contained in:
@@ -11,14 +11,6 @@ use walkdir::WalkDir;
|
||||
|
||||
struct ServerState(Mutex<Option<CommandChild>>);
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct AuthCredentials {
|
||||
user: String,
|
||||
pass: String,
|
||||
}
|
||||
|
||||
struct AuthState(Mutex<AuthCredentials>);
|
||||
|
||||
#[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::<AuthState>();
|
||||
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/<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![
|
||||
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| {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<number, 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[]> {
|
||||
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<string, number>();
|
||||
function preloadImage(url: string) { new Image().src = url; }
|
||||
|
||||
function measureAspect(url: string): Promise<number> {
|
||||
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}
|
||||
<img
|
||||
src={url}
|
||||
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"
|
||||
/>
|
||||
{#await resolveUrl(url, chunk.urls.length - i)}
|
||||
<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" />
|
||||
{:then src}
|
||||
<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" />
|
||||
{/await}
|
||||
{/each}
|
||||
{/each}
|
||||
<div style="height:1px;flex-shrink:0"></div>
|
||||
|
||||
{:else if style === "fade" && pageReady}
|
||||
<img
|
||||
src={store.pageUrls[store.pageNumber - 1]}
|
||||
alt="Page {store.pageNumber}"
|
||||
class={imgCls}
|
||||
decoding="async"
|
||||
style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;"
|
||||
/>
|
||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
|
||||
{:then src}
|
||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
|
||||
{/await}
|
||||
|
||||
{:else if style === "double" && pageReady}
|
||||
{#if pageGroups.length}
|
||||
<div class="double-wrap">
|
||||
{#each currentGroup as pg, i}
|
||||
<img
|
||||
src={store.pageUrls[pg - 1]}
|
||||
alt="Page {pg}"
|
||||
class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}"
|
||||
decoding="async"
|
||||
/>
|
||||
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
|
||||
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
||||
{:then src}
|
||||
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
||||
{/await}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -999,7 +1003,11 @@
|
||||
{/if}
|
||||
|
||||
{: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}
|
||||
</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-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); }
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
</script>
|
||||
|
||||
<img src={resolved} {alt} class={className} {loading} {decoding} {onerror} {...rest} />
|
||||
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
|
||||
|
||||
+12
-15
@@ -16,27 +16,25 @@ function basicHeader(user: string, pass: string): Record<string, string> {
|
||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
}
|
||||
|
||||
function buildRequestInit(init: RequestInit, extraHeaders: Record<string, string> = {}): RequestInit {
|
||||
return {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { ...(init.headers as Record<string, string> ?? {}), ...extraHeaders },
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAuthenticated(
|
||||
export function fetchAuthenticated(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
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<string, string> ?? {}),
|
||||
...(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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+77
-35
@@ -1,49 +1,91 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
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>>();
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
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<string> {
|
||||
if (!url) return "";
|
||||
const queue: QueueEntry[] = [];
|
||||
|
||||
const cached = cache.get(url);
|
||||
if (cached) return cached;
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
queue.push({ url, priority, resolve, reject });
|
||||
});
|
||||
inflight.set(url, promise);
|
||||
drain();
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user