diff --git a/package.json b/package.json index 051e714..8887fbc 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-http": "^2.5.8", "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-shell": "^2.3.5", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76fa749..a0e94a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@tauri-apps/api': specifier: ^2.0.0 version: 2.10.1 + '@tauri-apps/plugin-http': + specifier: ^2.5.8 + version: 2.5.8 '@tauri-apps/plugin-os': specifier: ^2.3.2 version: 2.3.2 @@ -445,6 +448,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-http@2.5.8': + resolution: {integrity: sha512-oxd7oypzQeu8kAfFCrw534Kq7Cw+NzozcnCY21O4rz3A+veJiIiuSCMIprgGcZOcLAXFP9GmDhKUbhuKWcunRw==} + '@tauri-apps/plugin-os@2.3.2': resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} @@ -1053,6 +1059,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.10.1 + '@tauri-apps/plugin-http@2.5.8': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-os@2.3.2': dependencies: '@tauri-apps/api': 2.10.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 86090f1..4ee771f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -425,7 +425,7 @@ dependencies = [ "bitflags 2.11.0", "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -952,6 +952,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -959,7 +968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -973,6 +982,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1005,6 +1020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1553,6 +1569,22 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2122,6 +2154,7 @@ name = "moku" version = "0.7.0" dependencies = [ "dirs 5.0.1", + "reqwest 0.12.28", "serde", "serde_json", "sysinfo 0.32.1", @@ -2133,6 +2166,8 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-shell", "tauri-plugin-updater", + "tokio", + "urlencoding", "walkdir", ] @@ -2157,6 +2192,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2493,12 +2545,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3281,17 +3371,21 @@ dependencies = [ "cookie", "cookie_store 0.22.1", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3302,6 +3396,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -4740,6 +4835,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5050,6 +5155,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -5095,6 +5206,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 170903f..213b37b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,9 @@ serde_json = "1" walkdir = "2" sysinfo = "0.32" dirs = "5" +urlencoding = "2" +tokio = { version = "1", features = ["rt-multi-thread"] } +reqwest = { version = "0.12", features = ["blocking"] } [profile.release] codegen-units = 1 diff --git a/src-tauri/capabilities/http-scope.json b/src-tauri/capabilities/http-scope.json new file mode 100644 index 0000000..0aa9db1 --- /dev/null +++ b/src-tauri/capabilities/http-scope.json @@ -0,0 +1,17 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "http-scope", + "description": "HTTP fetch scope", + "windows": ["main"], + "permissions": [ + { + "identifier": "http:default", + "allow": [ + { "url": "http://*:*/*" }, + { "url": "https://*:*/*" }, + { "url": "http://*/*" }, + { "url": "https://*/*" } + ] + } + ] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0d6fecf..e5287dc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,14 @@ 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, @@ -569,6 +577,14 @@ 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() { tauri::Builder::default() @@ -579,6 +595,71 @@ 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, @@ -591,6 +672,7 @@ 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/components/pages/Home.svelte b/src/components/pages/Home.svelte index 3dc05e7..da09dca 100644 --- a/src/components/pages/Home.svelte +++ b/src/components/pages/Home.svelte @@ -2,7 +2,7 @@ import { onMount, untrack } from "svelte"; import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte"; import { gql, thumbUrl } from "../../lib/client"; - import { fetchAuthenticated } from "../../lib/auth"; + import { getBlobUrl } from "../../lib/imageCache"; import Thumbnail from "../shared/Thumbnail.svelte"; import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries"; import { cache, CACHE_KEYS } from "../../lib/cache"; @@ -121,22 +121,15 @@ activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : "" ); let heroThumb = $state(""); - const heroThumbCache = new Map(); $effect(() => { const path = heroThumbSrc; const mode = store.settings.serverAuthMode ?? "NONE"; if (!path) { heroThumb = ""; return; } if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; } - if (heroThumbCache.has(path)) { heroThumb = heroThumbCache.get(path)!; return; } - heroThumb = ""; - fetchAuthenticated(thumbUrl(path), { method: "GET" }) - .then(r => r.blob()) - .then(blob => { - const url = URL.createObjectURL(blob); - heroThumbCache.set(path, url); - heroThumb = url; - }) - .catch(() => {}); + // Use tauri-plugin-http backed getBlobUrl which handles auth and bypasses CORS + getBlobUrl(thumbUrl(path)) + .then(url => { heroThumb = url; }) + .catch(() => { heroThumb = ""; }); }); const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : ""); const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null); diff --git a/src/components/pages/Library.svelte b/src/components/pages/Library.svelte index 4514842..7296e18 100644 --- a/src/components/pages/Library.svelte +++ b/src/components/pages/Library.svelte @@ -9,6 +9,7 @@ import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "../../store/state.svelte"; import type { Manga, Category, Chapter } from "../../lib/types"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; + import Thumbnail from "../shared/Thumbnail.svelte"; const CARD_MIN_W = 130; const CARD_GAP = 16; @@ -920,7 +921,7 @@ onpointerleave={onCardPointerLeave} >
- {m.title} + {#if m.downloadCount}{m.downloadCount}{/if} {#if m.unreadCount}{m.unreadCount}{/if} {#if selectMode} diff --git a/src/components/reader/Reader.svelte b/src/components/reader/Reader.svelte index f8aadb5..dced992 100644 --- a/src/components/reader/Reader.svelte +++ b/src/components/reader/Reader.svelte @@ -6,8 +6,8 @@ CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus, Bookmark, BookOpen, MonitorPlay, MapPin, Check, } from "phosphor-svelte"; - import { gql, thumbUrl } from "../../lib/client"; - import { fetchAuthenticated } from "../../lib/auth"; + import { gql, thumbUrl, plainThumbUrl } from "../../lib/client"; + import { getBlobUrl } 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"; @@ -43,16 +43,16 @@ if (!inflight.has(chapterId)) { const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId }) .then(async d => { - const rawUrls = d.fetchChapterPages.pages.map(thumbUrl); const mode = appStore.settings.serverAuthMode ?? "NONE"; - const urls = mode === "BASIC_AUTH" - ? await Promise.all(rawUrls.map(u => - fetchAuthenticated(u, { method: "GET" }) - .then(r => r.blob()) - .then(b => URL.createObjectURL(b)) - .catch(() => u) - )) - : rawUrls; + 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)); + } pageCache.set(chapterId, urls); return urls; }) diff --git a/src/components/shared/Thumbnail.svelte b/src/components/shared/Thumbnail.svelte index 817a5a4..4144f99 100644 --- a/src/components/shared/Thumbnail.svelte +++ b/src/components/shared/Thumbnail.svelte @@ -1,8 +1,7 @@ diff --git a/src/lib/client.ts b/src/lib/client.ts index c6579b1..cd16fc2 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -10,25 +10,17 @@ function getServerUrl(): string { function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; } -export function thumbUrl(path: string): string { +// 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}`; +} - const base = getServerUrl(); - const mode = store.settings.serverAuthMode; - - if (mode === "BASIC_AUTH") { - const user = store.settings.serverAuthUser?.trim() ?? ""; - const pass = store.settings.serverAuthPass?.trim() ?? ""; - if (user && pass) { - const url = new URL(`${base}${path}`); - url.username = user; - url.password = pass; - return url.toString(); - } - } - - return `${base}${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); } interface GQLResponse { diff --git a/src/lib/imageCache.ts b/src/lib/imageCache.ts new file mode 100644 index 0000000..9883944 --- /dev/null +++ b/src/lib/imageCache.ts @@ -0,0 +1,49 @@ +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import { store } from "../store/state.svelte"; + +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 {}; +} + +export async function getBlobUrl(url: string): Promise { + if (!url) return ""; + + const cached = cache.get(url); + if (cached) return cached; + + const existing = inflight.get(url); + if (existing) return existing; + + 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; + }); + + inflight.set(url, promise); + return promise; +}