From 82bd90afc57fd250a1ba723e0bd87650c45232ac Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 11 Jun 2026 13:37:46 +0800 Subject: [PATCH] feat(reader): random-access file reads on Android via rangefile scheme (#4534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(reader): random-access file reads on Android via rangefile scheme NativeFile's per-chunk Tauri IPC (open+seek+read+close) is slow on Android, and RemoteFile can't replace it because the WebView mishandles Range requests on intercepted custom-protocol responses — it re-applies the offset to the already-sliced body, so any non-zero-start range returns corrupt data or net::ERR_FAILED (Chromium 40739128, tauri-apps/tauri#12019/#3725). Add a `rangefile` custom URI scheme that carries the byte range in the URL query (?path=&start=&end=) instead of a Range header. With no Range header the WebView delivers the 200 body verbatim, while bytes still stream through the network stack rather than the IPC bridge. The handler is scope-gated by asset_protocol_scope (same boundary as the asset protocol) plus an explicit traversal/NUL/relative guard. RemoteFile.fromNativePath() drives the scheme on Android (query-carried range, X-Total-Size for size); nativeAppService.openFile routes Android reads through it with a NativeFile fallback. Verified on-device (Android 16 / WebView 147) via CDP: byte-equal reads at every offset, ~1.8x faster small scattered reads, real book opens/renders; all out-of-scope/traversal/NUL paths rejected 403. Co-Authored-By: Claude Opus 4.8 (1M context) * ci(rust): run cargo unit tests in rust_lint The rust_lint job ran only fmt + clippy, so the crate's ~40 Rust unit tests (parsers, parser_common, and the new range_file tests) never executed in CI. Add `cargo test -p Readest --lib` to rust_lint — the frontend dist is absent there, but generate_context! already compiles without it (clippy proves this) and the unit tests run headless. Also add a `test:rust` pnpm script and document it as verification done-condition #6. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/pull-request.yml | 3 + .../readest-app/.claude/rules/verification.md | 1 + apps/readest-app/package.json | 1 + apps/readest-app/src-tauri/src/lib.rs | 7 +- apps/readest-app/src-tauri/src/range_file.rs | 257 ++++++++++++++++++ apps/readest-app/src-tauri/tauri.conf.json | 4 +- .../src/__tests__/utils/file.test.ts | 85 ++++++ .../src/services/nativeAppService.ts | 44 +-- apps/readest-app/src/utils/file.ts | 43 ++- 9 files changed, 425 insertions(+), 20 deletions(-) create mode 100644 apps/readest-app/src-tauri/src/range_file.rs create mode 100644 apps/readest-app/src/__tests__/utils/file.test.ts diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 3f1c2128..25ee4805 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -40,6 +40,9 @@ jobs: - name: Clippy Check working-directory: apps/readest-app/src-tauri run: cargo clippy -p Readest --no-deps -- -D warnings + - name: Unit tests + working-directory: apps/readest-app/src-tauri + run: cargo test -p Readest --lib build_web_app: runs-on: ubuntu-latest diff --git a/apps/readest-app/.claude/rules/verification.md b/apps/readest-app/.claude/rules/verification.md index 13af44e8..90f1ba49 100644 --- a/apps/readest-app/.claude/rules/verification.md +++ b/apps/readest-app/.claude/rules/verification.md @@ -7,3 +7,4 @@ Before marking work complete, all applicable checks must pass: 3. `pnpm test:lua` — busted unit tests for `apps/readest.koplugin/spec/` (only when koplugin Lua files changed; soft-skips when busted/luajit not installed) 4. `pnpm fmt:check` — Rust format check (only when `src-tauri/` files changed) 5. `pnpm clippy:check` — Rust lint (only when `src-tauri/` files changed) +6. `pnpm test:rust` — Rust unit tests (`cargo test -p Readest --lib`; only when `src-tauri/` files changed); also run in the CI `rust_lint` job diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 01ddef29..737964fe 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -41,6 +41,7 @@ "tauri": "tauri", "fmt:check": "cargo fmt -p Readest --check", "clippy:check": "cargo clippy -p Readest --no-deps -- -D warnings", + "test:rust": "cargo test -p Readest --lib", "format": "pnpm -w format", "format:check": "pnpm -w format:check", "prepare-public-vendor": "mkdirp ./public/vendor/pdfjs ./public/vendor/simplecc ./public/vendor/jieba", diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index 6c166590..f26fd371 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ mod epub_parser; mod macos; mod mobi_parser; mod parser_common; +mod range_file; mod transfer_file; #[cfg(desktop)] mod window_state; @@ -303,7 +304,11 @@ pub fn run() { .plugin(tauri_plugin_turso::init()) .plugin(tauri_plugin_native_bridge::init()) .plugin(tauri_plugin_native_tts::init()) - .plugin(tauri_plugin_webview_upgrade::init()); + .plugin(tauri_plugin_webview_upgrade::init()) + // Serves local file byte-ranges to `RemoteFile` via `?path=&start=&end=` + // (range-in-URL, not a `Range` header) so Android's WebView doesn't + // re-apply the offset. Scope-gated by `asset_protocol_scope`. + .register_asynchronous_uri_scheme_protocol(range_file::SCHEME, range_file::handle); #[cfg(desktop)] let builder = builder.plugin( diff --git a/apps/readest-app/src-tauri/src/range_file.rs b/apps/readest-app/src-tauri/src/range_file.rs new file mode 100644 index 00000000..b163545e --- /dev/null +++ b/apps/readest-app/src-tauri/src/range_file.rs @@ -0,0 +1,257 @@ +// Custom `rangefile` URI scheme that serves byte ranges of local files to the +// WebView WITHOUT using a `Range` request header. +// +// Why this exists: on Android the WebView mishandles `Range` requests served +// through `shouldInterceptRequest` — it re-applies the range offset to the +// already-sliced intercepted body (skips `start` bytes a second time), so any +// non-zero-start range served by the asset protocol returns corrupt data or +// `net::ERR_FAILED` (Chromium 40739128; tauri-apps/tauri#12019, #3725). That +// makes `RemoteFile`'s random-access reads unusable through the asset protocol +// on Android. +// +// This scheme sidesteps the bug by encoding the range in the URL query +// (`?path=..&start=..&end=..`) instead of a `Range` header. With no `Range` +// header present the WebView performs no offset re-application and delivers the +// 200 body verbatim, while the bytes still stream through the WebView network +// stack (not the slow Tauri IPC bridge). Security mirrors the asset protocol: +// only paths allowed by `asset_protocol_scope` are served. + +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Component, Path, PathBuf}; +use tauri::http::{Request, Response, StatusCode}; +use tauri::{AppHandle, Manager, Runtime, UriSchemeContext, UriSchemeResponder}; + +/// Scheme name; the WebView reaches it at `http://rangefile.localhost/`. +pub const SCHEME: &str = "rangefile"; + +/// Upper bound on bytes returned for a single request. `RemoteFile` already +/// chunks its reads well below this; the cap just bounds a pathological range. +const MAX_RANGE_LEN: u64 = 8 * 1024 * 1024; + +/// Parsed `?path=..&start=..&end=..` query. `end` is inclusive (matches +/// `RemoteFile.fetchRangePart`); omitted `end` means "to EOF". +struct RangeQuery { + path: PathBuf, + start: u64, + end: Option, +} + +fn parse_query(uri_query: Option<&str>) -> Option { + let query = uri_query?; + let mut path: Option = None; + let mut start: u64 = 0; + let mut end: Option = None; + for pair in query.split('&') { + let mut it = pair.splitn(2, '='); + let key = it.next().unwrap_or(""); + let val = it.next().unwrap_or(""); + match key { + "path" => { + let decoded = percent_encoding::percent_decode_str(val) + .decode_utf8_lossy() + .into_owned(); + if !decoded.is_empty() { + path = Some(PathBuf::from(decoded)); + } + } + "start" => start = val.parse().unwrap_or(0), + "end" => end = val.parse().ok(), + _ => {} + } + } + Some(RangeQuery { + path: path?, + start, + end, + }) +} + +/// Defense-in-depth path guard, mirroring the asset protocol's `SafePathBuf`: +/// reject anything that isn't an absolute, traversal-free, NUL-free path BEFORE +/// the scope check. The scope's `is_allowed` already canonicalizes (resolving +/// `..`/symlinks) for existing files, so this is redundant for the security +/// outcome — but it fails closed and keeps the handler obviously-correct +/// instead of relying on that canonicalization subtlety. +fn is_safe_path(path: &Path) -> bool { + path.is_absolute() + && !path.to_string_lossy().contains('\0') + && !path.components().any(|c| matches!(c, Component::ParentDir)) +} + +pub fn handle( + ctx: UriSchemeContext<'_, R>, + request: Request>, + responder: UriSchemeResponder, +) { + // The handler runs off the UI thread (Android `shouldInterceptRequest` is + // called on a WebView worker thread), so blocking file I/O here is fine. + responder.respond(build_response(ctx.app_handle(), &request)); +} + +fn cors_origin(request: &Request>) -> String { + request + .headers() + .get("origin") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "*".to_string()) +} + +fn error(origin: &str, status: StatusCode) -> Response> { + Response::builder() + .status(status) + .header("Access-Control-Allow-Origin", origin) + .header("Cache-Control", "no-store") + .body(Vec::new()) + .unwrap() +} + +fn build_response(app: &AppHandle, request: &Request>) -> Response> { + let origin = cors_origin(request); + + let query = match parse_query(request.uri().query()) { + Some(q) => q, + None => return error(&origin, StatusCode::BAD_REQUEST), + }; + + // Defense-in-depth: reject traversal/NUL/relative paths outright. + if !is_safe_path(&query.path) { + log::warn!("rangefile: rejected unsafe path: {:?}", query.path); + return error(&origin, StatusCode::FORBIDDEN); + } + + // Security: identical boundary to the asset protocol — only paths the + // importer/picker has granted are readable. + if !app.asset_protocol_scope().is_allowed(&query.path) { + log::warn!( + "rangefile: path not allowed by asset scope: {:?}", + query.path + ); + return error(&origin, StatusCode::FORBIDDEN); + } + + let mut file = match File::open(&query.path) { + Ok(f) => f, + Err(e) => { + let status = match e.kind() { + std::io::ErrorKind::NotFound => StatusCode::NOT_FOUND, + std::io::ErrorKind::PermissionDenied => StatusCode::FORBIDDEN, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + return error(&origin, status); + } + }; + + let total = match file.metadata() { + Ok(m) => m.len(), + Err(_) => return error(&origin, StatusCode::INTERNAL_SERVER_ERROR), + }; + + let start = query.start.min(total); + let last = total.saturating_sub(1); + let end_inclusive = query.end.unwrap_or(last).min(last); + let nbytes = if total == 0 || start > end_inclusive { + 0 + } else { + (end_inclusive + 1 - start).min(MAX_RANGE_LEN) + }; + + let mut buf = vec![0u8; nbytes as usize]; + if nbytes > 0 { + if file.seek(SeekFrom::Start(start)).is_err() { + return error(&origin, StatusCode::INTERNAL_SERVER_ERROR); + } + let mut filled = 0usize; + while filled < buf.len() { + match file.read(&mut buf[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(_) => return error(&origin, StatusCode::INTERNAL_SERVER_ERROR), + } + } + buf.truncate(filled); + } + + // 200 (not 206) and NO `Content-Range`: the range was carried in the URL, + // not a `Range` header, so the WebView delivers this body verbatim. + Response::builder() + .status(StatusCode::OK) + .header("Access-Control-Allow-Origin", origin) + .header( + "Access-Control-Expose-Headers", + "X-Total-Size, Content-Length", + ) + .header("Content-Type", "application/octet-stream") + .header("Content-Length", buf.len().to_string()) + .header("X-Total-Size", total.to_string()) + .header("Cache-Control", "no-store") + .body(buf) + .unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_path_start_end() { + let q = parse_query(Some("path=%2Fbooks%2Fa.epub&start=1024&end=2047")).unwrap(); + assert_eq!(q.path, PathBuf::from("/books/a.epub")); + assert_eq!(q.start, 1024); + assert_eq!(q.end, Some(2047)); + } + + #[test] + fn decodes_utf8_path() { + // encodeURIComponent("/书/堂吉诃德.mobi") + let q = parse_query(Some( + "path=%2F%E4%B9%A6%2F%E5%A0%82%E5%90%89%E8%AF%83%E5%BE%B7.mobi&start=0&end=0", + )) + .unwrap(); + assert_eq!(q.path, PathBuf::from("/书/堂吉诃德.mobi")); + } + + #[test] + fn missing_path_is_none() { + assert!(parse_query(Some("start=0&end=10")).is_none()); + assert!(parse_query(None).is_none()); + } + + #[test] + fn end_omitted_means_eof() { + let q = parse_query(Some("path=%2Fa&start=5")).unwrap(); + assert_eq!(q.start, 5); + assert_eq!(q.end, None); + } + + #[test] + fn ampersand_and_equals_in_path_are_percent_encoded() { + // encodeURIComponent("/a&b=c.epub") -> %2Fa%26b%3Dc.epub + let q = parse_query(Some("path=%2Fa%26b%3Dc.epub&start=0")).unwrap(); + assert_eq!(q.path, PathBuf::from("/a&b=c.epub")); + } + + #[test] + fn safe_path_accepts_absolute_traversal_free() { + assert!(is_safe_path(Path::new( + "/data/user/0/com.bilingify.readest/Readest/Books/a.epub" + ))); + assert!(is_safe_path(Path::new("/书/堂吉诃德.mobi"))); + } + + #[test] + fn safe_path_rejects_parent_dir_traversal() { + assert!(!is_safe_path(Path::new( + "/data/user/0/com.bilingify.readest/Readest/../../../../etc/passwd" + ))); + assert!(!is_safe_path(Path::new("/a/../b"))); + } + + #[test] + fn safe_path_rejects_relative_and_nul() { + assert!(!is_safe_path(Path::new("data/x/a.epub"))); // not absolute + assert!(!is_safe_path(Path::new("a.epub"))); + assert!(!is_safe_path(Path::new("/data/a\0b.epub"))); // NUL byte + } +} diff --git a/apps/readest-app/src-tauri/tauri.conf.json b/apps/readest-app/src-tauri/tauri.conf.json index 02d83d26..f6264f75 100644 --- a/apps/readest-app/src-tauri/tauri.conf.json +++ b/apps/readest-app/src-tauri/tauri.conf.json @@ -14,8 +14,8 @@ "windows": [], "security": { "csp": { - "default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost", - "connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost http://*:* https://*:* https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://translate.toil.cc https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com", + "default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost http://rangefile.localhost ipc: http://ipc.localhost", + "connect-src": "'self' blob: data: asset: http://asset.localhost http://rangefile.localhost ipc: http://ipc.localhost http://*:* https://*:* https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://translate.toil.cc https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com", "img-src": "'self' blob: data: asset: http://asset.localhost https://* https://*:* http://* http://*:*", "style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://cdnjs.cloudflare.com https://storage.readest.com", "font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com https://storage.readest.com", diff --git a/apps/readest-app/src/__tests__/utils/file.test.ts b/apps/readest-app/src/__tests__/utils/file.test.ts new file mode 100644 index 00000000..2a9cb33a --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/file.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { RemoteFile } from '@/utils/file'; + +// RemoteFile.fromNativePath serves a local file through the `rangefile` custom +// URI scheme, carrying the byte range in the URL query (?start=&end=) rather +// than a `Range` header — because Android's WebView re-applies a `Range` +// header's offset to intercepted bodies and corrupts non-zero-start reads. +describe('RemoteFile.fromNativePath (rangefile query-range scheme)', () => { + const path = '/data/user/0/com.bilingify.readest/cache/堂吉诃德(译文名著典藏).mobi'; + const TOTAL = 10371956; + let calls: Array<{ url: string; init?: RequestInit }>; + let data: Uint8Array; + + beforeEach(() => { + calls = []; + data = new Uint8Array(8192); + for (let i = 0; i < data.length; i++) data[i] = i & 0xff; + globalThis.fetch = vi.fn(async (input: unknown, init?: RequestInit) => { + const url = String(input); + calls.push({ url, init }); + const u = new URL(url); + const start = Number(u.searchParams.get('start') ?? 0); + const end = Number(u.searchParams.get('end') ?? 0); + const body = data.slice(start, Math.min(end + 1, data.length)); + return { + ok: true, + status: 200, + headers: new Headers({ + 'X-Total-Size': String(TOTAL), + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(body.length), + }), + arrayBuffer: async () => + body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength), + } as unknown as Response; + }) as unknown as typeof fetch; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const noRangeHeader = () => + calls.every((c) => { + const h = c.init?.headers as Record | undefined; + return !h || !Object.keys(h).some((k) => k.toLowerCase() === 'range'); + }); + + it('builds a rangefile.localhost URL with the path percent-encoded in the query', () => { + const f = RemoteFile.fromNativePath(path, 'book.mobi'); + expect(f.url).toBe(`http://rangefile.localhost/?path=${encodeURIComponent(path)}`); + expect(f.name).toBe('book.mobi'); + }); + + it('open() reads the size from X-Total-Size and sends NO Range header', async () => { + const f = await RemoteFile.fromNativePath(path).open(); + expect(f.size).toBe(TOTAL); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toContain('start=0'); + expect(calls[0]!.url).toContain('end=0'); + expect(noRangeHeader()).toBe(true); + }); + + it('fetchRangePart() carries the range in the query, not a Range header', async () => { + const f = await RemoteFile.fromNativePath(path).open(); + calls.length = 0; + const buf = await f.fetchRangePart(1024, 2047); + expect(buf.byteLength).toBe(1024); + expect(calls).toHaveLength(1); + const u = new URL(calls[0]!.url); + expect(u.searchParams.get('start')).toBe('1024'); + expect(u.searchParams.get('end')).toBe('2047'); + expect(noRangeHeader()).toBe(true); + // bytes must be the real [1024,2047] slice (proves no offset re-application) + expect(new Uint8Array(buf)[0]).toBe(1024 & 0xff); + }); + + it('slice().arrayBuffer() returns the correct bytes for a non-zero offset', async () => { + const f = await RemoteFile.fromNativePath(path).open(); + const buf = await f.slice(2000, 2010).arrayBuffer(); // [2000, 2010) + expect(buf.byteLength).toBe(10); + expect(new Uint8Array(buf)[0]).toBe(2000 & 0xff); + expect(noRangeHeader()).toBe(true); + }); +}); diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 0447b763..62cd8720 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -239,23 +239,35 @@ export const nativeFileSystem: FileSystem = { } } else if (isFileURI(path)) { return await new NativeFile(fp, fname, baseDir ? baseDir : null).open(); - } else { - if (OS_TYPE === 'android' || OS_TYPE === 'ios') { - // NOTE: RemoteFile is not usable on Android due to a known issue of range request in Android WebView. - // see https://issues.chromium.org/issues/40739128 - // On iOS, importing picker Inbox files should also use NativeFile to avoid fetch/HEAD issues. + } else if (OS_TYPE === 'android') { + // Android can't use the asset protocol for ranged reads — its WebView + // re-applies a `Range` header's offset to intercepted bodies and corrupts + // non-zero-start reads (Chromium 40739128). Instead route reads through + // the `rangefile` custom scheme, which carries the range in the URL query + // (no `Range` header) so the WebView delivers the bytes verbatim, still + // over the network stack rather than the slow Tauri IPC bridge. + // Falls back to NativeFile if the path is outside the asset scope. + try { + const prefix = await this.getPrefix(base); + const absolutePath = prefix ? await join(prefix, path) : path; + return await RemoteFile.fromNativePath(absolutePath, fname).open(); + } catch { + return await new NativeFile(fp, fname, baseDir ? baseDir : null).open(); + } + } else if (OS_TYPE === 'ios') { + // On iOS, importing picker Inbox files should use NativeFile to avoid + // fetch/HEAD issues. + return await new NativeFile(fp, fname, baseDir ? baseDir : null).open(); + } else { + // NOTE: RemoteFile currently performs about 2× faster than NativeFile + // due to an unresolved performance issue in Tauri (see tauri-apps/tauri#9190). + // Once the bug is resolved, we should switch back to using NativeFile. + try { + const prefix = await this.getPrefix(base); + const absolutePath = prefix ? await join(prefix, path) : path; + return await new RemoteFile(this.getURL(absolutePath), fname).open(); + } catch { return await new NativeFile(fp, fname, baseDir ? baseDir : null).open(); - } else { - // NOTE: RemoteFile currently performs about 2× faster than NativeFile - // due to an unresolved performance issue in Tauri (see tauri-apps/tauri#9190). - // Once the bug is resolved, we should switch back to using NativeFile. - try { - const prefix = await this.getPrefix(base); - const absolutePath = prefix ? await join(prefix, path) : path; - return await new RemoteFile(this.getURL(absolutePath), fname).open(); - } catch { - return await new NativeFile(fp, fname, baseDir ? baseDir : null).open(); - } } } }, diff --git a/apps/readest-app/src/utils/file.ts b/apps/readest-app/src/utils/file.ts index 9faa6471..4bc69001 100644 --- a/apps/readest-app/src/utils/file.ts +++ b/apps/readest-app/src/utils/file.ts @@ -290,9 +290,13 @@ export class RemoteFile extends File implements ClosableFile { #order: number[] = []; #cache: Map = new Map(); // LRU cache #pendingFetches: Map> = new Map(); + // When true, byte ranges are carried in the URL query (?start=&end=) instead + // of a `Range` header — see fromNativePath(). + #queryRange = false; static MAX_CACHE_CHUNK_SIZE = 1024 * 128; static MAX_CACHE_ITEMS_SIZE: number = 128; + static RANGE_SCHEME_ORIGIN = 'http://rangefile.localhost'; constructor(url: string, name?: string, type = '', lastModified = Date.now()) { const basename = url.split('/').pop() || 'remote-file'; @@ -303,6 +307,26 @@ export class RemoteFile extends File implements ClosableFile { this.#lastModified = lastModified; } + /** + * Read a local file path through the `rangefile` custom URI scheme, carrying + * the byte range in the URL query (`?path=&start=&end=`) rather than a + * `Range` request header. + * + * On Android the WebView re-applies a `Range` header's offset to the body + * returned from an intercepted custom protocol (Chromium 40739128; + * tauri-apps/tauri#12019, #3725), corrupting any non-zero-start read — so the + * asset protocol can't back `RemoteFile` there. A query-carried range has no + * `Range` header, so the WebView delivers the 200 body verbatim while the + * bytes still stream through the network stack (not the slow Tauri IPC + * bridge). The Rust handler is scope-gated by `asset_protocol_scope`. + */ + static fromNativePath(absolutePath: string, name?: string): RemoteFile { + const url = `${RemoteFile.RANGE_SCHEME_ORIGIN}/?path=${encodeURIComponent(absolutePath)}`; + const file = new RemoteFile(url, name); + file.#queryRange = true; + return file; + } + override get name() { return this.#name; } @@ -339,7 +363,22 @@ export class RemoteFile extends File implements ClosableFile { return this; } + async _open_with_query() { + // No `Range` header — the rangefile handler returns the file size in + // `X-Total-Size` and the requested bytes as a plain 200 body. + const response = await fetch(`${this.url}&start=0&end=0`); + if (!response.ok) { + throw new Error(`Failed to fetch file size: ${response.status}`); + } + this.#size = Number(response.headers.get('x-total-size')); + this.#type = response.headers.get('content-type') || ''; + return this; + } + async open() { + if (this.#queryRange) { + return this._open_with_query(); + } // FIXME: currently HEAD request in asset protocol is not supported on Android if (getOSPlatform() === 'android') { return this._open_with_range(); @@ -357,7 +396,9 @@ export class RemoteFile extends File implements ClosableFile { start = Math.max(0, start); end = Math.min(this.size - 1, end); // console.log(`Fetching range: ${start}-${end}, size: ${end - start + 1}`); - const response = await fetch(this.url, { headers: { Range: `bytes=${start}-${end}` } }); + const response = this.#queryRange + ? await fetch(`${this.url}&start=${start}&end=${end}`) + : await fetch(this.url, { headers: { Range: `bytes=${start}-${end}` } }); if (!response.ok) { throw new Error(`Failed to fetch range: ${response.status}`); }