forked from akai/readest
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user