diff --git a/.github/scripts/build-updater-manifest.mjs b/.github/scripts/build-updater-manifest.mjs index 11ca6c7362..de1245c13c 100644 --- a/.github/scripts/build-updater-manifest.mjs +++ b/.github/scripts/build-updater-manifest.mjs @@ -49,7 +49,7 @@ for (const sig of readdirSync(dir).filter((f) => f.endsWith('.sig'))) { platforms['darwin-aarch64'] = entry; platforms['darwin-x86_64'] = entry; } else if (name.endsWith('.appimage')) { - platforms['linux-x86_64'] = entry; + platforms[name.includes('-linux-aarch64') ? 'linux-aarch64' : 'linux-x86_64'] = entry; } else if (name.endsWith('-setup.exe') || name.endsWith('.nsis.zip')) { platforms['windows-x86_64'] = entry; windowsIsNsis = true; diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index becc4f1682..6c00ef6c88 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -614,16 +614,25 @@ jobs: # Nightlies use `--snapshot`, which packages without publishing. linux: - name: Build Linux (CEF) + name: Build Linux ${{ matrix.arch }} (CEF) needs: setup-release - runs-on: ubuntu-22.04 - timeout-minutes: 60 + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-22.04 + - arch: aarch64 + runner: ubuntu-22.04-arm permissions: contents: write id-token: write attestations: write artifact-metadata: write env: + ARCH: ${{ matrix.arch }} TAG: ${{ needs.setup-release.outputs.tag }} VERSION: ${{ needs.setup-release.outputs.version }} VITE_APP_VERSION: ${{ needs.setup-release.outputs.version }} @@ -652,6 +661,7 @@ jobs: uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: src-tauri + key: ${{ matrix.arch }} - name: Stamp release version into tauri.conf.json shell: bash @@ -673,7 +683,7 @@ jobs: src-tauri/target/release/sable "$DISPLAY_NAME" \ src-tauri/target/release/sable-updater - DEB="src-tauri/target/release/bundle/deb/Sable-${VERSION}-linux-x86_64.deb" + DEB="src-tauri/target/release/bundle/deb/Sable-${VERSION}-linux-${ARCH}.deb" dpkg-deb --contents "$DEB" | grep 'etc/apparmor.d/sable$' >/dev/null dpkg-deb --ctrl-tarfile "$DEB" | tar -t | grep -x './postinst' >/dev/null dpkg-deb --ctrl-tarfile "$DEB" | tar -t | grep -x './prerm' >/dev/null @@ -685,14 +695,14 @@ jobs: src-tauri/target/release/cef-pkg/stage/share "$RELEASE_DIR/" test -f "$RELEASE_DIR/runtime/CEF-LICENSE.txt" tar -C "$RELEASE_DIR" -czf \ - "src-tauri/target/release/bundle/Sable-${VERSION}-linux-x86_64.tar.gz" \ + "src-tauri/target/release/bundle/Sable-${VERSION}-linux-${ARCH}.tar.gz" \ sable runtime share - name: Sign the AppImage for the updater if: ${{ env.TAURI_SIGNING_PRIVATE_KEY != '' }} shell: bash run: | - for f in src-tauri/target/release/bundle/appimage/Sable-*-linux-x86_64.AppImage; do + for f in src-tauri/target/release/bundle/appimage/Sable-*-linux-${ARCH}.AppImage; do [ -e "$f" ] || continue pnpm tauri signer sign "$f" done @@ -714,9 +724,9 @@ jobs: run: | for f in src-tauri/target/release/bundle/deb/Sable-*.deb \ src-tauri/target/release/bundle/rpm/Sable-*.rpm \ - src-tauri/target/release/bundle/Sable-*-linux-x86_64.tar.gz \ - src-tauri/target/release/bundle/appimage/Sable-*-linux-x86_64.AppImage \ - src-tauri/target/release/bundle/appimage/Sable-*-linux-x86_64.AppImage.sig; do + src-tauri/target/release/bundle/Sable-*-linux-${ARCH}.tar.gz \ + src-tauri/target/release/bundle/appimage/Sable-*-linux-${ARCH}.AppImage \ + src-tauri/target/release/bundle/appimage/Sable-*-linux-${ARCH}.AppImage.sig; do [ -e "$f" ] || continue echo "Uploading $f" gh release upload "$TAG" "$f" --clobber @@ -725,7 +735,7 @@ jobs: - name: Upload .deb as workflow artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: sable-linux-deb + name: sable-linux-deb-${{ matrix.arch }} path: src-tauri/target/release/bundle/deb/Sable-*.deb if-no-files-found: error retention-days: 1 @@ -750,7 +760,7 @@ jobs: - name: Download .deb artifact and compute checksum uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: sable-linux-deb + name: sable-linux-deb-x86_64 path: deb-artifact - name: Compute checksum diff --git a/nfpm.yaml b/nfpm.yaml index b342f5e388..9f71db3daf 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -1,7 +1,7 @@ # yaml-language-server: $schema=https://nfpm.goreleaser.com/schema.json name: sable -arch: amd64 +arch: ${PKG_ARCH} platform: linux version: ${PKG_VERSION} release: ${PKG_RELEASE} diff --git a/scripts/cef/copy-libs.sh b/scripts/cef/copy-libs.sh index 4e1fe150b1..578478805f 100755 --- a/scripts/cef/copy-libs.sh +++ b/scripts/cef/copy-libs.sh @@ -12,13 +12,19 @@ PROFILE="${1:-debug}" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" DEST="${2:-$ROOT/src-tauri/target/$PROFILE}" +case "$(uname -m)" in + x86_64) CEF_ARCH=x86_64 ;; + aarch64 | arm64) CEF_ARCH=aarch64 ;; + *) echo "❌ unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + # --target moves build-script output under target//. CEF_DIR="$( - find "$ROOT/src-tauri/target" -type d -name cef_linux_x86_64 \ + find "$ROOT/src-tauri/target" -type d -name "cef_linux_$CEF_ARCH" \ -path "*/$PROFILE/build/*" -print -quit 2>/dev/null || true )" if [ -z "$CEF_DIR" ]; then - echo "❌ CEF dist not found under target/**/$PROFILE/build — build with --features cef first." >&2 + echo "❌ cef_linux_$CEF_ARCH not found under target/**/$PROFILE/build — build with --features cef first." >&2 exit 1 fi diff --git a/scripts/cef/package.sh b/scripts/cef/package.sh index aca38aaf87..6a0f1c2793 100755 --- a/scripts/cef/package.sh +++ b/scripts/cef/package.sh @@ -19,6 +19,13 @@ if [[ "$VERSION" == *-* ]]; then RPM_ITERATION="0.${PRERELEASE}" fi +# ARCH is read by appimagetool. +case "$(uname -m)" in + x86_64) export ARCH=x86_64; NFPM_ARCH=amd64 ;; + aarch64 | arm64) export ARCH=aarch64; NFPM_ARCH=arm64 ;; + *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + STAGE="$ROOT/src-tauri/target/release" OUT="$STAGE/bundle" WORK="$STAGE/cef-pkg" @@ -28,7 +35,7 @@ DISPLAY_NAME="${3:-}" APPIMAGE_BIN_PATH="${4:-}" if [ -z "$BIN_PATH" ]; then for candidate in "$STAGE/Sable Nightly" "$STAGE/Sable" "$STAGE/sable" \ - "$ROOT/src-tauri/target/x86_64-unknown-linux-gnu/release/sable"; do + "$ROOT/src-tauri/target/$ARCH-unknown-linux-gnu/release/sable"; do [ -x "$candidate" ] || continue BIN_PATH="$candidate" break @@ -111,10 +118,10 @@ EOF chmod 755 "$PKGROOT/usr/bin/sable" cp -a "$WORK/stage/share/." "$PKGROOT/usr/share/" - PKGROOT="$PKGROOT" PKG_VERSION="$DEB_VERSION" PKG_RELEASE=1 nfpm pkg -f nfpm.yaml -p deb \ - -t "$OUT/deb/Sable-${VERSION}-linux-x86_64.deb" - PKGROOT="$PKGROOT" PKG_VERSION="$RPM_VERSION" PKG_RELEASE="$RPM_ITERATION" nfpm pkg -f nfpm.yaml -p rpm \ - -t "$OUT/rpm/Sable-${VERSION}-linux-x86_64.rpm" + PKGROOT="$PKGROOT" PKG_ARCH="$NFPM_ARCH" PKG_VERSION="$DEB_VERSION" PKG_RELEASE=1 nfpm pkg -f nfpm.yaml -p deb \ + -t "$OUT/deb/Sable-${VERSION}-linux-${ARCH}.deb" + PKGROOT="$PKGROOT" PKG_ARCH="$NFPM_ARCH" PKG_VERSION="$RPM_VERSION" PKG_RELEASE="$RPM_ITERATION" nfpm pkg -f nfpm.yaml -p rpm \ + -t "$OUT/rpm/Sable-${VERSION}-linux-${ARCH}.rpm" else echo "nfpm not found" >&2 exit 1 @@ -130,7 +137,7 @@ exec "$HERE/usr/bin/sable" "$@" EOF chmod 755 "$APPDIR/AppRun" -APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$APPIMAGETOOL_CMD" "$APPDIR" \ - "$OUT/appimage/Sable-${VERSION}-linux-x86_64.AppImage" +APPIMAGE_EXTRACT_AND_RUN=1 "$APPIMAGETOOL_CMD" "$APPDIR" \ + "$OUT/appimage/Sable-${VERSION}-linux-${ARCH}.AppImage" echo "Packages in: $OUT" 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/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 `