diff --git a/src-tauri/src/network/media_protocol.rs b/src-tauri/src/network/media_protocol.rs index 2d9f399fdb..2fcf96f924 100644 --- a/src-tauri/src/network/media_protocol.rs +++ b/src-tauri/src/network/media_protocol.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, fs, - io::Read, + io::{Read, Seek, SeekFrom}, path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, @@ -50,6 +50,9 @@ const MAX_CONCURRENT_DOWNLOAD_REQUESTS: usize = 6; const SESSION_WAIT: Duration = Duration::from_secs(5); const SESSION_REFRESH_WAIT: Duration = Duration::from_millis(2500); const MAX_CACHE_BYTES: u64 = 512 * 1024 * 1024; +// WRY's Android adapter copies custom-protocol bodies into a JNI byte array, so range +// responses must stay bounded even when WebView asks for the rest of a large video. +const MAX_RANGE_CHUNK: u64 = 2 * 1024 * 1024; // Short: a 4xx stops being true once an unreachable remote server comes back. const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(600); const MAX_NEGATIVE_CACHE_ENTRIES: usize = 512; @@ -475,9 +478,10 @@ pub fn respond( .and_then(|value| value.to_str().ok()) .map(str::to_owned) }; + let range = header_value(header::RANGE); let origin = header_value(header::ORIGIN); tauri::async_runtime::spawn(async move { - let mut response = handle_request(&app, uri) + let mut response = handle_request(&app, uri, range) .await .unwrap_or_else(error_response); apply_cors_headers(&mut response, origin.as_deref()); @@ -488,6 +492,7 @@ pub fn respond( async fn handle_request( app: &AppHandle, uri: Uri, + range: Option, ) -> Result>, StatusCode> { let target = percent_encoding::percent_decode_str(uri.path().trim_start_matches('/')) .decode_utf8() @@ -529,12 +534,16 @@ async fn handle_request( let (content_type, in_memory_body, disk_path) = ensure_cached(&state, &session, &key, media_url, dir, temp_dir).await?; - match in_memory_body { - Some(body) => { + match (range, in_memory_body) { + (Some(range_header), Some(body)) => { + Ok(serve_range_memory(&body, &content_type, &range_header)) + } + (Some(range_header), None) => serve_range(disk_path, content_type, range_header).await, + (None, Some(body)) => { let vec_body = Arc::try_unwrap(body).unwrap_or_else(|b| (*b).clone()); Ok(ok_response(vec_body, &content_type)) } - None => Ok(ok_response(read_full(disk_path).await?, &content_type)), + (None, None) => Ok(ok_response(read_full(disk_path).await?, &content_type)), } } @@ -1087,6 +1096,105 @@ async fn read_full(body_path: PathBuf) -> Result, StatusCode> { .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } +async fn serve_range( + body_path: PathBuf, + content_type: String, + range_header: String, +) -> Result>, StatusCode> { + tokio::task::spawn_blocking(move || { + let mut file = fs::File::open(&body_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let total = file + .metadata() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .len(); + let (start, end) = match select_range(&range_header, total) { + RangeSelection::Partial { start, end } => (start, end), + RangeSelection::Ignore => { + let mut body = Vec::new(); + file.read_to_end(&mut body) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + return Ok(ok_response(body, &content_type)); + } + RangeSelection::Unsatisfiable => return Ok(range_not_satisfiable(total)), + }; + let length = end - start + 1; + let mut body = vec![0; length as usize]; + file.seek(SeekFrom::Start(start)) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + file.read_exact(&mut body) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(partial_response(body, &content_type, start, end, total)) + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? +} + +fn serve_range_memory(body: &[u8], content_type: &str, range_header: &str) -> Response> { + let total = body.len() as u64; + let (start, end) = match select_range(range_header, total) { + RangeSelection::Partial { start, end } => (start, end), + RangeSelection::Ignore => return ok_response(body.to_vec(), content_type), + RangeSelection::Unsatisfiable => return range_not_satisfiable(total), + }; + partial_response( + body[start as usize..=end as usize].to_vec(), + content_type, + start, + end, + total, + ) +} + +enum RangeSelection { + Partial { start: u64, end: u64 }, + Ignore, + Unsatisfiable, +} + +fn select_range(range_header: &str, total: u64) -> RangeSelection { + if total == 0 { + return RangeSelection::Unsatisfiable; + } + let Some(spec) = range_header.strip_prefix("bytes=") else { + return RangeSelection::Unsatisfiable; + }; + if spec.contains(',') { + return RangeSelection::Ignore; + } + let Some((start, end)) = spec.trim().split_once('-') else { + return RangeSelection::Unsatisfiable; + }; + let (start, end) = if start.is_empty() { + let Ok(suffix) = end.parse::() else { + return RangeSelection::Unsatisfiable; + }; + if suffix == 0 { + return RangeSelection::Unsatisfiable; + } + let length = suffix.min(total).min(MAX_RANGE_CHUNK); + (total - length, total - 1) + } else { + let Ok(start) = start.parse::() else { + return RangeSelection::Unsatisfiable; + }; + let requested_end = if end.is_empty() { + total - 1 + } else { + let Ok(end) = end.parse::() else { + return RangeSelection::Unsatisfiable; + }; + end.min(total - 1) + }; + let end = requested_end.min(start.saturating_add(MAX_RANGE_CHUNK - 1)); + (start, end) + }; + if start <= end && start < total { + RangeSelection::Partial { start, end } + } else { + RangeSelection::Unsatisfiable + } +} + // Trim oldest-first when the cache exceeds its byte budget. fn evict_directory_if_needed(dir: &Path, max_bytes: u64) { let Ok(entries) = fs::read_dir(dir) else { @@ -1123,7 +1231,7 @@ fn evict_directory_if_needed(dir: &Path, max_bytes: u64) { } // Shared response headers. Media is content-addressed and the URL is session-scoped, so -// it is safe to let the webview cache it as immutable. +// it is safe to let the webview cache it as immutable and advertise byte-range support. fn media_response_builder(status: StatusCode, content_type: &str) -> ResponseBuilder { let cache_control = if content_type == "application/octet-stream" { "no-store" @@ -1133,6 +1241,7 @@ fn media_response_builder(status: StatusCode, content_type: &str) -> ResponseBui Response::builder() .status(status) .header(header::CONTENT_TYPE, content_type) + .header(header::ACCEPT_RANGES, "bytes") .header(header::CACHE_CONTROL, cache_control) .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") .header( @@ -1149,6 +1258,43 @@ fn ok_response(body: Vec, content_type: &str) -> Response> { .expect("failed to build media response") } +fn partial_response( + body: Vec, + content_type: &str, + start: u64, + end: u64, + total: u64, +) -> Response> { + let content_length = body.len(); + media_response_builder(StatusCode::PARTIAL_CONTENT, content_type) + .header(header::CONTENT_LENGTH, content_length) + .header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{total}"), + ) + .body(body) + .expect("failed to build partial media response") +} + +fn range_not_satisfiable(total: u64) -> Response> { + let mut response = error_response(StatusCode::RANGE_NOT_SATISFIABLE); + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_RANGE, + header::HeaderValue::from_str(&format!("bytes */{total}")) + .expect("valid 416 content range"), + ); + headers.insert( + header::CONTENT_LENGTH, + header::HeaderValue::from_static("0"), + ); + headers.insert( + header::ACCEPT_RANGES, + header::HeaderValue::from_static("bytes"), + ); + response +} + fn session_unavailable_response() -> Response> { Response::builder() .status(StatusCode::SERVICE_UNAVAILABLE) @@ -1209,6 +1355,13 @@ mod tests { static TEST_CACHE_ID: AtomicU64 = AtomicU64::new(0); + fn selected_range(header: &str, total: u64) -> Option<(u64, u64)> { + match super::select_range(header, total) { + super::RangeSelection::Partial { start, end } => Some((start, end)), + _ => None, + } + } + fn test_session(origin: &str, token: &str, scope: &str, generation: u64) -> MediaSession { MediaSession { origin: origin.to_owned(), @@ -1725,6 +1878,168 @@ mod tests { response.headers().get(header::CACHE_CONTROL).unwrap(), "private, max-age=31536000, immutable" ); + assert_eq!( + response.headers().get(header::ACCEPT_RANGES).unwrap(), + "bytes" + ); + } + + #[test] + fn select_range_supports_video_metadata_and_seek_requests() { + assert_eq!(selected_range("bytes=0-499", 1000), Some((0, 499))); + assert_eq!(selected_range("bytes=500-", 1000), Some((500, 999))); + assert_eq!(selected_range("bytes=-200", 1000), Some((800, 999))); + assert_eq!(selected_range("bytes=990-5000", 1000), Some((990, 999))); + } + + #[test] + fn open_ended_ranges_are_capped_from_the_requested_offset() { + let total = super::MAX_RANGE_CHUNK * 2 + 100; + assert_eq!( + selected_range("bytes=0-", total), + Some((0, super::MAX_RANGE_CHUNK - 1)) + ); + assert_eq!( + selected_range(&format!("bytes={}-", super::MAX_RANGE_CHUNK), total), + Some((super::MAX_RANGE_CHUNK, super::MAX_RANGE_CHUNK * 2 - 1)) + ); + assert_eq!( + selected_range(&format!("bytes={}-", super::MAX_RANGE_CHUNK * 2), total), + Some((super::MAX_RANGE_CHUNK * 2, total - 1)) + ); + } + + #[test] + fn malformed_or_unsatisfiable_range_is_rejected() { + for range in [ + "bytes=1000-1001", + "bytes=500-400", + "bytes=abc-def", + "items=0-1", + ] { + assert!( + matches!( + super::select_range(range, 1000), + super::RangeSelection::Unsatisfiable + ), + "{range}" + ); + } + assert!(matches!( + super::select_range("bytes=0-0", 0), + super::RangeSelection::Unsatisfiable + )); + } + + #[test] + fn multi_range_requests_fall_back_to_the_full_response() { + let body = [1_u8, 2, 3]; + let response = super::serve_range_memory(&body, "video/mp4", "bytes=0-0,2-2"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body(), &body); + } + + #[test] + fn in_memory_ranges_are_partial_and_capped() { + let body: Vec = (0..200u8).collect(); + let response = super::serve_range_memory(&body, "video/mp4", "bytes=10-19"); + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.body(), &body[10..=19]); + assert_eq!( + response.headers().get(header::CONTENT_RANGE).unwrap(), + "bytes 10-19/200" + ); + + let body = vec![7_u8; (super::MAX_RANGE_CHUNK * 2) as usize]; + let response = super::serve_range_memory(&body, "video/mp4", "bytes=0-"); + assert_eq!(response.body().len() as u64, super::MAX_RANGE_CHUNK); + assert_eq!( + response + .headers() + .get(header::CONTENT_RANGE) + .unwrap() + .to_str() + .unwrap(), + format!("bytes 0-{}/{}", super::MAX_RANGE_CHUNK - 1, body.len()) + ); + + let requested_suffix = super::MAX_RANGE_CHUNK + 100; + let response = + super::serve_range_memory(&body, "video/mp4", &format!("bytes=-{requested_suffix}")); + assert_eq!(response.body().len() as u64, super::MAX_RANGE_CHUNK); + let expected = format!( + "bytes {}-{}/{}", + body.len() as u64 - super::MAX_RANGE_CHUNK, + body.len() - 1, + body.len() + ); + assert_eq!( + response + .headers() + .get(header::CONTENT_RANGE) + .unwrap() + .to_str() + .unwrap(), + expected + ); + } + + #[test] + fn unsatisfiable_ranges_are_not_cacheable() { + let response = super::range_not_satisfiable(1000); + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!( + response.headers().get(header::CONTENT_RANGE).unwrap(), + "bytes */1000" + ); + assert_eq!( + response.headers().get(header::CACHE_CONTROL).unwrap(), + "no-store" + ); + assert_eq!(response.headers().get(header::CONTENT_LENGTH).unwrap(), "0"); + } + + #[test] + fn partial_responses_include_webview_range_metadata() { + let response = super::serve_range_memory(&[0_u8; 100], "video/mp4", "bytes=10-19"); + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "video/mp4" + ); + assert_eq!( + response.headers().get(header::CONTENT_LENGTH).unwrap(), + "10" + ); + assert_eq!( + response.headers().get(header::CONTENT_RANGE).unwrap(), + "bytes 10-19/100" + ); + assert_eq!( + response.headers().get(header::ACCEPT_RANGES).unwrap(), + "bytes" + ); + } + + #[tokio::test] + async fn disk_range_reads_only_the_requested_cached_bytes() { + let path = std::env::temp_dir().join(format!( + "sable-media-range-test-{}-{}", + std::process::id(), + TEST_CACHE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let body: Vec = (0..200u8).collect(); + fs::write(&path, &body).unwrap(); + + let response = super::serve_range(path.clone(), "video/mp4".into(), "bytes=10-19".into()) + .await + .unwrap(); + fs::remove_file(path).ok(); + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.body(), &body[10..=19]); } #[test]