From b23625075c0997a7851dec86e935eba0b8f7b86d Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Wed, 22 Jul 2026 08:14:19 +0200 Subject: [PATCH 1/8] fix(tauri): deduplicate concurrent media fetches --- src-tauri/src/network/media_protocol.rs | 65 ++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/network/media_protocol.rs b/src-tauri/src/network/media_protocol.rs index 5383a7ced..c61776233 100644 --- a/src-tauri/src/network/media_protocol.rs +++ b/src-tauri/src/network/media_protocol.rs @@ -1,8 +1,9 @@ use std::{ + collections::HashMap, fs, io::{Read, Seek, SeekFrom}, path::{Path, PathBuf}, - sync::{OnceLock, RwLock}, + sync::{Arc, Mutex, OnceLock, RwLock, Weak}, time::Duration, }; @@ -15,7 +16,7 @@ use tauri_plugin_http::reqwest::{ header::{AUTHORIZATION, CONTENT_TYPE}, Client, Url, }; -use tokio::sync::Semaphore; +use tokio::sync::{Mutex as AsyncMutex, Semaphore}; pub const MEDIA_URI_SCHEME: &str = "sable-media"; @@ -31,6 +32,7 @@ pub struct MediaSessionState { inner: RwLock>, client: OnceLock, semaphore: Semaphore, + cache_miss_gates: Mutex>>>, } impl Default for MediaSessionState { @@ -39,6 +41,7 @@ impl Default for MediaSessionState { inner: RwLock::new(None), client: OnceLock::new(), semaphore: Semaphore::new(MAX_CONCURRENT_REQUESTS), + cache_miss_gates: Mutex::new(HashMap::new()), } } } @@ -56,6 +59,21 @@ impl MediaSessionState { }) .clone() } + + fn cache_miss_gate(&self, key: &str) -> Result>, StatusCode> { + let mut gates = self + .cache_miss_gates + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + gates.retain(|_, gate| gate.strong_count() > 0); + if let Some(gate) = gates.get(key).and_then(Weak::upgrade) { + return Ok(gate); + } + + let gate = Arc::new(AsyncMutex::new(())); + gates.insert(key.to_owned(), Arc::downgrade(&gate)); + Ok(gate) + } } #[derive(Clone)] @@ -155,11 +173,6 @@ async fn handle_request( let content_type_path = dir.join(format!("{key}.ct")); let state = app.state::(); - let _permit = state - .semaphore - .acquire() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Cache to disk first, then serve from there so Range works regardless of // homeserver support. Files larger than the cache budget are served from @@ -168,6 +181,7 @@ async fn handle_request( let (content_type, in_memory_body) = ensure_cached( &state, &session, + &key, media_url, dir, body_path.clone(), @@ -189,6 +203,7 @@ async fn handle_request( async fn ensure_cached( state: &MediaSessionState, session: &MediaSession, + key: &str, media_url: Url, dir: PathBuf, body_path: PathBuf, @@ -200,6 +215,20 @@ async fn ensure_cached( return Ok((content_type, None)); } + let gate = state.cache_miss_gate(key)?; + let _gate_guard = gate.lock().await; + if let Some(content_type) = + read_content_type(body_path.clone(), content_type_path.clone()).await + { + return Ok((content_type, None)); + } + + let _permit = state + .semaphore + .acquire() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let upstream = state .client() .get(media_url) @@ -466,12 +495,34 @@ fn cache_key(session_token: &str, url: &str) -> String { #[cfg(test)] mod tests { + use std::sync::Arc; + use tauri::http::{header, StatusCode}; use super::{ cache_key, fits_in_cache, ok_response, parse_range, partial_response, serve_range_memory, + MediaSessionState, }; + #[test] + fn cache_miss_gates_are_reused_and_expired_entries_are_cleaned() { + let state = MediaSessionState::default(); + let first = state.cache_miss_gate("first").unwrap(); + let same = state.cache_miss_gate("first").unwrap(); + let different = state.cache_miss_gate("different").unwrap(); + + assert!(Arc::ptr_eq(&first, &same)); + assert!(!Arc::ptr_eq(&first, &different)); + + drop(first); + drop(same); + let _third = state.cache_miss_gate("third").unwrap(); + let gates = state.cache_miss_gates.lock().unwrap(); + assert!(!gates.contains_key("first")); + assert!(gates.contains_key("different")); + assert!(gates.contains_key("third")); + } + #[test] fn cache_key_is_stable_and_hex() { let url = "https://matrix.example.org/_matrix/client/v1/media/download/x/y"; From e6b16c883c34abe41ee635332de6e70266711c59 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Wed, 22 Jul 2026 15:02:49 +0200 Subject: [PATCH 2/8] fix(media): reduce loading contention --- src-tauri/src/network/media_protocol.rs | 19 ++++++++++++++++--- .../components/url-preview/UrlPreviewCard.tsx | 3 ++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/network/media_protocol.rs b/src-tauri/src/network/media_protocol.rs index c61776233..898a5454d 100644 --- a/src-tauri/src/network/media_protocol.rs +++ b/src-tauri/src/network/media_protocol.rs @@ -24,7 +24,7 @@ const MEDIA_PATH_PREFIXES: [&str; 2] = ["/_matrix/media/", "/_matrix/client/v1/m 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_CONCURRENT_REQUESTS: usize = 4; const MAX_CACHE_BYTES: u64 = 512 * 1024 * 1024; const MAX_RANGE_CHUNK: u64 = 2 * 1024 * 1024; @@ -439,7 +439,9 @@ fn media_response_builder(status: StatusCode, content_type: &str) -> ResponseBui } fn ok_response(body: Vec, content_type: &str) -> Response> { + let content_length = body.len(); media_response_builder(StatusCode::OK, content_type) + .header(header::CONTENT_LENGTH, content_length) .body(body) .expect("failed to build media response") } @@ -451,7 +453,9 @@ fn partial_response( 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}"), @@ -542,8 +546,13 @@ mod tests { } #[test] - fn protocol_responses_are_privately_cacheable_by_the_webview() { - let response = ok_response(Vec::new(), "image/png"); + fn ok_response_has_content_length_and_cache_headers() { + let response = ok_response(vec![0_u8; 42], "image/png"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_LENGTH).unwrap(), + "42" + ); assert_eq!( response.headers().get(header::CACHE_CONTROL).unwrap(), "private, max-age=31536000, immutable" @@ -578,6 +587,10 @@ mod tests { 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_LENGTH).unwrap(), + "100" + ); assert_eq!( response.headers().get(header::CONTENT_RANGE).unwrap(), "bytes 0-99/5000" diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 9e4540670..c2d6e7987 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -86,6 +86,7 @@ export const UrlPreviewCard = as< const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [linkPreviewImageMaxHeight] = useSetting(settingsAtom, 'linkPreviewImageMaxHeight'); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [previewStatus, loadPreview] = useAsyncCallback( useCallback(() => { @@ -281,7 +282,7 @@ export const UrlPreviewCard = as< }} mediaLayout="contained" fillsPreviewSlot - autoPlay + autoPlay={mediaAutoLoad} onAuxClick={handleAuxClick} body={prev['og:title']} url={prev['og:image']} From 6d1dd90110404cf69674dab1300aae1dc5a29cc3 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Wed, 22 Jul 2026 09:05:18 +0200 Subject: [PATCH 3/8] fix: restore desktop SSO callback --- src/app/pages/auth/SSOTauri.test.ts | 19 +++++++++++++++++++ src/app/pages/auth/SSOTauri.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/app/pages/auth/SSOTauri.test.ts b/src/app/pages/auth/SSOTauri.test.ts index 377f4680e..c16e9082b 100644 --- a/src/app/pages/auth/SSOTauri.test.ts +++ b/src/app/pages/auth/SSOTauri.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type as osType } from '@tauri-apps/plugin-os'; import { buildTauriSsoRedirectUrl, parseTauriOidcCallback, @@ -12,10 +13,28 @@ vi.mock('@tauri-apps/plugin-os', () => ({ })); beforeEach(() => { + vi.unstubAllEnvs(); + vi.mocked(osType).mockReturnValue('android'); localStorage.clear(); }); describe('buildTauriSsoRedirectUrl', () => { + it('uses the registered deep-link callback in production desktop builds', () => { + vi.stubEnv('DEV', false); + vi.mocked(osType).mockReturnValue('linux'); + + const url = new URL(buildTauriSsoRedirectUrl('https://hs.example')); + + expect(url.protocol).toBe('sable:'); + expect(url.hostname).toBe('login'); + expect(url.pathname).toBe('/lp/sso-callback'); + expect(url.searchParams.get('server')).toBe('https://hs.example'); + const nonce = url.searchParams.get('sso_nonce'); + expect(nonce).toBeTruthy(); + expect(takeTauriSsoNonce()).toBe(nonce); + expect(takeTauriSsoNonce()).toBeUndefined(); + }); + it('embeds the server and stores the same nonce it puts in the url', () => { const url = new URL(buildTauriSsoRedirectUrl('https://hs.example')); expect(url.searchParams.get('server')).toBe('https://hs.example'); diff --git a/src/app/pages/auth/SSOTauri.ts b/src/app/pages/auth/SSOTauri.ts index 6a60baa28..4100a75d4 100644 --- a/src/app/pages/auth/SSOTauri.ts +++ b/src/app/pages/auth/SSOTauri.ts @@ -16,7 +16,7 @@ const getAppBaseUrl = (): string => { return `${TAURI_SSO_PROTOCOL}//${TAURI_SSO_HOST}`; } - return 'https://app.sable.moe'; + return `${TAURI_SSO_PROTOCOL}//${TAURI_SSO_HOST}`; }; type TauriSsoCallback = { From 7c66892f30c3b5ac3f4ec9373a516ea8585c2895 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Wed, 22 Jul 2026 18:03:13 +0200 Subject: [PATCH 4/8] fix(media): recover authenticated video sessions --- .../improve-authenticated-media-loading.md | 5 + src/app/pages/client/ClientRoot.tsx | 4 +- src/app/utils/matrix.test.ts | 47 ++++++- src/app/utils/matrix.ts | 28 +++- src/app/utils/mediaTransport.test.ts | 14 +- src/app/utils/mediaTransport.ts | 4 - src/app/utils/tauriMediaAuth.test.ts | 85 ++++++++++++ src/app/utils/tauriMediaAuth.ts | Bin 866 -> 1366 bytes src/client/initMatrix.test.ts | 26 ++++ src/client/initMatrix.ts | 28 +++- src/client/oidcTokenRefresher.ts | 2 +- src/index.tsx | 3 +- src/sw-media-auth-recovery.test.ts | 102 ++++++++++++++ src/sw-session.ts | 23 ++-- src/sw.ts | 124 +++++++++++++----- 15 files changed, 421 insertions(+), 74 deletions(-) create mode 100644 .changeset/improve-authenticated-media-loading.md create mode 100644 src/app/utils/tauriMediaAuth.test.ts create mode 100644 src/client/initMatrix.test.ts create mode 100644 src/sw-media-auth-recovery.test.ts diff --git a/.changeset/improve-authenticated-media-loading.md b/.changeset/improve-authenticated-media-loading.md new file mode 100644 index 000000000..6248b3f08 --- /dev/null +++ b/.changeset/improve-authenticated-media-loading.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Improve authenticated media loading across web and Tauri, especially for videos on slow or unstable connections. diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 78a7ea43a..f86e518b2 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -272,7 +272,7 @@ export function ClientRoot({ children }: ClientRootProps) { log.log('initClient for', activeSession.userId); const newMx = await initClient(activeSession); loadedUserIdRef.current = activeSession.userId; - pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); + await pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); return newMx; }, [activeSession, activeSessionId, setActiveSessionId]) ); @@ -311,7 +311,7 @@ export function ClientRoot({ children }: ClientRootProps) { activeSession.userId, '— reloading client' ); - pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); + void pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); if (mx?.clientRunning) { stopClient(mx); } diff --git a/src/app/utils/matrix.test.ts b/src/app/utils/matrix.test.ts index a65a3ede8..0f3f2a1f1 100644 --- a/src/app/utils/matrix.test.ts +++ b/src/app/utils/matrix.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MatrixClient } from '$types/matrix-sdk'; const tauriApi = vi.hoisted(() => ({ isTauri: vi.fn<() => boolean>(), @@ -15,7 +16,7 @@ const mediaTransport = vi.hoisted(() => ({ vi.mock('@tauri-apps/api/core', () => tauriApi); vi.mock('./mediaTransport', () => mediaTransport); -const { rewriteAuthenticatedMediaUrl } = await import('./matrix'); +const { mxcUrlToHttp, rewriteAuthenticatedMediaUrl } = await import('./matrix'); describe('rewriteAuthenticatedMediaUrl', () => { beforeEach(() => { @@ -59,6 +60,36 @@ describe('rewriteAuthenticatedMediaUrl', () => { ); }); + it.each([ + '/_matrix/media/v3/download/example.org/abc123', + '/_matrix/media/v3/thumbnail/example.org/abc123?width=96&height=96&method=crop', + '/_matrix/media/r0/download/example.org/abc123', + '/_matrix/media/r0/thumbnail/example.org/abc123?width=96&height=96&method=crop', + ])('rewrites legacy media paths under Tauri: %s', (path) => { + tauriApi.isTauri.mockReturnValue(true); + const url = `https://matrix.example.org${path}`; + const separator = url.includes('?') ? '&' : '?'; + expect(rewriteAuthenticatedMediaUrl(url)).toBe( + `sable-media://${url}${separator}__sable_media_cache=2&__sable_media_session=%40user%3Aexample.com` + ); + }); + + it('does not rewrite unrelated Matrix media URLs', () => { + tauriApi.isTauri.mockReturnValue(true); + const url = 'https://matrix.example.org/_matrix/media/v3/config'; + expect(rewriteAuthenticatedMediaUrl(url)).toBe(url); + expect(tauriApi.convertFileSrc).not.toHaveBeenCalled(); + }); + + it.each([ + 'https://example.org/avatar.png?next=/_matrix/media/v3/download/example.org/abc123', + 'https://example.org/avatar.png#/_matrix/media/r0/thumbnail/example.org/abc123', + ])('does not rewrite a media path only present in query or hash: %s', (url) => { + tauriApi.isTauri.mockReturnValue(true); + expect(rewriteAuthenticatedMediaUrl(url)).toBe(url); + expect(tauriApi.convertFileSrc).not.toHaveBeenCalled(); + }); + it('passes through already-rewritten sable-media:// URLs', () => { tauriApi.isTauri.mockReturnValue(true); const url = @@ -69,3 +100,17 @@ describe('rewriteAuthenticatedMediaUrl', () => { expect(tauriApi.convertFileSrc).not.toHaveBeenCalled(); }); }); + +describe('mxcUrlToHttp', () => { + it('rewrites SDK legacy media URLs under Tauri without useAuthentication', () => { + tauriApi.isTauri.mockReturnValue(true); + const legacyUrl = 'https://matrix.example.org/_matrix/media/v3/download/example.org/video'; + const mx = { + mxcUrlToHttp: vi.fn<() => string>(() => legacyUrl), + } as unknown as MatrixClient; + + expect(mxcUrlToHttp(mx, 'mxc://example.org/video', false)).toBe( + `sable-media://${legacyUrl}?__sable_media_cache=2&__sable_media_session=%40user%3Aexample.com` + ); + }); +}); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 44907ce42..106ddb2ef 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -36,6 +36,13 @@ import { const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; const TAURI_MEDIA_CACHE_VERSION = '__sable_media_cache=2'; +const TAURI_MEDIA_PATH_PREFIXES = [ + '/_matrix/client/v1/media/', + '/_matrix/media/v3/download/', + '/_matrix/media/v3/thumbnail/', + '/_matrix/media/r0/download/', + '/_matrix/media/r0/thumbnail/', +]; export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName); @@ -478,7 +485,22 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): export const rewriteAuthenticatedMediaUrl = (httpUrl: string | null): string | null => { if (!httpUrl) return null; if (!isTauri()) return httpUrl; - if (!httpUrl.includes('/_matrix/client/v1/media/')) return httpUrl; + const sourceUrl = httpUrl.startsWith('sable-media://') + ? httpUrl.slice('sable-media://'.length) + : httpUrl; + let parsedUrl: URL; + try { + parsedUrl = new URL(sourceUrl); + } catch { + return httpUrl; + } + if ( + (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') || + parsedUrl.origin === 'null' || + !TAURI_MEDIA_PATH_PREFIXES.some((path) => parsedUrl.pathname.startsWith(path)) + ) { + return httpUrl; + } if (httpUrl.includes(TAURI_MEDIA_CACHE_VERSION)) return httpUrl; const mediaUrl = httpUrl.startsWith('sable-media://') ? httpUrl @@ -508,9 +530,7 @@ export const mxcUrlToHttp = ( useAuthentication ); - // Authenticated media has no service worker under Tauri to attach the token, so route it - // through the native sable-media:// protocol which injects the token in Rust. - if (httpUrl && useAuthentication) { + if (httpUrl && isTauri()) { return rewriteAuthenticatedMediaUrl(httpUrl); } return httpUrl; diff --git a/src/app/utils/mediaTransport.test.ts b/src/app/utils/mediaTransport.test.ts index 95cd1e140..90a334b76 100644 --- a/src/app/utils/mediaTransport.test.ts +++ b/src/app/utils/mediaTransport.test.ts @@ -356,7 +356,7 @@ describe('fetchMediaBlob', () => { expect(headersSeen).toEqual([null]); }); - it('retries once on the service worker path without direct auth headers', async () => { + it('fetches once on the service worker path when it returns an auth error', async () => { platform.hasControllingServiceWorker.mockReturnValue(true); const { fetchMediaBlob } = await import('./mediaTransport'); const url = 'https://example.org/auth-media.png'; @@ -365,17 +365,13 @@ describe('fetchMediaBlob', () => { vi.mocked(fetch).mockImplementation(async (_input, init) => { const headers = new Headers(init?.headers); headersSeen.push(headers.get('authorization')); - if (headersSeen.length === 1) { - return new Response('denied', { status: 403 }); - } - return new Response('ok', { status: 200 }); + return new Response('denied', { status: 403 }); }); - const blob = await fetchMediaBlob(url); + await expect(fetchMediaBlob(url)).rejects.toThrow('Failed to fetch media: 403'); - expect(await blob.text()).toBe('ok'); - expect(headersSeen).toEqual([null, null]); - expect(fetch).toHaveBeenCalledTimes(2); + expect(headersSeen).toEqual([null]); + expect(fetch).toHaveBeenCalledTimes(1); }); it('bypasses the service worker path when explicit auth overrides are provided', async () => { diff --git a/src/app/utils/mediaTransport.ts b/src/app/utils/mediaTransport.ts index d253c7598..6dc349941 100644 --- a/src/app/utils/mediaTransport.ts +++ b/src/app/utils/mediaTransport.ts @@ -296,10 +296,6 @@ async function fetchMediaBlobInternal(url: string, options?: MediaTransportOptio }; if (useServiceWorker) { - const response = await fetchMediaResponse(url, undefined, cacheMode); - if (response.ok || !isRetryableAuthError(response)) { - return fetchAndCache(response); - } return fetchAndCache(await fetchMediaResponse(url, undefined, cacheMode)); } diff --git a/src/app/utils/tauriMediaAuth.test.ts b/src/app/utils/tauriMediaAuth.test.ts new file mode 100644 index 000000000..124fc8354 --- /dev/null +++ b/src/app/utils/tauriMediaAuth.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const tauriApi = vi.hoisted(() => ({ + isTauri: vi.fn<() => boolean>(), +})); +const commands = vi.hoisted(() => ({ + clearMediaSession: vi.fn<() => Promise>(), + setMediaSession: + vi.fn<({ baseUrl, token }: { baseUrl: string; token: string }) => Promise>(), +})); +const mediaTransport = vi.hoisted(() => ({ + getActiveMediaSession: vi.fn<() => { baseUrl: string; accessToken: string } | undefined>(), +})); + +vi.mock('@tauri-apps/api/core', () => tauriApi); +vi.mock('$generated/tauri/commands', () => commands); +vi.mock('./mediaTransport', () => mediaTransport); + +const { initTauriMediaSession, updateTauriMediaSession } = await import('./tauriMediaAuth'); + +describe('Tauri media session coordinator', () => { + beforeEach(() => { + vi.clearAllMocks(); + tauriApi.isTauri.mockReturnValue(true); + commands.clearMediaSession.mockResolvedValue(); + commands.setMediaSession.mockResolvedValue(); + }); + + it('serializes writes and applies the last requested state', async () => { + let resolveFirst: (() => void) | undefined; + commands.setMediaSession + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + .mockResolvedValueOnce(); + + const first = updateTauriMediaSession('https://one.example', 'one'); + const second = updateTauriMediaSession('https://two.example', 'two'); + const clear = updateTauriMediaSession(); + + await Promise.resolve(); + expect(commands.setMediaSession).toHaveBeenCalledTimes(1); + resolveFirst?.(); + await Promise.all([first, second, clear]); + + expect(commands.setMediaSession).toHaveBeenNthCalledWith(1, { + baseUrl: 'https://one.example', + token: 'one', + }); + expect(commands.setMediaSession).toHaveBeenNthCalledWith(2, { + baseUrl: 'https://two.example', + token: 'two', + }); + expect(commands.clearMediaSession).toHaveBeenCalledTimes(1); + }); + + it('waits for the initial active session write', async () => { + let resolveWrite: (() => void) | undefined; + mediaTransport.getActiveMediaSession.mockReturnValue({ + baseUrl: 'https://matrix.example', + accessToken: 'token', + }); + commands.setMediaSession.mockImplementation( + () => + new Promise((resolve) => { + resolveWrite = resolve; + }) + ); + + const ready = initTauriMediaSession(); + let complete = false; + void ready.then(() => { + complete = true; + }); + await Promise.resolve(); + expect(complete).toBe(false); + + resolveWrite?.(); + await ready; + expect(complete).toBe(true); + }); +}); diff --git a/src/app/utils/tauriMediaAuth.ts b/src/app/utils/tauriMediaAuth.ts index 7b2c8de31ef50cc5f9bc448c4f2a38a863f1d2ee..e7281cb5ead42ef37a92d435b1bc1ca1dbbaea52 100644 GIT binary patch literal 1366 zcmbVMOK;Oa5WeSE3=*nsDYfr`v>*YefDmoPdFq`yE9_lscbyPL`R~j=YB_a^1UbZ7 zXJ)>~d>h&I+Bi6ZwD&j|2`6|mx`XV4^T%uKds|^It6Ce%-Yw*Fecck8uOuYik+o8* zC0KIP7vre64Jk6{NK|1c&+0lj)^#+{2=E9;=u4XmM_pyXMU7&@+TaNDfl@|GO*q=Zz>H}pV1jdZkv?`(^-^nRzM zxZzRhTpF^v-BIp)>3485A`ZP^MZwiGu4~92(b9uyZ&zTQVW1^oU9*k%`WLD3Z7~DA zf#U)|K0*E_?JF+|FyscKE)oKWT=rfAlb#DJ-Ii32_MmFW3s^@80A~&X4EW;#6J@x% znsP118UWm*bT31YA0c%v!FhG7X*-nJ{S)c`NVdn2*xvd4uHm9hQRQALq4y;U@p;Ed*huUA d-%_4grwulgEzk1rule7`#pEXICM1Fle*lqD%Qyf4 literal 866 zcmah{L2lbH5bRm6m;wbP0j_;_s-!vO)&c?Qo)eZ@JB!GqK~i=NSAXw{qGcsWTHUys zot@bo-tMdmIDyUHKGO{BSE_Z44r^z($iIgE`C09DexY`}(AH6YmGQZ6L*Z5j-qUxg zS^XgI*_yF?zS#EVl~cy|gQPQSD4Lq+|Y&Q6V*3`6SL$!eDchsf)(P*q& zBO`>ie?@uIVMV#by=Y}mU-p*kYn(EG*C;+FGeud#QD~hJW4<7 { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem(MATRIX_SESSIONS_KEY, JSON.stringify([alice, bob])); + }); + + it('keeps Alice media session while logging out secondary Bob', () => { + localStorage.setItem(ACTIVE_SESSION_KEY, JSON.stringify(alice.userId)); + + expect(ownsActiveMediaSession(bob)).toBe(false); + }); + + it('clears the active account media session', () => { + localStorage.setItem(ACTIVE_SESSION_KEY, JSON.stringify(alice.userId)); + + expect(ownsActiveMediaSession(alice)).toBe(true); + }); +}); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 3d556e794..f1edf6f3f 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -16,8 +16,14 @@ import { fetch } from '$utils/fetch'; import { clearMediaCache } from '$utils/mediaCache'; import { clearNavToActivePathStore } from '$state/navToActivePath'; -import type { Session, SessionStoreName } from '$state/sessions'; -import { getSessionStoreName, getStoredSessionRefreshToken } from '$state/sessions'; +import type { Session, Sessions, SessionStoreName } from '$state/sessions'; +import { + ACTIVE_SESSION_KEY, + getSessionStoreName, + getStoredSessionRefreshToken, + MATRIX_SESSIONS_KEY, +} from '$state/sessions'; +import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import * as Sentry from '@sentry/react'; @@ -43,6 +49,14 @@ const debugLog = createDebugLogger('initMatrix'); const slidingSyncByClient = new WeakMap(); const membershipActionCleanupByClient = new WeakMap void>(); const presenceSyncByClient = new WeakMap(); + +export const ownsActiveMediaSession = (session?: Session): boolean => { + if (!session) return true; + const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []); + const activeSessionId = getLocalStorageItem(ACTIVE_SESSION_KEY, undefined); + const activeSession = sessions.find((item) => item.userId === activeSessionId) ?? sessions[0]; + return activeSession?.userId === session.userId; +}; const presenceStartCleanupByClient = new WeakMap void>(); const SLIDING_SYNC_POLL_TIMEOUT_MS = 45000; @@ -584,7 +598,6 @@ export const logoutClient = async (mx: MatrixClient, session?: Session) => { sessionUserId: session?.userId, }); debugLog.info('general', 'Logging out client', { userId: mx.getUserId() }); - pushSessionToSW(); stopClient(mx); try { if (session?.oidc) { @@ -611,7 +624,14 @@ export const logoutClient = async (mx: MatrixClient, session?: Session) => { window.localStorage.clear(); } - await clearMediaCache(); + try { + await clearMediaCache(); + } finally { + if (ownsActiveMediaSession(session)) { + // Queue the final clear after any in-flight refresh. + await pushSessionToSW(); + } + } }; export const clearLoginData = async () => { diff --git a/src/client/oidcTokenRefresher.ts b/src/client/oidcTokenRefresher.ts index 8e2db8493..a97861314 100644 --- a/src/client/oidcTokenRefresher.ts +++ b/src/client/oidcTokenRefresher.ts @@ -40,7 +40,7 @@ export const createSessionTokenRefresher = ( // Only the active session owns the single service-worker session. const activeSessionId = getLocalStorageItem(ACTIVE_SESSION_KEY, undefined); if (activeSessionId === session.userId) { - pushSessionToSW(session.baseUrl, tokens.accessToken, session.userId); + await pushSessionToSW(session.baseUrl, tokens.accessToken, session.userId); } }; diff --git a/src/index.tsx b/src/index.tsx index 43e5e7136..95d0fb962 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -40,8 +40,6 @@ installAndroidBackBridge(); registerAppServiceWorker(); -initTauriMediaSession(); - if (hasServiceWorker()) { const controllerRefreshed = localStorage.getItem('controllerRefreshed') === 'true'; @@ -121,4 +119,5 @@ const mountApp = () => { root.render(); }; +await initTauriMediaSession(); mountApp(); diff --git a/src/sw-media-auth-recovery.test.ts b/src/sw-media-auth-recovery.test.ts new file mode 100644 index 000000000..75e4df8a7 --- /dev/null +++ b/src/sw-media-auth-recovery.test.ts @@ -0,0 +1,102 @@ +/* oxlint-disable vitest/require-mock-type-parameters */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { swTestHooks as swTestHooksHelper } from './sw'; + +vi.mock('workbox-precaching', () => ({ + cleanupOutdatedCaches: vi.fn(), + precacheAndRoute: vi.fn(), +})); + +type SwTestHooks = typeof swTestHooksHelper; + +describe('service worker media auth recovery', () => { + let swTestHooks: SwTestHooks; + let clients: Map; + + beforeEach(async () => { + vi.resetModules(); + clients = new Map(); + vi.stubGlobal('self', { + __WB_MANIFEST: [], + addEventListener: vi.fn(), + caches: { + open: vi.fn(async () => ({ + delete: vi.fn(async () => true), + match: vi.fn(async () => undefined), + put: vi.fn(async () => undefined), + })), + }, + clients: { + claim: vi.fn(), + get: vi.fn(async (id: string) => clients.get(id)), + matchAll: vi.fn(async () => Array.from(clients.values())), + }, + registration: {}, + }); + vi.stubGlobal('fetch', vi.fn()); + swTestHooks = (await import('./sw')).swTestHooks; + }); + + it('shares recovery and preserves each Range header on retry', async () => { + const client = { + id: 'client-a', + postMessage: vi.fn(), + } as unknown as Client; + clients.set(client.id, client); + + const initialSession = { accessToken: 'old-token', baseUrl: 'https://matrix.example.org' }; + const ranges: Array<{ authorization: string | null; range: string | null }> = []; + vi.mocked(fetch).mockImplementation(async (_input, init) => { + const headers = new Headers(init?.headers); + ranges.push({ authorization: headers.get('authorization'), range: headers.get('range') }); + return new Response('', { + status: headers.get('authorization') === 'Bearer old-token' ? 401 : 206, + }); + }); + + const first = new Request( + 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/media-id', + { headers: { Range: 'bytes=0-99' } } + ); + const second = new Request(first.url, { headers: { Range: 'bytes=100-199' } }); + const firstRecovery = swTestHooks.respondWithMediaAuthRecovery( + first, + initialSession, + 'follow', + client.id + ); + const secondRecovery = swTestHooks.respondWithMediaAuthRecovery( + second, + initialSession, + 'follow', + client.id + ); + + await vi.waitFor(() => expect(client.postMessage).toHaveBeenCalledTimes(1)); + swTestHooks.setSession(client.id, 'new-token', initialSession.baseUrl); + + await expect(firstRecovery).resolves.toHaveProperty('status', 206); + await expect(secondRecovery).resolves.toHaveProperty('status', 206); + expect(ranges).toEqual([ + { authorization: 'Bearer old-token', range: 'bytes=0-99' }, + { authorization: 'Bearer old-token', range: 'bytes=100-199' }, + { authorization: 'Bearer new-token', range: 'bytes=0-99' }, + { authorization: 'Bearer new-token', range: 'bytes=100-199' }, + ]); + }); + + it('posts a new session request after a previous request times out', async () => { + const client = { + id: 'client-timeout', + postMessage: vi.fn(), + } as unknown as Client; + clients.set(client.id, client); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + await expect(swTestHooks.requestSessionWithTimeout(client.id, 1)).resolves.toBeUndefined(); + const nextRequest = swTestHooks.requestSessionWithTimeout(client.id, 1000); + await vi.waitFor(() => expect(client.postMessage).toHaveBeenCalledTimes(2)); + swTestHooks.setSession(client.id, 'new-token', 'https://matrix.example.org'); + await expect(nextRequest).resolves.toMatchObject({ accessToken: 'new-token' }); + }); +}); diff --git a/src/sw-session.ts b/src/sw-session.ts index b5815dd81..36d5ac9cc 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -1,19 +1,19 @@ import { isTauri } from '@tauri-apps/api/core'; -import { clearMediaSession, setMediaSession } from '$generated/tauri/commands'; +import { updateTauriMediaSession } from './app/utils/tauriMediaAuth'; -export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string) { +export function pushSessionToSW( + baseUrl?: string, + accessToken?: string, + userId?: string +): Promise { if (isTauri()) { - // No service worker under Tauri; hand the token to the native sable-media protocol instead. - if (baseUrl && accessToken) { - void setMediaSession({ baseUrl, token: accessToken }).catch(() => undefined); - } else { - void clearMediaSession().catch(() => undefined); - } - return; + // Tauri has no service worker. + return updateTauriMediaSession(baseUrl, accessToken); } - if (!('serviceWorker' in navigator)) return; - if (!navigator.serviceWorker.controller) return; + if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) { + return Promise.resolve(); + } navigator.serviceWorker.controller.postMessage({ type: 'setSession', @@ -22,4 +22,5 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: userId, // oxlint-disable-next-line unicorn/require-post-message-target-origin }); + return Promise.resolve(); } diff --git a/src/sw.ts b/src/sw.ts index d99ba4fe9..0fad55a1c 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -128,8 +128,11 @@ const sessions = new Map(); */ let preloadedSession: SessionInfo | undefined; -const clientToResolve = new Map void>(); -const clientToSessionPromise = new Map>(); +type PendingSessionRequest = { + promise: Promise; + resolve: (value: SessionInfo | undefined) => void; +}; +const pendingSessionRequests = new Map(); async function cleanupDeadClients() { const activeClients = await self.clients.matchAll(); @@ -138,8 +141,7 @@ async function cleanupDeadClients() { Array.from(sessions.keys()).forEach((id) => { if (!activeIds.has(id)) { sessions.delete(id); - clientToResolve.delete(id); - clientToSessionPromise.delete(id); + pendingSessionRequests.delete(id); } }); } @@ -165,26 +167,23 @@ function setSession(clientId: string, accessToken: unknown, baseUrl: unknown, us clearPersistedSession().catch(() => undefined); } - const resolveSession = clientToResolve.get(clientId); - if (resolveSession) { - resolveSession(sessions.get(clientId)); - clientToResolve.delete(clientId); - clientToSessionPromise.delete(clientId); + const pending = pendingSessionRequests.get(clientId); + if (pending) { + pending.resolve(sessions.get(clientId)); + pendingSessionRequests.delete(clientId); } } function requestSession(client: Client): Promise { - const promise = - clientToSessionPromise.get(client.id) ?? - new Promise((resolve) => { - clientToResolve.set(client.id, resolve); - client.postMessage({ type: 'requestSession' }); - }); - - if (!clientToSessionPromise.has(client.id)) { - clientToSessionPromise.set(client.id, promise); - } + const existing = pendingSessionRequests.get(client.id); + if (existing) return existing.promise; + let resolveSession!: (value: SessionInfo | undefined) => void; + const promise = new Promise((resolve) => { + resolveSession = resolve; + }); + pendingSessionRequests.set(client.id, { promise, resolve: resolveSession }); + client.postMessage({ type: 'requestSession' }); return promise; } @@ -199,15 +198,20 @@ async function requestSessionWithTimeout( } const sessionPromise = requestSession(client); - + let timeoutId: ReturnType | undefined; const timeout = new Promise((resolve) => { - setTimeout(() => { + timeoutId = setTimeout(() => { console.warn('[SW] requestSessionWithTimeout: timed out after', timeoutMs, 'ms', clientId); resolve(undefined); }, timeoutMs); }); - return Promise.race([sessionPromise, timeout]); + return Promise.race([sessionPromise, timeout]).finally(() => { + if (timeoutId !== undefined) clearTimeout(timeoutId); + if (pendingSessionRequests.get(clientId)?.promise === sessionPromise) { + pendingSessionRequests.delete(clientId); + } + }); } // --------------------------------------------------------------------------- @@ -684,11 +688,26 @@ function mediaPath(url: string): boolean { } } +function authenticatedMediaPath(url: string): boolean { + try { + return new URL(url).pathname.startsWith('/_matrix/client/v1/media/'); + } catch { + return false; + } +} + function validMediaRequest(url: string, baseUrl: string): boolean { - return MEDIA_PATHS.some((p) => { - const validUrl = new URL(p, baseUrl); - return url.startsWith(validUrl.href); - }); + try { + const requestUrl = new URL(url); + return MEDIA_PATHS.some((p) => { + const mediaUrl = new URL(p, baseUrl); + return ( + requestUrl.origin === mediaUrl.origin && requestUrl.pathname.startsWith(mediaUrl.pathname) + ); + }); + } catch { + return false; + } } function fetchConfig(token: string, request?: Request): RequestInit { @@ -757,6 +776,42 @@ function respondWithInflightMedia( ); } +async function respondWithMediaAuthRecovery( + request: Request, + session: SessionInfo, + redirect: RequestRedirect, + clientId?: string +): Promise { + const response = await respondWithInflightMedia(request, session.accessToken, redirect); + if ((response.status !== 401 && response.status !== 403) || !clientId) return response; + + // One exact-client retry; concurrent recoveries share this request. + const refreshed = await requestSessionWithTimeout(clientId); + if ( + !refreshed || + refreshed.accessToken === session.accessToken || + !validMediaRequest(request.url, refreshed.baseUrl) + ) { + return response; + } + + return respondWithInflightMedia(request, refreshed.accessToken, redirect); +} + +function unavailableAuthenticatedMediaResponse(): Response { + return new Response('Media session unavailable', { + status: 503, + statusText: 'Service Unavailable', + headers: { 'Cache-Control': 'no-store' }, + }); +} + +export const swTestHooks = { + requestSessionWithTimeout, + respondWithMediaAuthRecovery, + setSession, +}; + self.addEventListener('message', (event: ExtendableMessageEvent) => { if (event.data.type === 'togglePush') { const token = event.data?.token; @@ -806,7 +861,7 @@ self.addEventListener('fetch', (event: FetchEvent) => { const session = clientId ? sessions.get(clientId) : undefined; if (session && validMediaRequest(url, session.baseUrl)) { - event.respondWith(respondWithInflightMedia(event.request, session.accessToken, redirect)); + event.respondWith(respondWithMediaAuthRecovery(event.request, session, redirect, clientId)); return; } @@ -826,7 +881,7 @@ self.addEventListener('fetch', (event: FetchEvent) => { ? preloadedSession : undefined); if (byBaseUrl) { - event.respondWith(respondWithInflightMedia(event.request, byBaseUrl.accessToken, redirect)); + event.respondWith(respondWithMediaAuthRecovery(event.request, byBaseUrl, redirect, clientId)); return; } @@ -836,8 +891,9 @@ self.addEventListener('fetch', (event: FetchEvent) => { event.respondWith( loadPersistedSession().then((persisted) => { if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return respondWithInflightMedia(event.request, persisted.accessToken, redirect); + return respondWithMediaAuthRecovery(event.request, persisted, redirect); } + if (authenticatedMediaPath(url)) return unavailableAuthenticatedMediaResponse(); return fetch(event.request); }) ); @@ -848,19 +904,15 @@ self.addEventListener('fetch', (event: FetchEvent) => { requestSessionWithTimeout(clientId).then(async (s) => { // Primary: session received from the live client window. if (s && validMediaRequest(url, s.baseUrl)) { - return respondWithInflightMedia(event.request, s.accessToken, redirect); + return respondWithMediaAuthRecovery(event.request, s, redirect, clientId); } // Fallback: try the persisted session (helps when SW restarts on iOS and // the client window hasn't responded to requestSession yet). const persisted = await loadPersistedSession(); if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return respondWithInflightMedia(event.request, persisted.accessToken, redirect); + return respondWithMediaAuthRecovery(event.request, persisted, redirect, clientId); } - console.warn( - '[SW fetch] No valid session for media request', - { url, clientId, hasSession: !!s }, - 'falling back to unauthenticated fetch' - ); + if (authenticatedMediaPath(url)) return unavailableAuthenticatedMediaResponse(); return fetch(event.request); }) ); From d2a3f91701815c115ed200a89b389071ff6208c7 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 22 Jul 2026 11:29:47 -0500 Subject: [PATCH 5/8] fix: add temp cache for larger files and deduplicate inflight large files --- src-tauri/src/network/media_protocol.rs | 239 ++++++++++++++++++------ 1 file changed, 186 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/network/media_protocol.rs b/src-tauri/src/network/media_protocol.rs index 898a5454d..2e0f85f07 100644 --- a/src-tauri/src/network/media_protocol.rs +++ b/src-tauri/src/network/media_protocol.rs @@ -28,11 +28,16 @@ const MAX_CONCURRENT_REQUESTS: usize = 4; const MAX_CACHE_BYTES: u64 = 512 * 1024 * 1024; const MAX_RANGE_CHUNK: u64 = 2 * 1024 * 1024; +const TEMP_CACHE_SUBDIR: &str = "sable-media-temp"; +const MAX_TEMP_CACHE_BYTES: u64 = 2 * 1024 * 1024 * 1024; // 2 GiB + +type FetchResult = Result<(String, Option>>, PathBuf), StatusCode>; + pub struct MediaSessionState { inner: RwLock>, client: OnceLock, semaphore: Semaphore, - cache_miss_gates: Mutex>>>, + cache_miss_gates: Mutex>>>>, } impl Default for MediaSessionState { @@ -60,7 +65,7 @@ impl MediaSessionState { .clone() } - fn cache_miss_gate(&self, key: &str) -> Result>, StatusCode> { + fn cache_miss_gate(&self, key: &str) -> Result>>, StatusCode> { let mut gates = self .cache_miss_gates .lock() @@ -70,7 +75,7 @@ impl MediaSessionState { return Ok(gate); } - let gate = Arc::new(AsyncMutex::new(())); + let gate = Arc::new(AsyncMutex::new(None)); gates.insert(key.to_owned(), Arc::downgrade(&gate)); Ok(gate) } @@ -112,6 +117,9 @@ pub fn clear_media_session( if let Ok(dir) = cache_dir(&app) { let _ = fs::remove_dir_all(dir); } + if let Ok(temp_dir) = temp_cache_dir(&app) { + let _ = fs::remove_dir_all(temp_dir); + } } pub fn respond( @@ -169,60 +177,151 @@ async fn handle_request( let key = cache_key(&session.token, &target); let dir = cache_dir(app).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let body_path = dir.join(&key); - let content_type_path = dir.join(format!("{key}.ct")); + let temp_dir = temp_cache_dir(app).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let state = app.state::(); - // 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( + let (content_type, in_memory_body, disk_path) = ensure_cached( &state, &session, &key, media_url, dir, - body_path.clone(), - content_type_path, + temp_dir, ) .await?; match (range, in_memory_body) { (Some(range_header), Some(body)) => { - Ok(serve_range_memory(body, &content_type, &range_header)) + 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)) } - (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)), + (None, None) => Ok(ok_response(read_full(disk_path).await?, &content_type)), } } -// Ensure the body + content type are on disk, fetching from the homeserver on a miss. +// Ensure the body + content type are on disk (persistent or temporary), fetching from the homeserver on a miss. async fn ensure_cached( state: &MediaSessionState, session: &MediaSession, key: &str, media_url: Url, dir: PathBuf, - body_path: PathBuf, - content_type_path: PathBuf, -) -> Result<(String, Option>), StatusCode> { + temp_dir: PathBuf, +) -> Result<(String, Option>>, PathBuf), StatusCode> { + ensure_cached_with_limits( + state, + session, + key, + media_url, + dir, + temp_dir, + MAX_CACHE_BYTES, + MAX_TEMP_CACHE_BYTES, + ) + .await +} + +async fn ensure_cached_with_limits( + state: &MediaSessionState, + session: &MediaSession, + key: &str, + media_url: Url, + dir: PathBuf, + temp_dir: PathBuf, + max_persistent_cache_bytes: u64, + max_temp_cache_bytes: u64, +) -> Result<(String, Option>>, PathBuf), StatusCode> { + let body_path = dir.join(key); + let content_type_path = dir.join(format!("{key}.ct")); + let temp_body_path = temp_dir.join(key); + let temp_content_type_path = temp_dir.join(format!("{key}.ct")); + + // Fast path 1: Persistent cache hit if let Some(content_type) = read_content_type(body_path.clone(), content_type_path.clone()).await { - return Ok((content_type, None)); + return Ok((content_type, None, body_path)); + } + + // Fast path 2: Temporary cache hit (sequential range requests for oversized media) + if let Some(content_type) = + read_content_type(temp_body_path.clone(), temp_content_type_path.clone()).await + { + return Ok((content_type, None, temp_body_path)); } let gate = state.cache_miss_gate(key)?; - let _gate_guard = gate.lock().await; + let mut gate_guard = gate.lock().await; + + // Double-check persistent cache inside gate lock if let Some(content_type) = read_content_type(body_path.clone(), content_type_path.clone()).await { - return Ok((content_type, None)); + return Ok((content_type, None, body_path)); + } + + // Double-check temporary cache inside gate lock + if let Some(content_type) = + read_content_type(temp_body_path.clone(), temp_content_type_path.clone()).await + { + return Ok((content_type, None, temp_body_path)); + } + + // Double-check in-flight results + if let Some(res) = &*gate_guard { + return match res { + Ok((content_type, body_opt, path)) => { + Ok((content_type.clone(), body_opt.clone(), path.clone())) + } + Err(status) => Err(*status), + }; } + let fetch_res = fetch_and_cache( + state, + session, + media_url, + dir, + temp_dir, + body_path, + content_type_path, + temp_body_path, + temp_content_type_path, + max_persistent_cache_bytes, + max_temp_cache_bytes, + ) + .await; + + match &fetch_res { + Ok((content_type, body_opt, path)) => { + *gate_guard = Some(Ok((content_type.clone(), body_opt.clone(), path.clone()))); + fetch_res + } + Err(status) => { + *gate_guard = Some(Err(*status)); + Err(*status) + } + } +} + +async fn fetch_and_cache( + state: &MediaSessionState, + session: &MediaSession, + media_url: Url, + dir: PathBuf, + temp_dir: PathBuf, + body_path: PathBuf, + content_type_path: PathBuf, + temp_body_path: PathBuf, + temp_content_type_path: PathBuf, + max_persistent_cache_bytes: u64, + max_temp_cache_bytes: u64, +) -> Result<(String, Option>>, PathBuf), StatusCode> { let _permit = state .semaphore .acquire() @@ -239,7 +338,7 @@ async fn ensure_cached( if !upstream.status().is_success() { return Err( - StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY) + StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), ); } @@ -249,27 +348,71 @@ async fn ensure_cached( .and_then(|value| value.to_str().ok()) .unwrap_or("application/octet-stream") .to_owned(); + let body = upstream .bytes() .await .map_err(|_| StatusCode::BAD_GATEWAY)? .to_vec(); - if fits_in_cache(body.len() as u64) { + if body.len() as u64 > max_temp_cache_bytes { + return Ok((content_type, Some(Arc::new(body)), temp_body_path)); + } + + if body.len() as u64 <= max_persistent_cache_bytes { write_cache( - dir, - body_path, + dir.clone(), + body_path.clone(), content_type_path, body, content_type.clone(), + max_persistent_cache_bytes, ) .await; - Ok((content_type, None)) + Ok((content_type, None, body_path)) } else { - Ok((content_type, Some(body))) + // Oversized media: write to temporary session cache for sequential range requests + let written = write_cache( + temp_dir.clone(), + temp_body_path.clone(), + temp_content_type_path, + body.clone(), + content_type.clone(), + max_temp_cache_bytes, + ) + .await; + + if written { + Ok((content_type, None, temp_body_path)) + } else { + // Storage write failed or evicted; serve from memory fallback + Ok((content_type, Some(Arc::new(body)), temp_body_path)) + } } } +async fn write_cache( + dir: PathBuf, + body_path: PathBuf, + content_type_path: PathBuf, + body: Vec, + content_type: String, + max_bytes: u64, +) -> bool { + tokio::task::spawn_blocking(move || { + if fs::create_dir_all(&dir).is_ok() { + let res_body = fs::write(&body_path, &body); + let res_ct = fs::write(&content_type_path, &content_type); + evict_directory_if_needed(&dir, max_bytes); + res_body.is_ok() && res_ct.is_ok() && body_path.is_file() + } else { + false + } + }) + .await + .unwrap_or(false) +} + fn fits_in_cache(len: u64) -> bool { len <= MAX_CACHE_BYTES } @@ -326,7 +469,7 @@ async fn serve_range( // 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> { +fn serve_range_memory(body: &[u8], 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); @@ -367,25 +510,8 @@ fn parse_range(header: &str, total: u64) -> Option<(u64, u64)> { Some((start, end)) } -async fn write_cache( - dir: PathBuf, - body_path: PathBuf, - content_type_path: PathBuf, - body: Vec, - content_type: String, -) { - let _ = tokio::task::spawn_blocking(move || { - 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; -} - // Trim oldest-first when the cache exceeds its byte budget. -fn evict_if_needed(dir: &Path) { +fn evict_directory_if_needed(dir: &Path, max_bytes: u64) { let Ok(entries) = fs::read_dir(dir) else { return; }; @@ -404,13 +530,13 @@ fn evict_if_needed(dir: &Path) { .collect(); let mut total: u64 = files.iter().map(|(_, len, _)| *len).sum(); - if total <= MAX_CACHE_BYTES { + if total <= max_bytes { return; } files.sort_by_key(|(_, _, modified)| *modified); for (path, len, _) in files { - if total <= MAX_CACHE_BYTES { + if total <= max_bytes { break; } if fs::remove_file(&path).is_ok() { @@ -488,6 +614,13 @@ fn cache_dir(app: &AppHandle) -> Result { .map_err(|err| err.to_string()) } +fn temp_cache_dir(app: &AppHandle) -> Result { + app.path() + .app_cache_dir() + .map(|dir| dir.join(TEMP_CACHE_SUBDIR)) + .map_err(|err| err.to_string()) +} + fn cache_key(session_token: &str, url: &str) -> String { let mut hasher = Sha256::new(); hasher.update(session_token.as_bytes()); @@ -611,7 +744,7 @@ mod tests { #[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"); + let response = serve_range_memory(&body, "application/octet-stream", "bytes=10-19"); assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); assert_eq!(response.body(), &body[10..=19]); assert_eq!( @@ -623,7 +756,7 @@ mod tests { #[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-"); + 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; @@ -635,7 +768,7 @@ mod tests { #[test] fn serve_range_memory_rejects_unsatisfiable_range() { - let response = serve_range_memory(Vec::new(), "application/octet-stream", "bytes=0-0"); + let response = serve_range_memory(&[], "application/octet-stream", "bytes=0-0"); assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); } } From c5093f3e73667bafce8364067e5084e98a532cb2 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 22 Jul 2026 11:30:26 -0500 Subject: [PATCH 6/8] Delete improve-authenticated-media-loading.md --- .changeset/improve-authenticated-media-loading.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/improve-authenticated-media-loading.md diff --git a/.changeset/improve-authenticated-media-loading.md b/.changeset/improve-authenticated-media-loading.md deleted file mode 100644 index 6248b3f08..000000000 --- a/.changeset/improve-authenticated-media-loading.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -default: patch ---- - -Improve authenticated media loading across web and Tauri, especially for videos on slow or unstable connections. From f947dd5c781c26180c5e96b645a8757d5cd9a51b Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 22 Jul 2026 11:33:42 -0500 Subject: [PATCH 7/8] formatting --- src-tauri/src/network/media_protocol.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/network/media_protocol.rs b/src-tauri/src/network/media_protocol.rs index 2e0f85f07..5c6712b8b 100644 --- a/src-tauri/src/network/media_protocol.rs +++ b/src-tauri/src/network/media_protocol.rs @@ -65,7 +65,10 @@ impl MediaSessionState { .clone() } - fn cache_miss_gate(&self, key: &str) -> Result>>, StatusCode> { + fn cache_miss_gate( + &self, + key: &str, + ) -> Result>>, StatusCode> { let mut gates = self .cache_miss_gates .lock() @@ -181,15 +184,8 @@ async fn handle_request( let state = app.state::(); - let (content_type, in_memory_body, disk_path) = ensure_cached( - &state, - &session, - &key, - media_url, - dir, - temp_dir, - ) - .await?; + let (content_type, in_memory_body, disk_path) = + ensure_cached(&state, &session, &key, media_url, dir, temp_dir).await?; match (range, in_memory_body) { (Some(range_header), Some(body)) => { @@ -338,7 +334,7 @@ async fn fetch_and_cache( if !upstream.status().is_success() { return Err( - StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), + StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY) ); } From 3000ea16af9bb9d2ac46ecd7e11afab67db61c57 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 22 Jul 2026 11:38:34 -0500 Subject: [PATCH 8/8] clippy --- src-tauri/src/network/media_protocol.rs | 3 +++ src/app/generated/tauri/desktop/DesktopRuntimeState.ts | 2 +- src/app/generated/tauri/desktop/DesktopSettings.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/network/media_protocol.rs b/src-tauri/src/network/media_protocol.rs index 5c6712b8b..57960fec8 100644 --- a/src-tauri/src/network/media_protocol.rs +++ b/src-tauri/src/network/media_protocol.rs @@ -222,6 +222,7 @@ async fn ensure_cached( .await } +#[allow(clippy::too_many_arguments)] async fn ensure_cached_with_limits( state: &MediaSessionState, session: &MediaSession, @@ -305,6 +306,7 @@ async fn ensure_cached_with_limits( } } +#[allow(clippy::too_many_arguments)] async fn fetch_and_cache( state: &MediaSessionState, session: &MediaSession, @@ -409,6 +411,7 @@ async fn write_cache( .unwrap_or(false) } +#[allow(dead_code)] fn fits_in_cache(len: u64) -> bool { len <= MAX_CACHE_BYTES } diff --git a/src/app/generated/tauri/desktop/DesktopRuntimeState.ts b/src/app/generated/tauri/desktop/DesktopRuntimeState.ts index b0640f018..b5561254a 100644 --- a/src/app/generated/tauri/desktop/DesktopRuntimeState.ts +++ b/src/app/generated/tauri/desktop/DesktopRuntimeState.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DesktopRuntimeState = { trayAvailable: boolean }; +export type DesktopRuntimeState = { trayAvailable: boolean, }; diff --git a/src/app/generated/tauri/desktop/DesktopSettings.ts b/src/app/generated/tauri/desktop/DesktopSettings.ts index 98fb9dcdf..4367a6ccc 100644 --- a/src/app/generated/tauri/desktop/DesktopSettings.ts +++ b/src/app/generated/tauri/desktop/DesktopSettings.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DesktopSettings = { closeToBackgroundOnClose: boolean; showSystemTrayIcon: boolean }; +export type DesktopSettings = { closeToBackgroundOnClose: boolean, showSystemTrayIcon: boolean, };