From 3a607a32171dc64b176de03f9cefd5ddd6a9135e Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 13:24:33 +0200 Subject: [PATCH 1/5] feat(tauri): cacheable media responses and Range streaming for video/audio --- src-tauri/src/network/media_protocol.rs | 254 ++++++++++++++++-- .../message/content/AudioContent.tsx | 3 + .../message/content/VideoContent.tsx | 3 + src/app/hooks/useRenderableMediaUrl.test.tsx | 8 +- src/app/utils/matrix.ts | 13 +- 5 files changed, 255 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/network/media_protocol.rs b/src-tauri/src/network/media_protocol.rs index 663c6ca0d..1e018d67f 100644 --- a/src-tauri/src/network/media_protocol.rs +++ b/src-tauri/src/network/media_protocol.rs @@ -1,13 +1,14 @@ use std::{ fs, - path::PathBuf, + io::{Read, Seek, SeekFrom}, + path::{Path, PathBuf}, sync::{OnceLock, RwLock}, time::Duration, }; use sha2::{Digest, Sha256}; use tauri::{ - http::{header, Request, Response, StatusCode, Uri}, + http::{header, response::Builder as ResponseBuilder, Request, Response, StatusCode, Uri}, AppHandle, Manager, Runtime, UriSchemeContext, UriSchemeResponder, }; use tauri_plugin_http::reqwest::{ @@ -23,6 +24,8 @@ const CACHE_SUBDIR: &str = "sable-media"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const MAX_CONCURRENT_REQUESTS: usize = 8; +const MAX_CACHE_BYTES: u64 = 512 * 1024 * 1024; +const MAX_RANGE_CHUNK: u64 = 2 * 1024 * 1024; pub struct MediaSessionState { inner: RwLock>, @@ -100,8 +103,13 @@ pub fn respond( ) { let app = ctx.app_handle().clone(); let uri = request.uri().clone(); + let range = request + .headers() + .get(header::RANGE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); tauri::async_runtime::spawn(async move { - let response = handle_request(&app, uri) + let response = handle_request(&app, uri, range) .await .unwrap_or_else(error_response); responder.respond(response); @@ -111,6 +119,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() @@ -152,10 +161,36 @@ async fn handle_request( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - if let Some((body, content_type)) = - read_cache(body_path.clone(), content_type_path.clone()).await + // Cache to disk first, then serve from there so Range works regardless of homeserver support. + let content_type = ensure_cached( + &state, + &session, + media_url, + dir, + body_path.clone(), + content_type_path, + ) + .await?; + + match range { + Some(range_header) => serve_range(body_path, content_type, range_header).await, + None => Ok(ok_response(read_full(body_path).await?, &content_type)), + } +} + +// Ensure the body + content type are on disk, fetching from the homeserver on a miss. +async fn ensure_cached( + state: &MediaSessionState, + session: &MediaSession, + media_url: Url, + dir: PathBuf, + body_path: PathBuf, + content_type_path: PathBuf, +) -> Result { + if let Some(content_type) = + read_content_type(body_path.clone(), content_type_path.clone()).await { - return Ok(ok_response(body, &content_type)); + return Ok(content_type); } let upstream = state @@ -188,25 +223,95 @@ async fn handle_request( dir, body_path, content_type_path, - body.clone(), + body, content_type.clone(), ) .await; - Ok(ok_response(body, &content_type)) + Ok(content_type) } -async fn read_cache(body_path: PathBuf, content_type_path: PathBuf) -> Option<(Vec, String)> { +// Only a hit when the body file is also present, so a stray `.ct` counts as a miss. +async fn read_content_type(body_path: PathBuf, content_type_path: PathBuf) -> Option { tokio::task::spawn_blocking(move || { - match (fs::read(&body_path), fs::read_to_string(&content_type_path)) { - (Ok(body), Ok(content_type)) => Some((body, content_type)), - _ => None, + if !body_path.is_file() { + return None; } + fs::read_to_string(&content_type_path).ok() }) .await .unwrap_or(None) } +async fn read_full(body_path: PathBuf) -> Result, StatusCode> { + tokio::task::spawn_blocking(move || fs::read(&body_path)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .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 Some((start, end)) = parse_range(&range_header, total) else { + return Ok(range_not_satisfiable(total)); + }; + + let end = end.min(start + MAX_RANGE_CHUNK - 1); + let length = end - start + 1; + + let mut buf = vec![0_u8; length as usize]; + file.seek(SeekFrom::Start(start)) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + file.read_exact(&mut buf) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(partial_response(buf, &content_type, start, end, total)) + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? +} + +// First byte range of a `Range: bytes=...` header as inclusive (start, end); None → 416. +fn parse_range(header: &str, total: u64) -> Option<(u64, u64)> { + if total == 0 { + return None; + } + + let spec = header.strip_prefix("bytes=")?; + let (start_str, end_str) = spec.split(',').next()?.trim().split_once('-')?; + + let (start, end) = if start_str.is_empty() { + let suffix: u64 = end_str.parse().ok()?; + if suffix == 0 { + return None; + } + (total.saturating_sub(suffix), total - 1) + } else { + let start: u64 = start_str.parse().ok()?; + let end = if end_str.is_empty() { + total - 1 + } else { + end_str.parse::().ok()?.min(total - 1) + }; + (start, end) + }; + + if start > end || start >= total { + return None; + } + Some((start, end)) +} + async fn write_cache( dir: PathBuf, body_path: PathBuf, @@ -218,28 +323,97 @@ async fn write_cache( if fs::create_dir_all(&dir).is_ok() { let _ = fs::write(&body_path, &body); let _ = fs::write(&content_type_path, &content_type); + evict_if_needed(&dir); } }) .await; } -fn ok_response(body: Vec, content_type: &str) -> Response> { +// Trim oldest-first when the cache exceeds its byte budget. +fn evict_if_needed(dir: &Path) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + + let mut files: Vec<(PathBuf, u64, std::time::SystemTime)> = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + let meta = entry.metadata().ok()?; + if !meta.is_file() { + return None; + } + let modified = meta.modified().unwrap_or(std::time::UNIX_EPOCH); + Some((path, meta.len(), modified)) + }) + .collect(); + + let mut total: u64 = files.iter().map(|(_, len, _)| *len).sum(); + if total <= MAX_CACHE_BYTES { + return; + } + + files.sort_by_key(|(_, _, modified)| *modified); + for (path, len, _) in files { + if total <= MAX_CACHE_BYTES { + break; + } + if fs::remove_file(&path).is_ok() { + total = total.saturating_sub(len); + } + } +} + +// Shared 200/206 headers. Media is content-addressed and the URL is session-scoped, so +// it is safe to let the webview cache it as immutable and to advertise Range support. +fn media_response_builder(status: StatusCode, content_type: &str) -> ResponseBuilder { Response::builder() - .status(StatusCode::OK) + .status(status) .header(header::CONTENT_TYPE, content_type) .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") - // Authorization is enforced by the protocol handler. Do not let the - // webview reuse this response after the active Matrix session changes. - .header(header::CACHE_CONTROL, "private, no-store") + .header(header::ACCEPT_RANGES, "bytes") + .header( + header::CACHE_CONTROL, + "private, max-age=31536000, immutable", + ) .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") .header( header::CONTENT_SECURITY_POLICY, "sandbox; default-src 'none'; script-src 'none'; object-src 'none'", ) +} + +fn ok_response(body: Vec, content_type: &str) -> Response> { + media_response_builder(StatusCode::OK, content_type) .body(body) .expect("failed to build media response") } +fn partial_response( + body: Vec, + content_type: &str, + start: u64, + end: u64, + total: u64, +) -> Response> { + media_response_builder(StatusCode::PARTIAL_CONTENT, content_type) + .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> { + Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(header::CONTENT_RANGE, format!("bytes */{total}")) + .body(Vec::new()) + .expect("failed to build range error response") +} + fn error_response(status: StatusCode) -> Response> { Response::builder() .status(status) @@ -266,9 +440,9 @@ fn cache_key(session_token: &str, url: &str) -> String { #[cfg(test)] mod tests { - use tauri::http::header; + use tauri::http::{header, StatusCode}; - use super::{cache_key, ok_response}; + use super::{cache_key, ok_response, parse_range, partial_response}; #[test] fn cache_key_is_stable_and_hex() { @@ -289,11 +463,49 @@ mod tests { } #[test] - fn protocol_responses_are_not_cached_by_the_webview() { + fn protocol_responses_are_privately_cacheable_by_the_webview() { let response = ok_response(Vec::new(), "image/png"); assert_eq!( response.headers().get(header::CACHE_CONTROL).unwrap(), - "private, no-store" + "private, max-age=31536000, immutable" + ); + assert_eq!( + response.headers().get(header::ACCEPT_RANGES).unwrap(), + "bytes" + ); + } + + #[test] + fn parse_range_handles_the_common_forms() { + assert_eq!(parse_range("bytes=0-499", 1000), Some((0, 499))); + assert_eq!(parse_range("bytes=500-", 1000), Some((500, 999))); + assert_eq!(parse_range("bytes=0-", 1000), Some((0, 999))); + // End past the file is clamped to the last byte. + assert_eq!(parse_range("bytes=990-5000", 1000), Some((990, 999))); + // Suffix range: the last N bytes. + assert_eq!(parse_range("bytes=-200", 1000), Some((800, 999))); + } + + #[test] + fn parse_range_rejects_unsatisfiable_and_malformed() { + assert_eq!(parse_range("bytes=1000-1001", 1000), None); + assert_eq!(parse_range("bytes=500-400", 1000), None); + assert_eq!(parse_range("bytes=abc-def", 1000), None); + assert_eq!(parse_range("items=0-1", 1000), None); + assert_eq!(parse_range("bytes=0-0", 0), None); + } + + #[test] + fn partial_response_reports_the_served_range() { + let response = partial_response(vec![0_u8; 100], "video/mp4", 0, 99, 5000); + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!( + response.headers().get(header::CONTENT_RANGE).unwrap(), + "bytes 0-99/5000" + ); + assert_eq!( + response.headers().get(header::ACCEPT_RANGES).unwrap(), + "bytes" ); } } diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index dd2a887fb..6622818e8 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Badge, Chip, IconButton, ProgressBar, Spinner, Text, toRem } from 'folds'; import { sizedIcon, Pause, Play, SpeakerHigh, SpeakerSlash } from '$components/icons/phosphor'; +import { isTauri } from '@tauri-apps/api/core'; import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { Range } from 'react-range'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -55,6 +56,8 @@ export function AudioContent({ useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); if (!mediaUrl) throw new Error('Invalid media URL'); + // Stream via the sable-media:// protocol (Range) instead of buffering a blob. + if (!encInfo && isTauri()) return mediaUrl; const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index 25f54bfb9..09a0e6acd 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -16,6 +16,7 @@ import { } from 'folds'; import { Eye, EyeSlash, menuIcon, sizedIcon, Play, Warning } from '$components/icons/phosphor'; import classNames from 'classnames'; +import { isTauri } from '@tauri-apps/api/core'; import { BlurhashCanvas } from 'react-blurhash'; import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import type { IThumbnailContent, IVideoInfo } from '$types/matrix/common'; @@ -82,6 +83,8 @@ export const VideoContent = as<'div', VideoContentProps>( const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); if (!mediaUrl) throw new Error('Invalid media URL'); + // Stream via the sable-media:// protocol (Range) instead of buffering a blob. + if (!encInfo && isTauri()) return mediaUrl; const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo) diff --git a/src/app/hooks/useRenderableMediaUrl.test.tsx b/src/app/hooks/useRenderableMediaUrl.test.tsx index fb7a16263..4efe8b67d 100644 --- a/src/app/hooks/useRenderableMediaUrl.test.tsx +++ b/src/app/hooks/useRenderableMediaUrl.test.tsx @@ -213,7 +213,9 @@ describe('useRenderableMediaUrl', () => { 'https://matrix.example.org/_matrix/client/v1/media/thumbnail/example.org/abc123?width=96&height=96'; const { result } = renderHook(() => useRenderableMediaUrl(rawAuthUrl)); - expect(result.current).toBe(`sable-media://${rawAuthUrl}&__sable_media_cache=2`); + expect(result.current).toBe( + `sable-media://${rawAuthUrl}&__sable_media_cache=2&__sable_media_session=anonymous` + ); expect(tauriApi.convertFileSrc).toHaveBeenCalledWith(rawAuthUrl, 'sable-media'); }); @@ -225,7 +227,9 @@ describe('useRenderableMediaUrl', () => { 'sable-media://https://matrix.example.org/_matrix/client/v1/media/thumbnail/example.org/abc123'; const { result } = renderHook(() => useRenderableMediaUrl(rewrittenUrl)); - expect(result.current).toBe(`${rewrittenUrl}?__sable_media_cache=2`); + expect(result.current).toBe( + `${rewrittenUrl}?__sable_media_cache=2&__sable_media_session=anonymous` + ); expect(tauriApi.convertFileSrc).not.toHaveBeenCalled(); }); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index c97e083ef..7a1755ba6 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -27,7 +27,11 @@ import { fetch } from '$utils/fetch'; import { getEventReactions, getStateEvent } from './room'; import { getReactionContent } from './messageReaction'; import { matchMxId, validMxId } from './mxIdHelper'; -import { fetchMediaBlob, type MediaTransportOptions } from './mediaTransport'; +import { + fetchMediaBlob, + getCurrentMediaSessionScope, + type MediaTransportOptions, +} from './mediaTransport'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; const TAURI_MEDIA_CACHE_VERSION = '__sable_media_cache=2'; @@ -413,11 +417,14 @@ export const rewriteAuthenticatedMediaUrl = (httpUrl: string | null): string | n if (!httpUrl) return null; if (!isTauri()) return httpUrl; if (!httpUrl.includes('/_matrix/client/v1/media/')) return httpUrl; + if (httpUrl.includes(TAURI_MEDIA_CACHE_VERSION)) return httpUrl; const mediaUrl = httpUrl.startsWith('sable-media://') ? httpUrl : convertFileSrc(httpUrl, 'sable-media'); - if (mediaUrl.includes(TAURI_MEDIA_CACHE_VERSION)) return mediaUrl; - return `${mediaUrl}${mediaUrl.includes('?') ? '&' : '?'}${TAURI_MEDIA_CACHE_VERSION}`; + // Session-scoped so the cacheable response is never shared across accounts. + const sessionScope = encodeURIComponent(getCurrentMediaSessionScope()); + const separator = mediaUrl.includes('?') ? '&' : '?'; + return `${mediaUrl}${separator}${TAURI_MEDIA_CACHE_VERSION}&__sable_media_session=${sessionScope}`; }; export const mxcUrlToHttp = ( From 99799d4d14163f779d4a03d085648afc189c0755 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 14:54:53 +0200 Subject: [PATCH 2/5] test(tauri): align media rewrite tests with session-scoped caching Update rewriteAuthenticatedMediaUrl expectations to account for the __sable_media_session query param, mocking getCurrentMediaSessionScope for deterministic results. Add a changeset for the Tauri media caching and Range streaming feature. --- .changeset/tauri-media-caching-streaming.md | 5 +++++ src/app/utils/matrix.test.ts | 18 +++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 .changeset/tauri-media-caching-streaming.md diff --git a/.changeset/tauri-media-caching-streaming.md b/.changeset/tauri-media-caching-streaming.md new file mode 100644 index 000000000..ea71a88c9 --- /dev/null +++ b/.changeset/tauri-media-caching-streaming.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Cache media responses and stream video/audio by byte range under Tauri for faster playback and lower bandwidth. diff --git a/src/app/utils/matrix.test.ts b/src/app/utils/matrix.test.ts index 7875593d2..a65a3ede8 100644 --- a/src/app/utils/matrix.test.ts +++ b/src/app/utils/matrix.test.ts @@ -7,7 +7,13 @@ const tauriApi = vi.hoisted(() => ({ ), })); +const mediaTransport = vi.hoisted(() => ({ + fetchMediaBlob: vi.fn<(url: string) => Promise>(), + getCurrentMediaSessionScope: vi.fn<() => string>(() => '@user:example.com'), +})); + vi.mock('@tauri-apps/api/core', () => tauriApi); +vi.mock('./mediaTransport', () => mediaTransport); const { rewriteAuthenticatedMediaUrl } = await import('./matrix'); @@ -38,7 +44,9 @@ describe('rewriteAuthenticatedMediaUrl', () => { it('rewrites authenticated-media download URLs under Tauri', () => { tauriApi.isTauri.mockReturnValue(true); const url = 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/abc123'; - expect(rewriteAuthenticatedMediaUrl(url)).toBe(`sable-media://${url}?__sable_media_cache=2`); + expect(rewriteAuthenticatedMediaUrl(url)).toBe( + `sable-media://${url}?__sable_media_cache=2&__sable_media_session=%40user%3Aexample.com` + ); expect(tauriApi.convertFileSrc).toHaveBeenCalledWith(url, 'sable-media'); }); @@ -46,14 +54,18 @@ describe('rewriteAuthenticatedMediaUrl', () => { tauriApi.isTauri.mockReturnValue(true); const url = 'https://matrix.example.org/_matrix/client/v1/media/thumbnail/example.org/abc123?width=96&height=96&method=crop'; - expect(rewriteAuthenticatedMediaUrl(url)).toBe(`sable-media://${url}&__sable_media_cache=2`); + expect(rewriteAuthenticatedMediaUrl(url)).toBe( + `sable-media://${url}&__sable_media_cache=2&__sable_media_session=%40user%3Aexample.com` + ); }); it('passes through already-rewritten sable-media:// URLs', () => { tauriApi.isTauri.mockReturnValue(true); const url = 'sable-media://https://matrix.example.org/_matrix/client/v1/media/download/example.org/abc123'; - expect(rewriteAuthenticatedMediaUrl(url)).toBe(`${url}?__sable_media_cache=2`); + expect(rewriteAuthenticatedMediaUrl(url)).toBe( + `${url}?__sable_media_cache=2&__sable_media_session=%40user%3Aexample.com` + ); expect(tauriApi.convertFileSrc).not.toHaveBeenCalled(); }); }); From 05343c67283669c067de345a2768c854b804ba65 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 20:20:43 +0200 Subject: [PATCH 3/5] fix(tauri): serve oversized media without persisting past the cache budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A media file larger than MAX_CACHE_BYTES was written to disk and then immediately evicted by evict_if_needed (it alone exceeded the budget, so everything — including itself — got dropped), so it was never servable from disk on a hit. ensure_cached now skips the write for bodies larger than the budget and returns them in-memory for that request; handle_request serves a Range request off the in-memory buffer via the new serve_range_memory. Cache hits and all files within the budget are unchanged. --- .changeset/tauri-media-caching-streaming.md | 2 +- src-tauri/src/network/media_protocol.rs | 100 ++++++++++++++++---- 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/.changeset/tauri-media-caching-streaming.md b/.changeset/tauri-media-caching-streaming.md index ea71a88c9..2932f32e0 100644 --- a/.changeset/tauri-media-caching-streaming.md +++ b/.changeset/tauri-media-caching-streaming.md @@ -2,4 +2,4 @@ default: minor --- -Cache media responses and stream video/audio by byte range under Tauri for faster playback and lower bandwidth. +Cache media responses and stream video/audio by byte range under Tauri for faster playback and lower bandwidth. Files larger than the cache budget are served without being persisted, so they no longer evict the whole cache on write. diff --git a/src-tauri/src/network/media_protocol.rs b/src-tauri/src/network/media_protocol.rs index 1e018d67f..5383a7ced 100644 --- a/src-tauri/src/network/media_protocol.rs +++ b/src-tauri/src/network/media_protocol.rs @@ -161,8 +161,11 @@ async fn handle_request( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Cache to disk first, then serve from there so Range works regardless of homeserver support. - let content_type = ensure_cached( + // Cache to disk first, then serve from there so Range works regardless of + // homeserver support. Files larger than the cache budget are served from + // memory for this request and never persisted, so evict_if_needed can't + // drop them on write. + let (content_type, in_memory_body) = ensure_cached( &state, &session, media_url, @@ -172,9 +175,13 @@ async fn handle_request( ) .await?; - match range { - Some(range_header) => serve_range(body_path, content_type, range_header).await, - None => Ok(ok_response(read_full(body_path).await?, &content_type)), + 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(body_path, content_type, range_header).await, + (None, Some(body)) => Ok(ok_response(body, &content_type)), + (None, None) => Ok(ok_response(read_full(body_path).await?, &content_type)), } } @@ -186,11 +193,11 @@ async fn ensure_cached( dir: PathBuf, body_path: PathBuf, content_type_path: PathBuf, -) -> Result { +) -> Result<(String, Option>), StatusCode> { if let Some(content_type) = read_content_type(body_path.clone(), content_type_path.clone()).await { - return Ok(content_type); + return Ok((content_type, None)); } let upstream = state @@ -219,16 +226,23 @@ async fn ensure_cached( .map_err(|_| StatusCode::BAD_GATEWAY)? .to_vec(); - write_cache( - dir, - body_path, - content_type_path, - body, - content_type.clone(), - ) - .await; + if fits_in_cache(body.len() as u64) { + write_cache( + dir, + body_path, + content_type_path, + body, + content_type.clone(), + ) + .await; + Ok((content_type, None)) + } else { + Ok((content_type, Some(body))) + } +} - Ok(content_type) +fn fits_in_cache(len: u64) -> bool { + len <= MAX_CACHE_BYTES } // Only a hit when the body file is also present, so a stray `.ct` counts as a miss. @@ -281,6 +295,18 @@ async fn serve_range( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? } +// Serve a Range request from an in-memory body. Mirrors serve_range but slices +// the buffer instead of seeking. +fn serve_range_memory(body: Vec, content_type: &str, range_header: &str) -> Response> { + let total = body.len() as u64; + let Some((start, end)) = parse_range(range_header, total) else { + return range_not_satisfiable(total); + }; + let end = end.min(start + MAX_RANGE_CHUNK - 1); + let buf = body[start as usize..=(end as usize)].to_vec(); + partial_response(buf, content_type, start, end, total) +} + // First byte range of a `Range: bytes=...` header as inclusive (start, end); None → 416. fn parse_range(header: &str, total: u64) -> Option<(u64, u64)> { if total == 0 { @@ -442,7 +468,9 @@ fn cache_key(session_token: &str, url: &str) -> String { mod tests { use tauri::http::{header, StatusCode}; - use super::{cache_key, ok_response, parse_range, partial_response}; + use super::{ + cache_key, fits_in_cache, ok_response, parse_range, partial_response, serve_range_memory, + }; #[test] fn cache_key_is_stable_and_hex() { @@ -508,4 +536,42 @@ mod tests { "bytes" ); } + + #[test] + fn oversized_media_is_not_cacheable() { + assert!(fits_in_cache(0)); + assert!(fits_in_cache(super::MAX_CACHE_BYTES)); + assert!(!fits_in_cache(super::MAX_CACHE_BYTES + 1)); + } + + #[test] + fn serve_range_memory_slices_the_in_memory_body() { + let body: Vec = (0..200u8).collect(); + let response = serve_range_memory(body.clone(), "application/octet-stream", "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" + ); + } + + #[test] + fn serve_range_memory_caps_at_max_range_chunk() { + let body = vec![7_u8; (super::MAX_RANGE_CHUNK * 2) as usize]; + let response = serve_range_memory(body, "application/octet-stream", "bytes=0-"); + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.body().len() as u64, super::MAX_RANGE_CHUNK); + let total = super::MAX_RANGE_CHUNK * 2; + assert_eq!( + response.headers().get(header::CONTENT_RANGE).unwrap(), + &format!("bytes 0-{}/{total}", super::MAX_RANGE_CHUNK - 1) + ); + } + + #[test] + fn serve_range_memory_rejects_unsatisfiable_range() { + let response = serve_range_memory(Vec::new(), "application/octet-stream", "bytes=0-0"); + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + } } From c66f7fa17d5f0e4b166163ad893e7e9ed1fa6127 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 20 Jul 2026 21:08:06 +0200 Subject: [PATCH 4/5] chore(tauri): bump tauri-plugin-notifications to 4ea297b (main HEAD) --- src-tauri/Cargo.lock | 38 +++++++++++++++++++------------------- src-tauri/Cargo.toml | 6 +++--- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0fa65b93e..f43e58706 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -148,7 +148,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -159,7 +159,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -957,7 +957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1481,7 +1481,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1783,7 +1783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2705,7 +2705,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -3530,7 +3530,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3654,7 +3654,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4040,7 +4040,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -4684,7 +4684,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -4721,7 +4721,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -5124,7 +5124,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5182,7 +5182,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5756,7 +5756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6433,7 +6433,7 @@ dependencies = [ [[package]] name = "tauri-plugin-notifications" version = "0.5.0-rc.11" -source = "git+https://github.com/SableClient/tauri-plugin-notifications.git?rev=df09f08e64b840e4d51cde2720030836eed40f4c#df09f08e64b840e4d51cde2720030836eed40f4c" +source = "git+https://github.com/SableClient/tauri-plugin-notifications.git?rev=4ea297bdeffaba6a8e2e4386a18fbd4dd59fa0f0#4ea297bdeffaba6a8e2e4386a18fbd4dd59fa0f0" dependencies = [ "log", "notify-rust", @@ -6767,7 +6767,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7393,7 +7393,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7467,7 +7467,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8124,7 +8124,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 48aa95609..5e1056a9a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -62,12 +62,12 @@ windows = { version = "0.61", features = [ tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] } [target.'cfg(any(windows, target_os = "linux"))'.dependencies] -tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "df09f08e64b840e4d51cde2720030836eed40f4c" } +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "4ea297bdeffaba6a8e2e4386a18fbd4dd59fa0f0" } # default-features = false drops notify-rust so macOS uses the native # UNUserNotificationCenter backend (needs a signed .app to deliver). [target.'cfg(target_os = "macos")'.dependencies] -tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "df09f08e64b840e4d51cde2720030836eed40f4c", default-features = false } +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "4ea297bdeffaba6a8e2e4386a18fbd4dd59fa0f0", default-features = false } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" @@ -88,7 +88,7 @@ cef = { version = "=148.0.0", optional = true } libloading = "0.8" [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] -tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "df09f08e64b840e4d51cde2720030836eed40f4c", features = [ +tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugin-notifications.git", rev = "4ea297bdeffaba6a8e2e4386a18fbd4dd59fa0f0", features = [ "push-notifications", ] } tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" } From 2f2ce07d03adac014828b04cec71bf560de7d301 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 20 Jul 2026 15:25:41 -0500 Subject: [PATCH 5/5] Delete tauri-media-caching-streaming.md --- .changeset/tauri-media-caching-streaming.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/tauri-media-caching-streaming.md diff --git a/.changeset/tauri-media-caching-streaming.md b/.changeset/tauri-media-caching-streaming.md deleted file mode 100644 index 2932f32e0..000000000 --- a/.changeset/tauri-media-caching-streaming.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -default: minor ---- - -Cache media responses and stream video/audio by byte range under Tauri for faster playback and lower bandwidth. Files larger than the cache budget are served without being persisted, so they no longer evict the whole cache on write.