diff --git a/src-tauri/src/network/media_protocol.rs b/src-tauri/src/network/media_protocol.rs index 5383a7ced8..57960fec82 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"; @@ -23,14 +24,20 @@ 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; +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>>>>, } impl Default for MediaSessionState { @@ -39,6 +46,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 +64,24 @@ 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(None)); + gates.insert(key.to_owned(), Arc::downgrade(&gate)); + Ok(gate) + } } #[derive(Clone)] @@ -94,6 +120,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( @@ -151,55 +180,152 @@ 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::(); - 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 - // 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, - dir, - body_path.clone(), - content_type_path, - ) - .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)) => { - Ok(serve_range_memory(body, &content_type, &range_header)) + 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)), + (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, 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 +} + +#[allow(clippy::too_many_arguments)] +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, 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 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) + } + } +} + +#[allow(clippy::too_many_arguments)] +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() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let upstream = state .client() .get(media_url) @@ -220,27 +346,72 @@ 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) +} + +#[allow(dead_code)] fn fits_in_cache(len: u64) -> bool { len <= MAX_CACHE_BYTES } @@ -297,7 +468,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); @@ -338,25 +509,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; }; @@ -375,13 +529,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() { @@ -410,7 +564,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") } @@ -422,7 +578,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}"), @@ -455,6 +613,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()); @@ -466,12 +631,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"; @@ -491,8 +678,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" @@ -527,6 +719,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" @@ -547,7 +743,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!( @@ -559,7 +755,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; @@ -571,7 +767,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); } } diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 9e4540670b..c2d6e79875 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']} diff --git a/src/app/generated/tauri/desktop/DesktopRuntimeState.ts b/src/app/generated/tauri/desktop/DesktopRuntimeState.ts index b0640f018c..b5561254ae 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 98fb9dcdf8..4367a6ccc7 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, }; diff --git a/src/app/pages/auth/SSOTauri.test.ts b/src/app/pages/auth/SSOTauri.test.ts index 377f4680e9..c16e9082ba 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 6a60baa28b..4100a75d42 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 = { diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 78a7ea43aa..f86e518b28 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 a65a3ede8d..0f3f2a1f12 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 44907ce422..106ddb2ef6 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 95cd1e1407..90a334b761 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 d253c75980..6dc3499411 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 0000000000..124fc83549 --- /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 7b2c8de31e..e7281cb5ea 100644 Binary files a/src/app/utils/tauriMediaAuth.ts and b/src/app/utils/tauriMediaAuth.ts differ diff --git a/src/client/initMatrix.test.ts b/src/client/initMatrix.test.ts new file mode 100644 index 0000000000..db141dd664 --- /dev/null +++ b/src/client/initMatrix.test.ts @@ -0,0 +1,26 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Session } from '$state/sessions'; +import { ACTIVE_SESSION_KEY, MATRIX_SESSIONS_KEY } from '$state/sessions'; +import { ownsActiveMediaSession } from './initMatrix'; + +const alice = { userId: '@alice:example.org' } as Session; +const bob = { userId: '@bob:example.org' } as Session; + +describe('ownsActiveMediaSession', () => { + 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 3d556e7946..f1edf6f3f6 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 8e2db84934..a978613148 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 43e5e71366..95d0fb962c 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 0000000000..75e4df8a79 --- /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 b5815dd811..36d5ac9cc0 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 d99ba4fe9c..0fad55a1c5 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); }) );