From d5dd3245a6bd96f07979793a30ba9d68cdf8644f Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 15 Aug 2026 17:44:30 +0200 Subject: [PATCH 1/5] fix(media): route Android media through the loopback server instead of a redirect --- src-tauri/src/lib.rs | 2 +- src-tauri/src/network/media_protocol.rs | 93 +---- .../media_protocol/android_loopback.rs | 336 +++--------------- src/app/components/message/Reaction.tsx | 7 +- .../message/content/VideoContent.tsx | 2 +- src/app/generated/tauri/commands.ts | 6 +- src/app/generated/tauri/events.ts | 15 +- src/app/generated/tauri/index.ts | 2 +- src/app/generated/tauri/types.ts | 5 +- src/app/hooks/useRenderableMediaUrl.ts | 59 ++- src/app/utils/tauriMediaAuth.ts | 10 + 11 files changed, 142 insertions(+), 395 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2a396c7fc4..ab2b6ca8e5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -458,7 +458,7 @@ pub fn run() { network::media_protocol::clear_media_session, network::media_protocol::set_media_encryption, #[cfg(target_os = "android")] - network::media_protocol::prepare_loopback_video, + network::media_protocol::prepare_loopback_media, sentry::set_native_sentry_enabled, share_inbox::share_inbox_drain, share_inbox::share_inbox_read, diff --git a/src-tauri/src/network/media_protocol.rs b/src-tauri/src/network/media_protocol.rs index 1fef94dcb6..5dd5884260 100644 --- a/src-tauri/src/network/media_protocol.rs +++ b/src-tauri/src/network/media_protocol.rs @@ -45,7 +45,6 @@ const READ_TIMEOUT: Duration = Duration::from_secs(30); const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); // Small and multiplexed over one HTTP/2 connection, so a tight cap only serialises the timeline. const MAX_CONCURRENT_THUMBNAIL_REQUESTS: usize = 12; -// Originals stay capped: more parallelism just splits the same mobile bandwidth. const MAX_CONCURRENT_DOWNLOAD_REQUESTS: usize = 6; // The frontend mounts (and starts requesting media) before it hands us the session, so a request // may arrive first. `` never retries, so waiting beats answering 503. @@ -61,16 +60,6 @@ const MAX_TEMP_CACHE_BYTES: u64 = 2 * 1024 * 1024 * 1024; // 2 GiB type FetchResult = Result<(String, Option>>, PathBuf), StatusCode>; -/// Uninhabited off Android, so the streaming branches compile out. -#[cfg(target_os = "android")] -type FetchProgress = Option>; -#[cfg(not(target_os = "android"))] -type FetchProgress = Option; - -// Published per flushed batch, so a reader never sees bytes still sitting in the write buffer. -#[cfg(target_os = "android")] -const PROGRESS_FLUSH_BYTES: u64 = 64 * 1024; - pub struct MediaSessionState { session_store: SessionStore, encryption: EncryptionStore, @@ -276,7 +265,7 @@ pub fn set_media_encryption( #[cfg(target_os = "android")] #[tauri::command] -pub async fn prepare_loopback_video( +pub async fn prepare_loopback_media( app: AppHandle, url: String, ) -> Result { @@ -401,44 +390,11 @@ async fn handle_request( let dir = cache_dir(app).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let temp_dir = temp_cache_dir(app).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Wry blocks the webview's `shouldInterceptRequest` thread for all of this and drops the - // response after 30s, so redirect before fetching. Range requests seek media already cached. - #[cfg(target_os = "android")] - if !loopback && range.is_none() { - if let Some(server) = &state.loopback { - let (redirect, pending) = server.redirect_pending(&session, &key); - if let Some(pending) = pending { - let app = app.clone(); - let session = session.clone(); - let key = key.clone(); - tauri::async_runtime::spawn(async move { - let state = app.state::(); - let progress = Some(Arc::clone(&pending)); - let stored = - ensure_cached(&state, &session, &key, media_url, dir, temp_dir, &progress) - .await - .ok() - .and_then(|(content_type, in_memory_body, disk_path)| { - // An in-memory body means there is no file for the loopback to open. - in_memory_body - .is_none() - .then_some((disk_path, content_type)) - }); - if let Some(server) = &state.loopback { - server.publish(&session, &key, stored.clone()); - } - pending.resolve(stored); - }); - } - return Ok(redirect); - } - } - let (content_type, in_memory_body, disk_path) = - ensure_cached(&state, &session, &key, media_url, dir, temp_dir, &None).await?; + ensure_cached(&state, &session, &key, media_url, dir, temp_dir).await?; #[cfg(target_os = "android")] - if loopback && in_memory_body.is_none() && content_type.starts_with("video/") { + if loopback && in_memory_body.is_none() { if let Some(loopback) = &state.loopback { return Ok(loopback.redirect_response(&session, &key, disk_path, &content_type)); } @@ -465,7 +421,6 @@ async fn ensure_cached( media_url: Url, dir: PathBuf, temp_dir: PathBuf, - progress: &FetchProgress, ) -> Result<(String, Option>>, PathBuf), StatusCode> { ensure_cached_with_limits( state, @@ -476,7 +431,6 @@ async fn ensure_cached( temp_dir, MAX_CACHE_BYTES, MAX_TEMP_CACHE_BYTES, - progress, ) .await } @@ -491,7 +445,6 @@ async fn ensure_cached_with_limits( temp_dir: PathBuf, max_persistent_cache_bytes: u64, max_temp_cache_bytes: u64, - progress: &FetchProgress, ) -> Result<(String, Option>>, PathBuf), StatusCode> { let body_path = dir.join(key); let content_type_path = dir.join(format!("{key}.ct")); @@ -569,7 +522,6 @@ async fn ensure_cached_with_limits( temp_content_type_path, max_persistent_cache_bytes, max_temp_cache_bytes, - progress, ) .await; @@ -625,7 +577,6 @@ async fn fetch_and_cache( temp_content_type_path: PathBuf, max_persistent_cache_bytes: u64, max_temp_cache_bytes: u64, - progress: &FetchProgress, ) -> Result<(String, Option>>, PathBuf), StatusCode> { let permit = acquire_lane(state, &media_url).await; @@ -697,15 +648,7 @@ async fn fetch_and_cache( // Plaintext media streams to disk, so peak memory is one chunk instead of the whole file. let staging_path = temp_body_path.with_extension("part"); - match stream_to_staging_file( - &mut upstream, - temp_dir.clone(), - staging_path.clone(), - progress, - &content_type, - ) - .await - { + match stream_to_staging_file(&mut upstream, temp_dir.clone(), staging_path.clone()).await { StreamOutcome::Written(size) => { drop(permit); let (target_dir, target_body, target_ct, max_bytes) = @@ -765,8 +708,6 @@ async fn stream_to_staging_file( upstream: &mut tauri_plugin_http::reqwest::Response, temp_dir: PathBuf, staging_path: PathBuf, - progress: &FetchProgress, - content_type: &str, ) -> StreamOutcome { if tokio::fs::create_dir_all(&temp_dir).await.is_err() { return StreamOutcome::Unstorable; @@ -777,16 +718,6 @@ async fn stream_to_staging_file( let mut file = tokio::io::BufWriter::new(file); let mut written: u64 = 0; - #[cfg(target_os = "android")] - let mut published: u64 = 0; - - // Without a length the response cannot be framed, so readers wait for the finished file. - #[cfg(target_os = "android")] - if let (Some(pending), Some(total)) = (progress, upstream.content_length()) { - pending.begin_stream(staging_path.clone(), content_type.to_owned(), total); - } - #[cfg(not(target_os = "android"))] - let _ = (progress, content_type); loop { match upstream.chunk().await { @@ -798,24 +729,9 @@ async fn stream_to_staging_file( break; } written += chunk.len() as u64; - - #[cfg(target_os = "android")] - if let Some(pending) = progress { - if written - published >= PROGRESS_FLUSH_BYTES { - if tokio::io::AsyncWriteExt::flush(&mut file).await.is_err() { - break; - } - published = written; - pending.advance(written); - } - } } Ok(None) => { if tokio::io::AsyncWriteExt::flush(&mut file).await.is_ok() { - #[cfg(target_os = "android")] - if let Some(pending) = progress { - pending.advance(written); - } return StreamOutcome::Written(written); } break; @@ -1127,7 +1043,6 @@ mod tests { temp, 1024 * 1024, 1024 * 1024, - &None, ) .await; fs::remove_dir_all(root).ok(); diff --git a/src-tauri/src/network/media_protocol/android_loopback.rs b/src-tauri/src/network/media_protocol/android_loopback.rs index bb978804bd..c769857007 100644 --- a/src-tauri/src/network/media_protocol/android_loopback.rs +++ b/src-tauri/src/network/media_protocol/android_loopback.rs @@ -4,9 +4,8 @@ use std::{ io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}, net::{TcpListener, TcpStream}, path::PathBuf, - sync::{Arc, Condvar, Mutex, RwLock}, + sync::{Arc, RwLock}, thread, - time::Duration, }; use sha2::{Digest, Sha256}; @@ -14,132 +13,9 @@ use tauri::http::{header, Response, StatusCode}; use super::session::MediaSession; -// Frees the socket if a fetch never completes, instead of holding a per-host connection forever. -const PENDING_WAIT: Duration = Duration::from_secs(120); -const KEEP_ALIVE_IDLE: Duration = Duration::from_secs(30); - pub(super) struct LoopbackMediaServer { origin: String, - routes: Arc>>, -} - -#[derive(Clone)] -enum Route { - Ready(CachedMedia), - /// Registered before the fetch starts so the custom protocol can redirect immediately. - Pending(Arc), -} - -/// One in-flight fetch, read by loopback connections while the fetch task writes it. -pub(super) struct PendingMedia { - state: Mutex, - changed: Condvar, -} - -#[derive(Default)] -struct PendingState { - stream: Option, - written: u64, - outcome: Option>, -} - -#[derive(Clone)] -struct PendingStream { - path: PathBuf, - content_type: String, - total: u64, -} - -enum PendingStart { - /// Known length, so the staging file can be tailed as it lands. - Stream(PendingStream), - Done(Option), -} - -impl PendingMedia { - fn new() -> Self { - Self { - state: Mutex::new(PendingState::default()), - changed: Condvar::new(), - } - } - - /// Only plaintext media of known length streams; everything else waits for [`Self::resolve`]. - pub(super) fn begin_stream(&self, path: PathBuf, content_type: String, total: u64) { - if let Ok(mut state) = self.state.lock() { - state.stream = Some(PendingStream { - path, - content_type, - total, - }); - } - self.changed.notify_all(); - } - - pub(super) fn advance(&self, written: u64) { - if let Ok(mut state) = self.state.lock() { - state.written = written; - } - self.changed.notify_all(); - } - - pub(super) fn resolve(&self, media: Option<(PathBuf, String)>) { - let media = media.map(|(path, content_type)| CachedMedia { path, content_type }); - if let Ok(mut state) = self.state.lock() { - state.outcome = Some(media); - } - self.changed.notify_all(); - } - - fn wait_start(&self) -> PendingStart { - let Ok(state) = self.state.lock() else { - return PendingStart::Done(None); - }; - let Ok((state, _)) = self - .changed - .wait_timeout_while(state, PENDING_WAIT, |state| { - state.stream.is_none() && state.outcome.is_none() - }) - else { - return PendingStart::Done(None); - }; - match (&state.stream, &state.outcome) { - // A fetch that already finished is served from its final path, not the staging file. - (_, Some(outcome)) => PendingStart::Done(outcome.clone()), - (Some(stream), None) => PendingStart::Stream(stream.clone()), - (None, None) => PendingStart::Done(None), - } - } - - fn wait_start_done(&self) -> Option { - let state = self.state.lock().ok()?; - let (state, _) = self - .changed - .wait_timeout_while(state, PENDING_WAIT, |state| state.outcome.is_none()) - .ok()?; - state.outcome.clone().flatten() - } - - /// Blocks until more than `have` bytes have landed, or the fetch ends. `None` means no more - /// bytes are coming. - fn wait_for(&self, have: u64) -> Option { - let state = self.state.lock().ok()?; - let (state, _) = self - .changed - .wait_timeout_while(state, PENDING_WAIT, |state| { - state.written <= have && state.outcome.is_none() - }) - .ok()?; - if state.written > have { - return Some(state.written); - } - // Resolved without new bytes: a failure leaves the body short of `total`. - state - .outcome - .as_ref() - .and_then(|outcome| outcome.as_ref())?; - None - } + routes: Arc>>, } #[derive(Clone)] @@ -152,7 +28,7 @@ impl LoopbackMediaServer { pub(super) fn start() -> std::io::Result { let listener = TcpListener::bind(("127.0.0.1", 0))?; let origin = format!("http://127.0.0.1:{}", listener.local_addr()?.port()); - let routes = Arc::new(RwLock::new(HashMap::::new())); + let routes = Arc::new(RwLock::new(HashMap::::new())); let server_routes = Arc::clone(&routes); thread::Builder::new() .name("sable-media-loopback".into()) @@ -185,61 +61,12 @@ impl LoopbackMediaServer { if let Ok(mut routes) = self.routes.write() { routes.insert( capability.clone(), - Route::Ready(CachedMedia { + CachedMedia { path, content_type: content_type.to_owned(), - }), + }, ); } - self.redirect_to(&capability) - } - - /// Returns a handle to resolve when the fetch finishes, or `None` if one is already running. - pub(super) fn redirect_pending( - &self, - session: &MediaSession, - cache_key: &str, - ) -> (Response>, Option>) { - let capability = capability(session, cache_key); - let pending = match self.routes.write() { - Ok(mut routes) => { - if routes.contains_key(&capability) { - None - } else { - let pending = Arc::new(PendingMedia::new()); - routes.insert(capability.clone(), Route::Pending(Arc::clone(&pending))); - Some(pending) - } - } - Err(_) => None, - }; - (self.redirect_to(&capability), pending) - } - - /// Swaps the pending entry for the finished file so later requests skip straight to it. - pub(super) fn publish( - &self, - session: &MediaSession, - cache_key: &str, - media: Option<(PathBuf, String)>, - ) { - let capability = capability(session, cache_key); - if let Ok(mut routes) = self.routes.write() { - match &media { - Some((path, content_type)) => routes.insert( - capability, - Route::Ready(CachedMedia { - path: path.clone(), - content_type: content_type.clone(), - }), - ), - // A failed fetch must not linger: the next request has to retry it. - None => routes.remove(&capability), - }; - } - } - - fn redirect_to(&self, capability: &str) -> Response> { Response::builder() .status(StatusCode::FOUND) .header(header::LOCATION, format!("{}/{}", self.origin, capability)) @@ -263,166 +90,89 @@ fn capability(session: &MediaSession, cache_key: &str) -> String { .collect() } -fn serve(stream: TcpStream, routes: Arc>>) { - let _ = stream.set_read_timeout(Some(KEEP_ALIVE_IDLE)); - let Ok(mut writer) = stream.try_clone() else { +fn serve(mut stream: TcpStream, routes: Arc>>) { + let Ok((method, capability, range)) = parse_request(&stream) else { + let _ = write_status(&mut stream, 400, "Bad Request", &[]); return; }; - let mut reader = BufReader::new(stream); - while serve_one(&mut reader, &mut writer, &routes) {} -} - -fn serve_one( - reader: &mut BufReader, - writer: &mut TcpStream, - routes: &Arc>>, -) -> bool { - let Ok((method, capability, range)) = parse_request(reader) else { - let _ = write_status(writer, 400, "Bad Request", &[], false); - return false; - }; if method != "GET" && method != "HEAD" { - let _ = write_status(writer, 405, "Method Not Allowed", &[], false); - return false; + let _ = write_status(&mut stream, 405, "Method Not Allowed", &[]); + return; } - let route = routes + let media = routes .read() .ok() .and_then(|routes| routes.get(&capability).cloned()); - let Some(route) = route else { - let _ = write_status(writer, 404, "Not Found", &[], false); - return false; - }; - // Waiting costs a loopback socket rather than a webview thread, with no 30s ceiling over it. - let media = match route { - Route::Ready(media) => Some(media), - Route::Pending(pending) => match pending.wait_start() { - // A range request wants to seek, which only the finished file can satisfy. - PendingStart::Stream(stream) if range.is_none() => { - return serve_stream(writer, &pending, &stream, &method); - } - PendingStart::Stream(_) => pending.wait_start_done(), - PendingStart::Done(media) => media, - }, - }; let Some(media) = media else { - let _ = write_status(writer, 504, "Gateway Timeout", &[], false); - return false; + let _ = write_status(&mut stream, 404, "Not Found", &[]); + return; }; let Ok(mut file) = File::open(media.path) else { - let _ = write_status(writer, 404, "Not Found", &[], false); - return false; + let _ = write_status(&mut stream, 404, "Not Found", &[]); + return; }; let Ok(total) = file.metadata().map(|metadata| metadata.len()) else { - let _ = write_status(writer, 500, "Internal Server Error", &[], false); - return false; + let _ = write_status(&mut stream, 500, "Internal Server Error", &[]); + return; }; let selection = range.as_deref().and_then(|value| parse_range(value, total)); if range.is_some() && selection.is_none() { let _ = write_status( - writer, + &mut stream, 416, "Range Not Satisfiable", &[("Content-Range", format!("bytes */{total}"))], - false, ); - return false; + return; } let (start, end, partial) = selection.unwrap_or((0, total.saturating_sub(1), false)); let length = end.saturating_sub(start) + 1; - let mut headers = media_headers(&media.content_type, length); + let mut headers = vec![ + ("Content-Type", media.content_type), + ("Content-Length", length.to_string()), + ("Accept-Ranges", "bytes".to_owned()), + ( + "Access-Control-Allow-Origin", + "https://tauri.localhost".to_owned(), + ), + ( + "Cache-Control", + "private, max-age=31536000, immutable".to_owned(), + ), + ]; if partial { headers.push(("Content-Range", format!("bytes {start}-{end}/{total}"))); } if write_status( - writer, + &mut stream, if partial { 206 } else { 200 }, if partial { "Partial Content" } else { "OK" }, &headers, - true, ) .is_err() + || method == "HEAD" { - return false; - } - if method == "HEAD" { - return true; + return; } if file.seek(SeekFrom::Start(start)).is_err() { - return false; + return; } let mut left = length; let mut buffer = [0_u8; 64 * 1024]; while left > 0 { let want = left.min(buffer.len() as u64) as usize; let Ok(read) = file.read(&mut buffer[..want]) else { - return false; + return; }; - if read == 0 || writer.write_all(&buffer[..read]).is_err() { - return false; + if read == 0 || stream.write_all(&buffer[..read]).is_err() { + return; } left -= read as u64; } - true } -/// Writes the body as the fetch lands it, so the image paints progressively. -fn serve_stream( - writer: &mut TcpStream, - pending: &PendingMedia, - stream: &PendingStream, - method: &str, -) -> bool { - let headers = media_headers(&stream.content_type, stream.total); - if write_status(writer, 200, "OK", &headers, true).is_err() { - return false; - } - if method == "HEAD" { - return true; - } - let Ok(mut file) = File::open(&stream.path) else { - return false; - }; - let mut sent: u64 = 0; - let mut buffer = [0_u8; 64 * 1024]; - while sent < stream.total { - // Progress is only published after a flush, so these bytes are readable. - let Some(available) = pending.wait_for(sent) else { - return false; - }; - while sent < available.min(stream.total) { - let want = (available.min(stream.total) - sent).min(buffer.len() as u64) as usize; - let Ok(read) = file.read(&mut buffer[..want]) else { - return false; - }; - if read == 0 || writer.write_all(&buffer[..read]).is_err() { - return false; - } - sent += read as u64; - } - } - true -} - -fn media_headers(content_type: &str, length: u64) -> Vec<(&'static str, String)> { - vec![ - ("Content-Type", content_type.to_owned()), - ("Content-Length", length.to_string()), - ("Accept-Ranges", "bytes".to_owned()), - ( - "Access-Control-Allow-Origin", - "https://tauri.localhost".to_owned(), - ), - ( - "Cache-Control", - "private, max-age=31536000, immutable".to_owned(), - ), - ] -} - -fn parse_request( - reader: &mut BufReader, -) -> Result<(String, String, Option), ()> { +fn parse_request(stream: &TcpStream) -> Result<(String, String, Option), ()> { + let mut reader = BufReader::new(stream); let mut request_line = String::new(); reader.read_line(&mut request_line).map_err(|_| ())?; let mut parts = request_line.split_whitespace(); @@ -477,12 +227,10 @@ fn write_status( status: u16, reason: &str, headers: &[(&str, String)], - keep_alive: bool, ) -> std::io::Result<()> { - let connection = if keep_alive { "keep-alive" } else { "close" }; write!( stream, - "HTTP/1.1 {status} {reason}\r\nConnection: {connection}\r\n" + "HTTP/1.1 {status} {reason}\r\nConnection: close\r\n" )?; for (name, value) in headers { write!(stream, "{name}: {value}\r\n")?; diff --git a/src/app/components/message/Reaction.tsx b/src/app/components/message/Reaction.tsx index 48fc8fa2cf..12047913d6 100644 --- a/src/app/components/message/Reaction.tsx +++ b/src/app/components/message/Reaction.tsx @@ -8,6 +8,7 @@ import { getMemberDisplayName } from '$utils/room/display'; import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; import { useAtomValue } from 'jotai'; import { Image as MediaImage } from '$components/media'; +import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; import { nicknamesAtom } from '$state/nicknames'; import * as css from './Reaction.css'; @@ -21,6 +22,10 @@ export const Reaction = as< } >(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => { const [imgError, setImgError] = useState(false); + const rawReactionUrl = reaction.startsWith('mxc://') + ? (mxcUrlToHttp(mx, reaction, useAuthentication) ?? undefined) + : undefined; + const renderableReactionUrl = useRenderableMediaUrl(rawReactionUrl); return ( setImgError(true)} /> diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index 38ed5e7607..f8b9bb7901 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -107,7 +107,7 @@ export const VideoContent = as<'div', VideoContentProps>( if (!mediaUrl) throw new Error('Invalid media URL'); const prepareAndroidLoopback = (source: string) => isAndroidTauri() - ? invoke('prepare_loopback_video', { url: source }) + ? invoke('prepare_loopback_media', { url: source }) : Promise.resolve(source); if (!encInfo) { if (isTauri()) { diff --git a/src/app/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts index d9c5d07353..32be64f822 100644 --- a/src/app/generated/tauri/commands.ts +++ b/src/app/generated/tauri/commands.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-08-05T12:41:11.731056+00:00 + * Generated at: 2026-08-15T15:25:41.893696146+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -66,8 +66,8 @@ export async function playNotificationSound(params: types.PlayNotificationSoundP return invoke('play_notification_sound', params); } -export async function prepareLoopbackVideo(params: types.PrepareLoopbackVideoParams): Promise { - return invoke('prepare_loopback_video', params); +export async function prepareLoopbackMedia(params: types.PrepareLoopbackMediaParams): Promise { + return invoke('prepare_loopback_media', params); } export async function saveDownload(params: types.SaveDownloadParams): Promise { diff --git a/src/app/generated/tauri/events.ts b/src/app/generated/tauri/events.ts index ff0d638558..565fbcbf53 100644 --- a/src/app/generated/tauri/events.ts +++ b/src/app/generated/tauri/events.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-08-05T12:41:11.732167+00:00 + * Generated at: 2026-08-15T15:25:41.894349935+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -27,4 +27,17 @@ export async function onOpenSettings( }); } +/** + * Listen for 'share-received' events + * @param handler - Callback function to handle the event + * @returns Promise that resolves to an unlisten function + */ +export async function onShareReceived( + handler: (payload: void) => void +): Promise { + return listen('share-received', (event) => { + handler(event.payload); + }); +} + diff --git a/src/app/generated/tauri/index.ts b/src/app/generated/tauri/index.ts index 0b97ef4c53..e82098e0b4 100644 --- a/src/app/generated/tauri/index.ts +++ b/src/app/generated/tauri/index.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-08-05T12:41:11.732351+00:00 + * Generated at: 2026-08-15T15:25:41.894506005+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index f5b2a7ba78..7cd1d109c0 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -1,7 +1,7 @@ /** * Auto-generated TypeScript bindings for Tauri commands * Generated by tauri-typegen v0.5.0 - * Generated at: 2026-08-05T12:41:11.729365+00:00 + * Generated at: 2026-08-15T15:25:41.892923977+00:00 * Generator: none * * Do not edit manually - regenerate using: cargo tauri-typegen generate @@ -107,7 +107,7 @@ export interface PlayNotificationSoundParams { [key: string]: unknown; } -export interface PrepareLoopbackVideoParams { +export interface PrepareLoopbackMediaParams { url: string; [key: string]: unknown; } @@ -193,3 +193,4 @@ export interface UploadWriteChunkParams { chunk: string; [key: string]: unknown; } + diff --git a/src/app/hooks/useRenderableMediaUrl.ts b/src/app/hooks/useRenderableMediaUrl.ts index 68d7f52676..a65b25237e 100644 --- a/src/app/hooks/useRenderableMediaUrl.ts +++ b/src/app/hooks/useRenderableMediaUrl.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useAtomValue } from 'jotai'; -import { isTauri } from '@tauri-apps/api/core'; +import { invoke, isTauri } from '@tauri-apps/api/core'; +import { isAndroidTauri } from '$utils/platform'; import { activeSessionIdAtom } from '$state/sessions'; import { fetchMediaBlob, @@ -119,6 +120,38 @@ function releaseObjectUrlEntry(cacheKey: string): void { pruneUnreferencedCache(); } +type LoopbackEntry = { promise: Promise; url?: string }; + +// Keyed by the `sable-media` URL so one avatar repeated down a timeline resolves once. +const loopbackCache = new Map(); + +// Resolved up front because wry rejects a 3xx from a protocol handler, so the loopback cannot +// be reached by redirect. +function resolveLoopbackUrl(protocolUrl: string): LoopbackEntry { + const existing = loopbackCache.get(protocolUrl); + if (existing) return existing; + + const entry: LoopbackEntry = { + promise: invoke('prepare_loopback_media', { url: protocolUrl }) + .then((loopbackUrl) => { + entry.url = loopbackUrl; + return loopbackUrl; + }) + .catch(() => { + // The custom protocol still works, so a failure here costs performance, not media. + entry.url = protocolUrl; + return protocolUrl; + }), + }; + loopbackCache.set(protocolUrl, entry); + return entry; +} + +// Capabilities are keyed by access token, so they do not survive a session change. +export function clearLoopbackMediaUrlCache(): void { + loopbackCache.clear(); +} + export function clearRenderableMediaUrlCache(): void { for (const [cacheKey, entry] of Array.from(objectUrlCache.entries())) { if (entry.refs === 0 && entry.settled && entry.objectUrl) { @@ -148,6 +181,28 @@ export function useRenderableMediaUrl(url: string | undefined): string | undefin const [swMediaAuthSupported, setSwMediaAuthSupported] = useState( () => getCachedSWMediaAuthSupport() ?? false ); + const androidTauri = tauri && isAndroidTauri(); + const protocolUrl = tauri ? (rewriteAuthenticatedMediaUrl(url ?? null) ?? undefined) : undefined; + // A settled entry resolves synchronously, so a repeated avatar never flashes a fallback. + const [loopbackUrl, setLoopbackUrl] = useState(() => + androidTauri && protocolUrl ? loopbackCache.get(protocolUrl)?.url : undefined + ); + + useEffect(() => { + if (!androidTauri || !protocolUrl) return undefined; + const entry = resolveLoopbackUrl(protocolUrl); + if (entry.url) { + setLoopbackUrl(entry.url); + return undefined; + } + let cancelled = false; + void entry.promise.then((resolved) => { + if (!cancelled) setLoopbackUrl(resolved); + }); + return () => { + cancelled = true; + }; + }, [androidTauri, protocolUrl]); const needsBlob = !swMediaAuthSupported; const usesExistingObjectUrl = renderableUrl?.startsWith('blob:') ?? false; const [resolvedState, setResolvedState] = useState(() => { @@ -214,7 +269,7 @@ export function useRenderableMediaUrl(url: string | undefined): string | undefin }, [needsBlob, objectUrlCacheKey, renderableUrl, tauri, usesExistingObjectUrl]); if (tauri) { - return rewriteAuthenticatedMediaUrl(url ?? null) ?? undefined; + return androidTauri ? loopbackUrl : protocolUrl; } if (!needsBlob || usesExistingObjectUrl) { diff --git a/src/app/utils/tauriMediaAuth.ts b/src/app/utils/tauriMediaAuth.ts index 6ca4877d04..f2c511f231 100644 --- a/src/app/utils/tauriMediaAuth.ts +++ b/src/app/utils/tauriMediaAuth.ts @@ -2,11 +2,14 @@ import { isTauri } from '@tauri-apps/api/core'; import { clearMediaSession, setMediaSession } from '$generated/tauri/commands'; import { createLogger } from './debug'; import { getActiveMediaSession } from './mediaTransport'; +import { clearLoopbackMediaUrlCache } from '$hooks/useRenderableMediaUrl'; const log = createLogger('tauri-media-auth'); let pendingNativeWrite: Promise = Promise.resolve(); let tauriMediaSessionListenersInstalled = false; +// In memory only, never logged. Compared so a no-op sync keeps the loopback cache. +let lastMediaToken: string | undefined; export const updateTauriMediaSession = ( baseUrl?: string, @@ -21,8 +24,15 @@ export const updateTauriMediaSession = ( // `scope` keys the native media cache. It must be the stable user ID, never the // access token, which rotates on every OIDC refresh. await setMediaSession({ baseUrl, token: accessToken, scope: userId }); + // Capabilities embed the access token, so a rotated token orphans every cached URL. + if (accessToken !== lastMediaToken) { + clearLoopbackMediaUrlCache(); + lastMediaToken = accessToken; + } } else { await clearMediaSession(); + clearLoopbackMediaUrlCache(); + lastMediaToken = undefined; } } catch { // Do not log command arguments: they contain the homeserver URL and access token. From 887a60a2b7948f28a967b6f8be51edaae8cf4772 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 15 Aug 2026 17:53:34 +0200 Subject: [PATCH 2/5] fix(settings): pop the pushed section entry so one back leaves mobile settings --- .../features/settings/SettingsRoute.test.tsx | 34 +++++++++++++++++++ src/app/features/settings/SettingsRoute.tsx | 14 ++++++-- src/app/features/settings/navigation.ts | 1 + 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index e2712978a6..d7558d3106 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -196,6 +196,16 @@ function LocationProbe() { ); } +function RouterBackProbe() { + const navigate = useNavigate(); + + return ( + + ); +} + function HomePage() { const navigate = useNavigate(); const location = useLocation(); @@ -243,6 +253,7 @@ function renderClientShell( + }> } /> @@ -649,6 +660,29 @@ describe('SettingsRoute', () => { ); expect(screen.getByText('Settings')).toBeInTheDocument(); }); + + it('leaves settings in one back after a section opened from the menu was closed', async () => { + const user = userEvent.setup(); + + renderClientShell(ScreenSize.Mobile, { + initialEntries: [getHomePath(), getSettingsPath()], + initialIndex: 1, + }); + + await user.click(screen.getByRole('button', { name: 'Appearance' })); + expect(await screen.findByRole('heading', { name: 'Appearance section' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Back' })); + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getSettingsPath()) + ); + + await user.click(screen.getByRole('button', { name: 'Router back' })); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getHomePath()) + ); + }); }); describe('Settings shallow route shell', () => { diff --git a/src/app/features/settings/SettingsRoute.tsx b/src/app/features/settings/SettingsRoute.tsx index f61b84f259..a6ef5a12d8 100644 --- a/src/app/features/settings/SettingsRoute.tsx +++ b/src/app/features/settings/SettingsRoute.tsx @@ -102,6 +102,11 @@ export function SettingsRoute({ routeSection }: SettingsRouteProps) { if (section === undefined) return; if (screenSize === ScreenSize.Mobile) { + if (routeState?.pushedFromSettingsMenu) { + navigate(-1); + return; + } + navigate(getSettingsPath(), { replace: true, state: routeState }); return; } @@ -124,19 +129,24 @@ export function SettingsRoute({ routeSection }: SettingsRouteProps) { navigate(closeTarget.to, { replace: true, state: closeTarget.state }); }; + const menuPushState = (): SettingsRouteState | null => + screenSize === ScreenSize.Mobile && activeSection === null + ? { ...routeState, pushedFromSettingsMenu: true } + : routeState; + const handleSelectSection = (nextSection: SettingsSectionId) => { if (nextSection === activeSection) return; navigate(getSettingsPath(nextSection), { replace: shallowBackgroundState, - state: location.state, + state: menuPushState(), }); }; const handleSelectSetting = (nextSection: SettingsSectionId, focus: string) => { navigate(getSettingsPath(nextSection, focus), { replace: shallowBackgroundState, - state: location.state, + state: menuPushState(), }); }; diff --git a/src/app/features/settings/navigation.ts b/src/app/features/settings/navigation.ts index 2ade3e5eef..5cd53cfbee 100644 --- a/src/app/features/settings/navigation.ts +++ b/src/app/features/settings/navigation.ts @@ -2,4 +2,5 @@ import type { ShallowRouteState } from '$pages/client/shallowRoute'; export type SettingsRouteState = ShallowRouteState & { redirectedFromDesktopRoot?: boolean; + pushedFromSettingsMenu?: boolean; }; From 413adbe04a9d2cecd13b340e56950155156e9e88 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 15 Aug 2026 17:55:42 +0200 Subject: [PATCH 3/5] fix(call): keep incoming call audio playing when switching rooms --- .../features/call/LivekitJsCallAudio.test.tsx | 80 +++++++++++++++++++ src/app/features/call/LivekitJsCallAudio.tsx | 18 +++++ .../call/LivekitJsCallSurface.test.tsx | 27 +++---- .../features/call/LivekitJsCallSurface.tsx | 6 +- src/app/pages/Router.tsx | 2 + 5 files changed, 111 insertions(+), 22 deletions(-) create mode 100644 src/app/features/call/LivekitJsCallAudio.test.tsx create mode 100644 src/app/features/call/LivekitJsCallAudio.tsx diff --git a/src/app/features/call/LivekitJsCallAudio.test.tsx b/src/app/features/call/LivekitJsCallAudio.test.tsx new file mode 100644 index 0000000000..038f453fac --- /dev/null +++ b/src/app/features/call/LivekitJsCallAudio.test.tsx @@ -0,0 +1,80 @@ +import { type Context } from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Room } from 'livekit-client'; +import { createStore, Provider } from 'jotai'; +import { + livekitJsCallAtom, + livekitJsCallSoundAtom, + type LivekitJsCallSession, +} from '$state/livekitJsCall'; +import { LivekitJsCallAudio } from './LivekitJsCallAudio'; + +const mocks = vi.hoisted(() => ({ + roomContext: undefined as unknown as Context, +})); + +vi.mock('@livekit/components-react', async () => { + const { createContext, useContext } = await import('react'); + mocks.roomContext = createContext(undefined); + return { + RoomContext: mocks.roomContext, + RoomAudioRenderer: ({ muted }: { muted?: boolean }) => { + const room = useContext(mocks.roomContext) as { name?: string } | undefined; + return ( +
+ ); + }, + }; +}); + +const session = (room?: Room): LivekitJsCallSession => ({ + roomId: '!room:example.org', + initialMedia: { microphone: true, camera: false, sound: true }, + lifecycle: 'active', + failure: null, + room, + mediaReady: true, + hangup: () => Promise.resolve(), +}); + +const renderWith = (call: LivekitJsCallSession | undefined, sound = true) => { + const store = createStore(); + store.set(livekitJsCallAtom, call); + store.set(livekitJsCallSoundAtom, sound); + return render( + + + + ); +}; + +describe('LivekitJsCallAudio', () => { + it('renders the sink for the ongoing call regardless of the selected room', () => { + renderWith(session({ name: 'lk-room' } as unknown as Room)); + + expect(screen.getByTestId('room-audio')).toHaveAttribute('data-room', 'lk-room'); + }); + + it('mutes incoming audio while the shared sound toggle is off', () => { + renderWith(session({ name: 'lk-room' } as unknown as Room), false); + + expect(screen.getByTestId('room-audio')).toHaveAttribute('data-muted', 'true'); + }); + + it('renders nothing without a call', () => { + renderWith(undefined); + + expect(screen.queryByTestId('room-audio')).not.toBeInTheDocument(); + }); + + it('renders nothing before the LiveKit room exists', () => { + renderWith(session(undefined)); + + expect(screen.queryByTestId('room-audio')).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/features/call/LivekitJsCallAudio.tsx b/src/app/features/call/LivekitJsCallAudio.tsx new file mode 100644 index 0000000000..ccf450a801 --- /dev/null +++ b/src/app/features/call/LivekitJsCallAudio.tsx @@ -0,0 +1,18 @@ +import { RoomAudioRenderer, RoomContext } from '@livekit/components-react'; +import { useAtomValue } from 'jotai'; +import { livekitJsCallAtom, livekitJsCallSoundAtom } from '$state/livekitJsCall'; + +/** Lives above the room route: the surface unmounts on navigation and takes its `