feat(reader): random-access file reads on Android via rangefile scheme (#4534)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-11 13:37:46 +08:00
committed by GitHub
parent 9180767ba4
commit 82bd90afc5
9 changed files with 425 additions and 20 deletions
+3
View File
@@ -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
@@ -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
+1
View File
@@ -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",
+6 -1
View File
@@ -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(
@@ -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<u64>,
}
fn parse_query(uri_query: Option<&str>) -> Option<RangeQuery> {
let query = uri_query?;
let mut path: Option<PathBuf> = None;
let mut start: u64 = 0;
let mut end: Option<u64> = 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<R: Runtime>(
ctx: UriSchemeContext<'_, R>,
request: Request<Vec<u8>>,
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<Vec<u8>>) -> 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<Vec<u8>> {
Response::builder()
.status(status)
.header("Access-Control-Allow-Origin", origin)
.header("Cache-Control", "no-store")
.body(Vec::new())
.unwrap()
}
fn build_response<R: Runtime>(app: &AppHandle<R>, request: &Request<Vec<u8>>) -> Response<Vec<u8>> {
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
}
}
+2 -2
View File
@@ -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",
@@ -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<string, string> | 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);
});
});
@@ -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();
}
}
}
},
+42 -1
View File
@@ -290,9 +290,13 @@ export class RemoteFile extends File implements ClosableFile {
#order: number[] = [];
#cache: Map<number, ArrayBuffer> = new Map(); // LRU cache
#pendingFetches: Map<string, Promise<ArrayBuffer>> = 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}`);
}