diff --git a/.github/actions/install-desktop-deps/action.yml b/.github/actions/install-desktop-deps/action.yml index 50f85262e2c..575f197509b 100644 --- a/.github/actions/install-desktop-deps/action.yml +++ b/.github/actions/install-desktop-deps/action.yml @@ -34,6 +34,8 @@ runs: libpipewire-0.3-dev \ libspa-0.2-dev \ libasound2-dev \ + libasound2-plugins \ + pulseaudio-utils \ libdbus-1-dev \ libudev-dev \ libx11-dev \ @@ -49,4 +51,8 @@ runs: libva-dev \ xvfb \ xauth \ + rpm \ + cpio \ + libarchive-tools \ + zstd \ patchelf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f67f266a21..6b91ef631b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,10 @@ jobs: shell: bash run: node scripts/setup.js + - name: Test desktop installation and storage helpers + shell: bash + run: cargo test --locked -p cap-utils -p cap-cli-install --lib + - name: Build desktop binaries shell: bash run: ./scripts/build-desktop-binaries.sh ${{ matrix.settings.target }} @@ -270,10 +274,14 @@ jobs: shell: bash run: ./scripts/build-desktop-binaries.sh ${{ matrix.settings.target }} - - name: Test Linux recording and encoder regressions + - name: Test Linux desktop regressions if: ${{ runner.os == 'Linux' }} shell: bash run: | + node --test scripts/linux-bundle-config.test.mjs scripts/finalize-linux-appimage.test.mjs + pnpm --dir apps/desktop exec vitest run scripts/prepare.test.js + cargo test --locked -p cap-utils -p cap-cli-install --lib + cargo test --locked -p cap --bin cap record::tests cargo test --locked -p cap-recording --lib cargo test --locked -p cap-enc-ffmpeg env: diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 1445ee2e568..a26dcec2d74 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -235,7 +235,7 @@ jobs: console.error("package.version line not found in " + path); process.exit(1); } - toml = toml.replace(re, `version = "${version}"`); + toml = toml.replace(re, "version = \"" + version + "\""); fs.writeFileSync(path, toml); console.log("Stamped " + path + " with " + version); ' @@ -340,7 +340,7 @@ jobs: - name: Build app working-directory: apps/desktop - run: ${{ runner.os == 'Linux' && 'CARGO_PROFILE_RELEASE_DEBUG=0 ' || '' }}pnpm build:tauri --target ${{ matrix.settings.target }} --config src-tauri/tauri.prod.conf.json ${{ runner.os == 'Windows' && '--bundles nsis' || runner.os == 'Linux' && '--bundles deb --verbose' || '' }} + run: ${{ runner.os == 'Linux' && 'CARGO_PROFILE_RELEASE_DEBUG=0 node ../../scripts/build-linux-packages.mjs' || 'pnpm build:tauri --target' }} ${{ matrix.settings.target }} --config src-tauri/tauri.prod.conf.json ${{ runner.os == 'Windows' && '--bundles nsis' || runner.os == 'Linux' && '--verbose' || '' }} env: # https://github.com/tauri-apps/tauri-action/issues/740 CI: false @@ -426,12 +426,6 @@ jobs: fi echo "Frontend build output OK ($FE, $(wc -c < "$FE") bytes)" - UNSUPPORTED_ARTIFACT="$(find target/${{ matrix.settings.target }}/release/bundle -type f \( -name '*.AppImage' -o -name '*.rpm' \) -print -quit)" - if [[ -n "$UNSUPPORTED_ARTIFACT" ]]; then - echo "::error::Unexpected Linux non-deb artifact produced: $UNSUPPORTED_ARTIFACT" - exit 1 - fi - DEB="$(find target/${{ matrix.settings.target }}/release/bundle/deb -name '*.deb' | head -n1)" if [[ -z "$DEB" ]]; then echo "::error::No .deb produced"; exit 1; fi if [[ ! -s "$DEB.sig" ]]; then echo "::error::No .deb updater signature produced next to $DEB"; exit 1; fi @@ -450,7 +444,7 @@ jobs: done GPUI_RPATH="$(objdump -p "$WORK/usr/bin/cap-gpui" | awk '/RPATH|RUNPATH/{print $2}')" - if [[ "$GPUI_RPATH" != *'$ORIGIN/../lib/cap'* ]]; then + if [[ "$GPUI_RPATH" != *"\$ORIGIN/../lib/cap"* ]]; then echo "::error::The bundled GPUI executable cannot resolve shared libraries from /usr/lib/cap" exit 1 fi @@ -464,7 +458,6 @@ jobs: fi # (2) every FFmpeg soname the binary NEEDs is bundled - BIN="$WORK/usr/bin/Cap" NEEDED="$(for executable in "${EXECUTABLES[@]}"; do objdump -p "$executable"; done | awk '/NEEDED/{print $2}' | grep -E '^lib(av|sw|postproc)' | sort -u || true)" echo "Binary NEEDs FFmpeg sonames:"; echo "${NEEDED:-(none)}" echo "Bundled FFmpeg libs:"; ls -1 "$WORK/usr/lib/cap" 2>/dev/null || true @@ -519,6 +512,73 @@ jobs: fi echo "Runtime dependency declarations OK" + - name: Verify Linux RPM and AppImage contents + if: ${{ runner.os == 'Linux' }} + shell: bash + env: + CAP_RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + BUNDLE="${{ github.workspace }}/target/${{ matrix.settings.target }}/release/bundle" + RPM="$BUNDLE/rpm/Cap-$CAP_RELEASE_VERSION-1.x86_64.rpm" + APPIMAGE="$BUNDLE/appimage/Cap_${CAP_RELEASE_VERSION}_amd64.AppImage" + test -s "$APPIMAGE.sig" + WORK="$(mktemp -d)" + trap 'rm -rf "$WORK"' EXIT + mkdir "$WORK/rpm" "$WORK/appimage" + (cd "$WORK/appimage" && "$APPIMAGE" --appimage-extract > /dev/null) + APPROOT="$WORK/appimage/squashfs-root" + ROOTS=("$APPROOT") + if [[ "$CAP_RELEASE_VERSION" != *-* ]]; then + test -s "$RPM" + test "$(rpm -qp --queryformat '%{VERSION}-%{RELEASE}' "$RPM")" = "$CAP_RELEASE_VERSION-1" + rpm -Kv "$RPM" + bsdtar -xf "$RPM" -C "$WORK/rpm" + test "$(cat "$WORK/rpm/usr/lib/cap/package-format")" = rpm + ROOTS+=("$WORK/rpm") + fi + for ROOT in "${ROOTS[@]}"; do + for binary in Cap cap-muxer cap-exporter cap-cli cap-gpui; do + test -x "$ROOT/usr/bin/$binary" + while read -r soname; do + test -f "$ROOT/usr/lib/cap/$soname" + done < <(objdump -p "$ROOT/usr/bin/$binary" | awk '/NEEDED/{print $2}' | grep -E '^lib(av|sw|postproc)' || true) + done + done + test "$(cat "$APPROOT/usr/lib/cap/package-format")" = appimage + test -x "$APPROOT/usr/bin/pactl" + test -s "$APPROOT/usr/lib/alsa-lib/libasound_module_pcm_pulse.so" + test -s "$APPROOT/usr/lib/cap/alsa-pulse.conf" + if [[ -n "$(find "$APPROOT" -name 'libwayland-client.so*' -print -quit)" ]]; then + echo "::error::AppImage bundles a Wayland client that can conflict with host Mesa drivers" + exit 1 + fi + if [[ -n "$(find "$APPROOT/usr/lib" -maxdepth 1 -name 'libpipewire-0.3.so*' -print -quit)" ]]; then + echo "::error::AppImage bundles PipeWire that can conflict with host ALSA plugins" + exit 1 + fi + + - name: Build Arch Linux package + id: build_arch + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + BUNDLE="target/${{ matrix.settings.target }}/release/bundle" + mkdir -p "$BUNDLE/arch" + docker run --rm --network none \ + --env CAP_PACKAGE_UID="$(id -u)" \ + --volume "${{ github.workspace }}:/source:ro" \ + --volume "${{ github.workspace }}/$BUNDLE/arch:/output" \ + archlinux:base-devel@sha256:68bfc3b0d277b08a99101dc9b94aaa03e5ae70cf1b4fb965c03b2b87b915760d \ + bash -c 'useradd --uid "$CAP_PACKAGE_UID" --create-home cap-package && runuser -u cap-package -- bash /source/scripts/build-linux-arch-package.sh "$1" /output' \ + bash "/source/$BUNDLE/deb/Cap_${{ inputs.version }}_amd64.deb" + PACKAGES=("$BUNDLE/arch/"*.pkg.tar.zst) + test "${#PACKAGES[@]}" -eq 1 + test -s "${PACKAGES[0]}" + test "$(bsdtar -xOf "${PACKAGES[0]}" usr/lib/cap/package-format)" = arch + echo "package=../../${PACKAGES[0]}" >> "$GITHUB_OUTPUT" + - name: Verify Windows installer contents if: ${{ runner.os == 'Windows' }} shell: pwsh @@ -659,6 +719,30 @@ jobs: command: release upload ${{ env.CN_APPLICATION }} "${{ inputs.version }}" --file "../../target/${{ matrix.settings.target }}/release/bundle/deb/Cap_${{ inputs.version }}_amd64.deb" --signature "../../target/${{ matrix.settings.target }}/release/bundle/deb/Cap_${{ inputs.version }}_amd64.deb.sig" --public-platform deb-x86_64 --update-platform linux-x86_64-deb ${{ env.CN_CHANNEL_FLAG }} api-key: ${{ secrets.CN_API_KEY }} + - name: Upload Linux AppImage asset + if: ${{ runner.os == 'Linux' }} + uses: crabnebula-dev/cloud-release@v0 + with: + working-directory: apps/desktop + command: release upload ${{ env.CN_APPLICATION }} "${{ inputs.version }}" --file "../../target/${{ matrix.settings.target }}/release/bundle/appimage/Cap_${{ inputs.version }}_amd64.AppImage" --signature "../../target/${{ matrix.settings.target }}/release/bundle/appimage/Cap_${{ inputs.version }}_amd64.AppImage.sig" --public-platform appimage-x86_64 --update-platform linux-x86_64-appimage ${{ env.CN_CHANNEL_FLAG }} + api-key: ${{ secrets.CN_API_KEY }} + + - name: Upload Linux RPM asset + if: ${{ runner.os == 'Linux' && !contains(inputs.version, '-') }} + uses: crabnebula-dev/cloud-release@v0 + with: + working-directory: apps/desktop + command: release upload ${{ env.CN_APPLICATION }} "${{ inputs.version }}" --file "../../target/${{ matrix.settings.target }}/release/bundle/rpm/Cap-${{ inputs.version }}-1.x86_64.rpm" --public-platform rpm-x86_64 ${{ env.CN_CHANNEL_FLAG }} + api-key: ${{ secrets.CN_API_KEY }} + + - name: Upload Arch Linux asset + if: ${{ runner.os == 'Linux' }} + uses: crabnebula-dev/cloud-release@v0 + with: + working-directory: apps/desktop + command: release upload ${{ env.CN_APPLICATION }} "${{ inputs.version }}" --file "${{ steps.build_arch.outputs.package }}" --public-platform pacman-x86_64 ${{ env.CN_CHANNEL_FLAG }} + api-key: ${{ secrets.CN_API_KEY }} + - uses: matbour/setup-sentry-cli@8ef22a4ff03bcd1ebbcaa3a36a81482ca8e3872e - name: Upload debug symbols to Sentry diff --git a/.github/workflows/sync-tests.yml b/.github/workflows/sync-tests.yml index 3e7343db139..658ce494c1f 100644 --- a/.github/workflows/sync-tests.yml +++ b/.github/workflows/sync-tests.yml @@ -125,6 +125,8 @@ jobs: shell: bash run: | cargo test --locked -p cap-editor --lib audio::tests:: + cargo test --locked -p cap-editor --lib audio_output::tests:: + cargo test --locked -p cap-editor --lib playback::tests:: # Real encoders + DASH muxer + remux/validation over full instant-mode # scenarios: pause/resume excision, stall-recovery bursts with diff --git a/apps/cli/src/record.rs b/apps/cli/src/record.rs index 04617847fb8..92aa09977a2 100644 --- a/apps/cli/src/record.rs +++ b/apps/cli/src/record.rs @@ -2,7 +2,7 @@ use cap_project::{ InstantRecordingMeta, Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner, }; use cap_recording::{ - CameraFeed, MicrophoneFeed, + CameraFeed, DoneFut, MicrophoneFeed, PipelineStoppedByUser, feeds::{camera, microphone}, instant_recording, screen_capture::ScreenCaptureTarget, @@ -697,6 +697,13 @@ enum ActorHandle { } impl ActorHandle { + fn done_fut(&self) -> DoneFut { + match self { + Self::Studio(actor) => actor.done_fut(), + Self::Instant(actor) => actor.done_fut(), + } + } + async fn stop(&self) -> Result { match self { Self::Studio(actor) => actor @@ -704,12 +711,12 @@ impl ActorHandle { .await .map(Box::new) .map(CompletedRecording::Studio) - .map_err(|e| e.to_string()), + .map_err(|e| format!("{e:#}")), Self::Instant(actor) => actor .stop() .await .map(CompletedRecording::Instant) - .map_err(|e| e.to_string()), + .map_err(|e| format!("{e:#}")), } } } @@ -826,6 +833,10 @@ async fn start_recording( } RecordMode::Instant => { let mut builder = instant_builder; + #[cfg(target_os = "linux")] + if camera_active { + builder = builder.with_linux_camera_composition(); + } builder = builder.with_max_output_size( cap_recording::RecordingDefaults::default().instant_mode_max_resolution, ); @@ -935,22 +946,44 @@ async fn finalize( stop_file: Option<&Path>, ) -> Result { let outcome = std::panic::AssertUnwindSafe(async { - wait_for_stop(duration, interactive, stop_file).await; - actor.stop().await.map_err(|e| e.to_string()) + finalize_after_stop_trigger( + actor.done_fut().map(|result| match result { + Err(error) if error.is_caused_by::() => Ok(()), + result => result.map_err(|error| error.to_string()), + }), + wait_for_stop(duration, interactive, stop_file), + async { finalize_completed(actor.stop().await?).await }, + ) + .await }) .catch_unwind() .await; - let completed = match outcome { - Ok(Ok(completed)) => completed, - Ok(Err(error)) => return Err(error), - Err(_) => actor - .stop() - .await - .map_err(|e| format!("recording panicked; finalize failed: {e}"))?, - }; + match outcome { + Ok(result) => result, + Err(_) => { + let completed = actor + .stop() + .await + .map_err(|e| format!("recording panicked; finalize failed: {e}"))?; + finalize_completed(completed).await + } + } +} - finalize_completed(completed).await +async fn finalize_after_stop_trigger( + capture_done: impl Future>, + stop_requested: impl Future, + finalize: impl Future>, +) -> Result { + let capture_result = tokio::select! { + biased; + result = capture_done => result, + _ = stop_requested => Ok(()), + }; + let finalized = finalize.await; + capture_result?; + finalized } async fn finalize_completed(completed: CompletedRecording) -> Result { @@ -1202,6 +1235,59 @@ fn emit_record_event(format: OutputFormat, event: &RecordEvent<'_>) -> Result<() mod tests { use super::*; + #[tokio::test] + async fn capture_failure_finalizes_without_waiting_for_stop_request() { + let finalized = std::cell::Cell::new(false); + let result = finalize_after_stop_trigger( + std::future::ready(Err("capture window disappeared".to_string())), + std::future::pending(), + async { + finalized.set(true); + Err::<(), _>("display".to_string()) + }, + ) + .await; + + assert!(finalized.get()); + assert_eq!(result.unwrap_err(), "capture window disappeared"); + } + + #[tokio::test] + async fn stop_request_finalizes_while_capture_is_running() { + let result = finalize_after_stop_trigger( + std::future::pending(), + std::future::ready(()), + std::future::ready(Ok(42)), + ) + .await; + + assert_eq!(result.unwrap(), 42); + } + + #[tokio::test] + async fn completed_capture_finalizes_without_stop_request() { + let result = finalize_after_stop_trigger( + std::future::ready(Ok(())), + std::future::pending(), + std::future::ready(Ok(42)), + ) + .await; + + assert_eq!(result.unwrap(), 42); + } + + #[tokio::test] + async fn finalization_error_is_returned_after_stop_request() { + let result = finalize_after_stop_trigger( + std::future::pending(), + std::future::ready(()), + std::future::ready(Err::<(), _>("failed to finalize recording".to_string())), + ) + .await; + + assert_eq!(result.unwrap_err(), "failed to finalize recording"); + } + #[test] fn record_event_fields_are_camel_case() { let started = serde_json::to_value(RecordEvent::Started { diff --git a/apps/desktop-gpui/assets/icons/caption-close-windows.svg b/apps/desktop-gpui/assets/icons/caption-close-windows.svg new file mode 100644 index 00000000000..81697c14191 --- /dev/null +++ b/apps/desktop-gpui/assets/icons/caption-close-windows.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-gpui/assets/icons/caption-maximize-windows.svg b/apps/desktop-gpui/assets/icons/caption-maximize-windows.svg new file mode 100644 index 00000000000..2b102192114 --- /dev/null +++ b/apps/desktop-gpui/assets/icons/caption-maximize-windows.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-gpui/assets/icons/caption-minimize-windows.svg b/apps/desktop-gpui/assets/icons/caption-minimize-windows.svg new file mode 100644 index 00000000000..d6d32e0bc94 --- /dev/null +++ b/apps/desktop-gpui/assets/icons/caption-minimize-windows.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-gpui/assets/icons/caption-restore-windows.svg b/apps/desktop-gpui/assets/icons/caption-restore-windows.svg new file mode 100644 index 00000000000..25ccb4ea4d1 --- /dev/null +++ b/apps/desktop-gpui/assets/icons/caption-restore-windows.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-gpui/patches/zed-linux.patch b/apps/desktop-gpui/patches/zed-linux.patch new file mode 100644 index 00000000000..93d8da1d61f --- /dev/null +++ b/apps/desktop-gpui/patches/zed-linux.patch @@ -0,0 +1,167 @@ +diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs +index 993b2ff3fa..c59ee3927c 100644 +--- a/crates/gpui_linux/src/linux/wayland/window.rs ++++ b/crates/gpui_linux/src/linux/wayland/window.rs +@@ -112,0 +113,2 @@ pub struct WaylandWindowState { ++ fixed_outer_size: Option>, ++ fixed_geometry_size: Option>, +@@ -594 +596 @@ impl WaylandWindowState { +- Ok(Self { ++ let mut state = Self { +@@ -607,0 +610,2 @@ impl WaylandWindowState { ++ fixed_outer_size: (!options.is_resizable).then_some(options.bounds.size), ++ fixed_geometry_size: None, +@@ -629 +633,24 @@ impl WaylandWindowState { +- }) ++ }; ++ state.update_size_constraints(); ++ Ok(state) ++ } ++ ++ fn update_size_constraints(&mut self) { ++ let Some(outer_size) = self.fixed_outer_size else { ++ return; ++ }; ++ let geometry_size = fixed_window_geometry_size( ++ outer_size, ++ self.inset(), ++ self.tiling, ++ self.renderer.max_texture_size(), ++ self.scale, ++ ); ++ if self.fixed_geometry_size == Some(geometry_size) { ++ return; ++ } ++ if let Some(toplevel) = self.surface_state.toplevel() { ++ toplevel.set_min_size(geometry_size.width, geometry_size.height); ++ toplevel.set_max_size(geometry_size.width, geometry_size.height); ++ self.fixed_geometry_size = Some(geometry_size); ++ } +@@ -903,0 +931 @@ impl WaylandWindowStatePtr { ++ state.update_size_constraints(); +@@ -961 +989,5 @@ impl WaylandWindowStatePtr { +- self.state.borrow_mut().decorations = WindowDecorations::Server; ++ { ++ let mut state = self.state.borrow_mut(); ++ state.decorations = WindowDecorations::Server; ++ state.update_size_constraints(); ++ } +@@ -969 +1001,5 @@ impl WaylandWindowStatePtr { +- self.state.borrow_mut().decorations = WindowDecorations::Client; ++ { ++ let mut state = self.state.borrow_mut(); ++ state.decorations = WindowDecorations::Client; ++ state.update_size_constraints(); ++ } +@@ -1273,0 +1310 @@ impl WaylandWindowStatePtr { ++ state.update_size_constraints(); +@@ -1469 +1506 @@ impl PlatformWindow for WaylandWindow { +- let state = self.borrow(); ++ let mut state = self.borrow_mut(); +@@ -1488,0 +1526,5 @@ impl PlatformWindow for WaylandWindow { ++ if state.fixed_outer_size.is_some() { ++ state.fixed_outer_size = Some(size); ++ state.update_size_constraints(); ++ } ++ +@@ -1778,0 +1821,3 @@ impl PlatformWindow for WaylandWindow { ++ if state.fixed_outer_size.is_some() { ++ return; ++ } +@@ -1856,0 +1902 @@ impl PlatformWindow for WaylandWindow { ++ state.update_size_constraints(); +@@ -1865,0 +1912 @@ impl PlatformWindow for WaylandWindow { ++ state.update_size_constraints(); +@@ -1878,0 +1926 @@ impl PlatformWindow for WaylandWindow { ++ state.update_size_constraints(); +@@ -2084,0 +2133,90 @@ fn inset_by_tiling(mut bounds: Bounds, inset: Pixels, tiling: Tiling) -> ++ ++fn fixed_window_geometry_size( ++ outer_size: Size, ++ inset: Pixels, ++ tiling: Tiling, ++ max_texture_size: u32, ++ scale: f32, ++) -> Size { ++ let limit = (max_texture_size as f32 / scale.max(f32::EPSILON)).max(1.0); ++ let outer_size = outer_size.map(|value| px(f32::from(value).clamp(1.0, limit))); ++ inset_by_tiling( ++ Bounds { ++ origin: Point::default(), ++ size: outer_size, ++ }, ++ inset, ++ tiling, ++ ) ++ .size ++ .map(|value| (f32::from(value) as i32).max(1)) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn fixed_size_tracks_programmatic_expansion_in_logical_pixels() { ++ for height in [395.0, 440.0, 520.0, 395.0] { ++ assert_eq!( ++ fixed_window_geometry_size( ++ size(px(330.0), px(height)), ++ px(0.0), ++ Tiling::default(), ++ 8192, ++ 2.0, ++ ), ++ size(330, height as i32), ++ ); ++ } ++ } ++ ++ #[test] ++ fn fixed_size_uses_the_same_insets_as_xdg_window_geometry() { ++ for tiling in [ ++ Tiling::default(), ++ Tiling::tiled(), ++ Tiling { ++ top: true, ++ left: true, ++ ..Tiling::default() ++ }, ++ ] { ++ let outer = size(px(330.0), px(395.0)); ++ let constrained = fixed_window_geometry_size(outer, px(8.0), tiling, 8192, 2.0); ++ assert_eq!( ++ compute_outer_size( ++ px(8.0), ++ Some(constrained.map(|value| px(value as f32))), ++ tiling, ++ ), ++ Some(outer), ++ ); ++ } ++ } ++ ++ #[test] ++ fn fixed_size_stays_positive_and_within_the_scaled_texture_limit() { ++ assert_eq!( ++ fixed_window_geometry_size( ++ size(px(10000.0), px(10000.0)), ++ px(8.0), ++ Tiling::default(), ++ 8192, ++ 2.0, ++ ), ++ size(4080, 4080), ++ ); ++ assert_eq!( ++ fixed_window_geometry_size( ++ size(px(0.0), px(10.0)), ++ px(8.0), ++ Tiling::default(), ++ 8192, ++ 1.0, ++ ), ++ size(1, 1), ++ ); ++ } ++} diff --git a/apps/desktop-gpui/patches/zed-windows.patch b/apps/desktop-gpui/patches/zed-windows.patch new file mode 100644 index 00000000000..06c1a5a173b --- /dev/null +++ b/apps/desktop-gpui/patches/zed-windows.patch @@ -0,0 +1,345 @@ +--- a/crates/gpui_windows/src/directx_renderer.rs ++++ b/crates/gpui_windows/src/directx_renderer.rs +@@ -1985,0 +1986,38 @@ ++ ++#[cfg(test)] ++mod tests { ++ use super::shader_resources::{RawShaderBytes, ShaderModule, ShaderTarget}; ++ use gpui::{PolychromeSprite, Quad}; ++ use windows::{Win32::Graphics::Direct3D::Fxc::D3DDisassemble, core::PCSTR}; ++ ++ #[test] ++ fn compiled_shader_strides_match_uploaded_primitives() { ++ for (module, stride) in [ ++ (ShaderModule::Quad, size_of::()), ++ ( ++ ShaderModule::PolychromeSprite, ++ size_of::(), ++ ), ++ ] { ++ for target in [ShaderTarget::Vertex, ShaderTarget::Fragment] { ++ let shader = RawShaderBytes::new(module, target).unwrap(); ++ let bytes = shader.as_bytes(); ++ let assembly = unsafe { ++ D3DDisassemble(bytes.as_ptr().cast(), bytes.len(), 0, PCSTR::null()).unwrap() ++ }; ++ let text = unsafe { ++ std::slice::from_raw_parts( ++ assembly.GetBufferPointer().cast::(), ++ assembly.GetBufferSize(), ++ ) ++ }; ++ let text = String::from_utf8_lossy(text); ++ let declaration = format!("dcl_resource_structured t1, {stride}"); ++ assert!( ++ text.lines().any(|line| line.trim() == declaration), ++ "{module:?} {target:?} does not match Rust's {stride}-byte stride" ++ ); ++ } ++ } ++ } ++} +--- a/crates/gpui_windows/src/shaders.hlsl ++++ b/crates/gpui_windows/src/shaders.hlsl +@@ -36,0 +37,11 @@ ++}; ++ ++struct EdgeFadeParams { ++ float top_y; ++ float bottom_y; ++ float band_top; ++ float band_bottom; ++ float left_x; ++ float right_x; ++ float band_left; ++ float band_right; +@@ -94,0 +106,17 @@ ++ ++float edge_fade_alpha(float2 position, EdgeFadeParams fade) { ++ float ramp = 1.0; ++ if (fade.band_top > 0.0) { ++ ramp = min(ramp, saturate((position.y - fade.top_y) / fade.band_top)); ++ } ++ if (fade.band_bottom > 0.0) { ++ ramp = min(ramp, saturate((fade.bottom_y - position.y) / fade.band_bottom)); ++ } ++ if (fade.band_left > 0.0) { ++ ramp = min(ramp, saturate((position.x - fade.left_x) / fade.band_left)); ++ } ++ if (fade.band_right > 0.0) { ++ ramp = min(ramp, saturate((fade.right_x - position.x) / fade.band_right)); ++ } ++ return ramp * ramp; ++} +@@ -504,0 +533 @@ ++ EdgeFadeParams fade; +@@ -556,0 +586,2 @@ ++ float fade_alpha = edge_fade_alpha(input.position.xy, quad.fade); ++ background_color.a *= fade_alpha; +@@ -663,0 +695 @@ ++ border_color.a *= fade_alpha; +@@ -1211,0 +1244 @@ ++ EdgeFadeParams fade; +@@ -1256 +1289 @@ +- color.a *= sprite.opacity * saturate(0.5 - distance); ++ color.a *= sprite.opacity * saturate(0.5 - distance) * edge_fade_alpha(input.position.xy, sprite.fade); +--- a/crates/gpui_windows/src/window.rs ++++ b/crates/gpui_windows/src/window.rs +@@ -216 +216,5 @@ +- placement.rcNormalPosition, ++ translate_rect( ++ placement.rcNormalPosition, ++ workspace_offset(self.hwnd, self.display.get(), self.scale_factor.get()), ++ 1, ++ ), +@@ -463 +467 @@ +- (WS_EX_TOOLWINDOW, WINDOW_STYLE(0x0)) ++ (WS_EX_TOOLWINDOW, WS_POPUP) +@@ -465,0 +470,5 @@ ++ if hide_title_bar { ++ dwstyle |= WS_POPUP; ++ } else { ++ dwstyle |= WS_CAPTION; ++ } +@@ -550,0 +560 @@ ++ params.window_min_size, +@@ -621,2 +631,30 @@ +- let bounds = gpui::bounds(self.bounds().origin, size).to_device_pixels(self.scale_factor()); +- let rect = calculate_window_rect(bounds, &self.state.border_offset); ++ let size = size.to_device_pixels(self.scale_factor()); ++ let mut rect = RECT::default(); ++ if unsafe { GetWindowRect(hwnd, &mut rect) } ++ .log_err() ++ .is_none() ++ { ++ return; ++ } ++ rect.right = rect.left + size.width.0 + self.state.border_offset.width_offset.get(); ++ rect.bottom = rect.top + size.height.0 + self.state.border_offset.height_offset.get(); ++ let movable = !is_tool_window(hwnd) && !self.state.is_fullscreen(); ++ if movable { ++ rect = fit_window_rect( ++ rect, ++ self.state ++ .display ++ .get() ++ .visible_bounds() ++ .to_device_pixels(self.scale_factor()), ++ minimum_outer_size( ++ self.state.min_size, ++ self.scale_factor(), ++ &self.state.border_offset, ++ ), ++ ); ++ } ++ let mut flags = SWP_NOACTIVATE | SWP_NOZORDER; ++ if !movable { ++ flags |= SWP_NOMOVE; ++ } +@@ -631,2 +669,2 @@ +- bounds.origin.x.0, +- bounds.origin.y.0, ++ rect.left, ++ rect.top, +@@ -635 +673 @@ +- SWP_NOMOVE, ++ flags, +@@ -637,0 +676,29 @@ ++ .log_err(); ++ } ++ }) ++ .detach(); ++ } ++ ++ fn start_window_move(&self) { ++ if !self.is_movable { ++ return; ++ } ++ let this = self.0.clone(); ++ self.executor ++ .spawn(async move { ++ let mut cursor = POINT::default(); ++ unsafe { ++ if GetAsyncKeyState(VK_LBUTTON.0 as i32) >= 0 ++ || GetCursorPos(&mut cursor).log_err().is_none() ++ { ++ return; ++ } ++ ReleaseCapture().log_err(); ++ let position = (cursor.x as u16 as u32) | ((cursor.y as u16 as u32) << 16); ++ PostMessageW( ++ Some(this.hwnd), ++ WM_SYSCOMMAND, ++ WPARAM((SC_MOVE | HTCAPTION) as usize), ++ LPARAM(position as isize), ++ ) ++ .context("unable to start window move") +@@ -1510,0 +1578 @@ ++ minimum_size: Option>, +@@ -1524 +1592,10 @@ +- placement.rcNormalPosition = calculate_window_rect(bounds, border_offset); ++ let mut rect = calculate_window_rect(bounds, border_offset); ++ if !is_tool_window(hwnd) { ++ rect = fit_window_rect( ++ rect, ++ display.visible_bounds().to_device_pixels(scale_factor), ++ minimum_outer_size(minimum_size, scale_factor, border_offset), ++ ); ++ } ++ placement.rcNormalPosition = ++ translate_rect(rect, workspace_offset(hwnd, display, scale_factor), -1); +@@ -1525,0 +1603,54 @@ ++} ++ ++fn is_tool_window(hwnd: HWND) -> bool { ++ unsafe { get_window_long(hwnd, GWL_EXSTYLE) as u32 & WS_EX_TOOLWINDOW.0 != 0 } ++} ++ ++fn workspace_offset(hwnd: HWND, display: WindowsDisplay, scale_factor: f32) -> Point { ++ if is_tool_window(hwnd) { ++ return point(DevicePixels(0), DevicePixels(0)); ++ } ++ display ++ .visible_bounds() ++ .to_device_pixels(scale_factor) ++ .origin ++ - display.physical_bounds().origin ++} ++ ++fn translate_rect(mut rect: RECT, offset: Point, direction: i32) -> RECT { ++ rect.left += offset.x.0 * direction; ++ rect.right += offset.x.0 * direction; ++ rect.top += offset.y.0 * direction; ++ rect.bottom += offset.y.0 * direction; ++ rect ++} ++ ++fn minimum_outer_size( ++ minimum: Option>, ++ scale_factor: f32, ++ border_offset: &WindowBorderOffset, ++) -> Size { ++ let minimum = minimum.unwrap_or_default().to_device_pixels(scale_factor); ++ size( ++ DevicePixels((minimum.width.0 + border_offset.width_offset.get()).max(1)), ++ DevicePixels((minimum.height.0 + border_offset.height_offset.get()).max(1)), ++ ) ++} ++ ++fn fit_window_rect(rect: RECT, work: Bounds, minimum: Size) -> RECT { ++ let width = ++ (rect.right - rect.left).clamp(minimum.width.0, work.size.width.0.max(minimum.width.0)); ++ let height = ++ (rect.bottom - rect.top).clamp(minimum.height.0, work.size.height.0.max(minimum.height.0)); ++ let left = rect ++ .left ++ .clamp(work.left().0, (work.right().0 - width).max(work.left().0)); ++ let top = rect ++ .top ++ .clamp(work.top().0, (work.bottom().0 - height).max(work.top().0)); ++ RECT { ++ left, ++ top, ++ right: left + width, ++ bottom: top + height, ++ } +@@ -1612,2 +1743,2 @@ +- use super::ClickState; +- use gpui::{DevicePixels, MouseButton, point}; ++ use super::{ClickState, fit_window_rect, translate_rect}; ++ use gpui::{Bounds, DevicePixels, MouseButton, point, size}; +@@ -1614,0 +1746,66 @@ ++ use windows::Win32::Foundation::RECT; ++ ++ #[test] ++ fn expanded_window_moves_above_bottom_taskbar() { ++ let work = Bounds { ++ origin: point(DevicePixels(0), DevicePixels(0)), ++ size: size(DevicePixels(1280), DevicePixels(760)), ++ }; ++ let rect = fit_window_rect( ++ RECT { ++ left: 467, ++ top: 199, ++ right: 1083, ++ bottom: 867, ++ }, ++ work, ++ size(DevicePixels(1), DevicePixels(1)), ++ ); ++ assert_eq!( ++ (rect.left, rect.top, rect.right, rect.bottom), ++ (467, 92, 1083, 760) ++ ); ++ } ++ ++ #[test] ++ fn oversized_settings_shrink_but_editor_minimum_is_preserved() { ++ let work = Bounds { ++ origin: point(DevicePixels(-1280), DevicePixels(0)), ++ size: size(DevicePixels(1280), DevicePixels(760)), ++ }; ++ let requested = RECT { ++ left: -1100, ++ top: -15, ++ right: -302, ++ bottom: 799, ++ }; ++ let settings = fit_window_rect(requested, work, size(DevicePixels(796), DevicePixels(599))); ++ assert_eq!( ++ (settings.left, settings.top, settings.right, settings.bottom), ++ (-1100, 0, -302, 760) ++ ); ++ let editor = fit_window_rect(requested, work, size(DevicePixels(1296), DevicePixels(839))); ++ assert_eq!( ++ (editor.left, editor.top, editor.right, editor.bottom), ++ (-1280, 0, 16, 839) ++ ); ++ } ++ ++ #[test] ++ fn top_and_left_taskbar_workspace_coordinates_round_trip() { ++ let screen = RECT { ++ left: -1800, ++ top: 80, ++ right: -900, ++ bottom: 700, ++ }; ++ for offset in [ ++ point(DevicePixels(0), DevicePixels(40)), ++ point(DevicePixels(60), DevicePixels(0)), ++ ] { ++ let workspace = translate_rect(screen, offset, -1); ++ assert_eq!(workspace.left, screen.left - offset.x.0); ++ assert_eq!(workspace.top, screen.top - offset.y.0); ++ assert_eq!(translate_rect(workspace, offset, 1), screen); ++ } ++ } +--- a/crates/gpui_windows/src/events.rs ++++ b/crates/gpui_windows/src/events.rs +@@ -847,5 +846,0 @@ +- +- // SetWindowPos may not send WM_SIZE for maximized windows in some cases, +- // so we manually update the size to ensure proper rendering +- let device_size = size(DevicePixels(width), DevicePixels(height)); +- self.handle_size_change(device_size, new_scale_factor, true); +@@ -858,3 +852,0 @@ +- // this will emit `WM_SIZE` and `WM_MOVE` right here +- // even before this function returns +- // the new size is handled in `WM_SIZE` +@@ -873,0 +866,15 @@ ++ } ++ ++ // SetWindowPos can omit a final WM_SIZE; its outer bounds also include native captions. ++ let mut client_rect = RECT::default(); ++ if !unsafe { IsIconic(handle) }.as_bool() ++ && unsafe { GetClientRect(handle, &mut client_rect) } ++ .context("unable to get client bounds after dpi has changed") ++ .log_err() ++ .is_some() ++ { ++ let device_size = size( ++ DevicePixels((client_rect.right - client_rect.left).max(1)), ++ DevicePixels((client_rect.bottom - client_rect.top).max(1)), ++ ); ++ self.handle_size_change(device_size, self.state.scale_factor.get(), true); diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index a07107f0a96..655475184b9 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -383,6 +383,10 @@ pub fn show_main_window(cx: &mut App) { // focus gate; here the reshow *is* the trigger, so a recording // made a moment ago is in the list without a restart. view.refresh_recents(window, cx); + #[cfg(target_os = "linux")] + if let Err(error) = platform::set_x11_window_visible(window, true) { + tracing::warn!(%error, "could not show the X11 main window"); + } platform::native_window(window) }) .ok() @@ -454,7 +458,13 @@ pub fn heal_main_window_style(cx: &mut App) { pub fn hide_main_window(cx: &mut App) { let main = cx.global::().main; let native = main - .update(cx, |_, window, _| platform::native_window(window)) + .update(cx, |_, window, _| { + #[cfg(target_os = "linux")] + if let Err(error) = platform::set_x11_window_visible(window, false) { + tracing::warn!(%error, "could not hide the X11 main window"); + } + platform::native_window(window) + }) .ok() .flatten(); cx.spawn(async move |cx| { @@ -603,7 +613,7 @@ pub fn open_settings(page: Page, cx: &mut App) { // hand-draws its own.) titlebar: Some(gpui::TitlebarOptions { title: Some("Cap Settings".into()), - appears_transparent: true, + appears_transparent: !cfg!(target_os = "windows"), traffic_light_position: Some(settings_window::TRAFFIC_LIGHTS), }), // A normal window, not a panel: the Tauri Settings window is an @@ -748,7 +758,7 @@ pub fn open_onboarding(cx: &mut App) { window_bounds: Some(WindowBounds::Windowed(bounds)), titlebar: Some(gpui::TitlebarOptions { title: Some("Welcome to Cap".into()), - appears_transparent: true, + appears_transparent: !cfg!(target_os = "windows"), traffic_light_position: Some(onboarding_window::TRAFFIC_LIGHTS), }), kind: WindowKind::Normal, @@ -882,7 +892,7 @@ pub fn open_mode_select(cx: &mut App) -> bool { // `TitleBarStyle::Overlay`). titlebar: Some(gpui::TitlebarOptions { title: Some("Cap Mode Selection".into()), - appears_transparent: true, + appears_transparent: !cfg!(target_os = "windows"), traffic_light_position: mode_select_window::TRAFFIC_LIGHTS, }), // An ordinary window that activates the dock icon @@ -997,7 +1007,7 @@ pub fn open_teleprompter(cx: &mut App) { // buttons, moved, as on the settings window. titlebar: Some(gpui::TitlebarOptions { title: Some("Cap Teleprompter".into()), - appears_transparent: true, + appears_transparent: !cfg!(target_os = "windows"), traffic_light_position: Some(teleprompter_window::TRAFFIC_LIGHTS), }), // `alwaysOnTop: true` + `visibleOnAllWorkspaces: true` are applied @@ -1589,6 +1599,8 @@ fn open_overlay( // one overlay has to hold focus for it to arrive. let focus_handle = view.focus_handle(); window.focus(&focus_handle, cx); + #[cfg(any(target_os = "linux", target_os = "windows"))] + window.activate_window(); } window.refresh(); }) @@ -2084,10 +2096,27 @@ pub fn open_camera_window(cx: &mut App) { origin: point(px(x), px(y)), size: size(px(width), px(height)), })), + #[cfg(not(target_os = "linux"))] titlebar: None, + #[cfg(target_os = "linux")] + titlebar: Some(gpui::TitlebarOptions { + title: Some("Cap Camera".into()), + ..Default::default() + }), + #[cfg(target_os = "linux")] + app_id: Some("Cap".into()), + #[cfg(target_os = "linux")] + window_decorations: Some(gpui::WindowDecorations::Client), // Non-activating panel, same as the bar: the bubble is clickable // without stealing focus from what is being recorded. + #[cfg(not(target_os = "linux"))] kind: WindowKind::PopUp, + #[cfg(target_os = "linux")] + kind: if cap_recording::screenshot::uses_wayland_portal() { + WindowKind::Floating + } else { + WindowKind::PopUp + }, focus: false, show: true, is_resizable: false, @@ -2163,6 +2192,9 @@ fn camera_frame(cx: &mut App) -> Option { handle .update(cx, |_, window, _| { let native = platform::native_window(window)?; + #[cfg(target_os = "windows")] + let frame = platform::window_logical_frame(&native); + #[cfg(not(target_os = "windows"))] let frame = platform::window_frame(&native); let origin = window.bounds().origin; Some(CameraFrame { @@ -2186,11 +2218,20 @@ fn move_camera_window(camera: CameraFrame, to: (f32, f32), cx: &mut App) { if dx.abs() < 0.5 && dy.abs() < 0.5 { return; } + #[cfg(not(target_os = "windows"))] let (frame_x, frame_y, width, height) = camera.frame; + #[cfg(target_os = "windows")] + let (_, _, width, height) = camera.frame; + #[cfg(target_os = "windows")] + let (x, y) = (f64::from(to.0), f64::from(to.1)); // gpui +y is down, AppKit +y is up. + #[cfg(not(target_os = "windows"))] let (x, y) = (frame_x + dx, frame_y - dy); let native = camera.native; cx.spawn(async move |_| { + #[cfg(target_os = "windows")] + platform::set_window_logical_frame(&native, x, y, width, height); + #[cfg(not(target_os = "windows"))] platform::set_window_frame(&native, x, y, width, height); }) .detach(); @@ -2416,7 +2457,7 @@ pub fn open_editor(project_path: PathBuf, cx: &mut App) { // left group reserves an `h-full w-16` spacer for them. titlebar: Some(gpui::TitlebarOptions { title: Some("Cap Editor".into()), - appears_transparent: true, + appears_transparent: !cfg!(target_os = "windows"), traffic_light_position: editor_window::TRAFFIC_LIGHTS, }), // An ordinary window that activates the dock icon @@ -2458,6 +2499,8 @@ pub fn open_editor(project_path: PathBuf, cx: &mut App) { handle .update(cx, |view, window, cx| { platform::kick_display_link(window); + #[cfg(target_os = "windows")] + platform::maximize_if_larger_than_work_area(window, cx); view.focus_root(window, cx); tracing::info!( number = platform::window_number(window), @@ -3599,7 +3642,7 @@ pub fn open_screenshot_editor(path: PathBuf, cx: &mut App) { window_bounds: Some(WindowBounds::Windowed(bounds)), titlebar: Some(gpui::TitlebarOptions { title: Some("Cap Screenshot Editor".into()), - appears_transparent: true, + appears_transparent: !cfg!(target_os = "windows"), traffic_light_position: None, }), kind: WindowKind::Normal, diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 7af985b8a73..03be91d21be 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -47,6 +47,10 @@ const ICONS: &[(&str, &[u8])] = assets!("icons": "camera.svg", "caret-down.svg", "captions.svg", + "caption-close-windows.svg", + "caption-maximize-windows.svg", + "caption-minimize-windows.svg", + "caption-restore-windows.svg", "circle.svg", "check.svg", "clapperboard.svg", diff --git a/apps/desktop-gpui/src/camera_window.rs b/apps/desktop-gpui/src/camera_window.rs index 6ab01a30fcf..353fad82382 100644 --- a/apps/desktop-gpui/src/camera_window.rs +++ b/apps/desktop-gpui/src/camera_window.rs @@ -442,10 +442,11 @@ impl CameraPreviewView { frame: Arc, dims: (usize, usize), cx: &mut Context, - ) { - self.latest_frame = Some(frame); + ) -> Option> { + let previous = self.latest_frame.replace(frame); self.frame_dims = Some(dims); cx.notify(); + previous } fn set_chrome(&mut self, radius: f32, size: f32, cx: &mut Context) { @@ -509,6 +510,7 @@ impl Render for CameraPreviewView { container = container.child( gpui::img(image) .size_full() + .rounded(px(radius)) .object_fit(gpui::ObjectFit::Cover), ); } @@ -659,6 +661,13 @@ impl CameraWindow { // `document.documentElement.classList.toggle("dark", true)` // (`camera.tsx:128`): the bubble is always dark, whatever the app // theme preference says. + #[cfg(target_os = "windows")] + platform::apply_window_theme( + window, + platform::ForcedAppearance::Dark, + cx.foreground_executor(), + ); + #[cfg(not(target_os = "windows"))] platform::apply_window_theme(window, platform::ForcedAppearance::Dark); let theme = Theme::dark(); let state = store::load().camera_window.unwrap_or_default(); @@ -790,9 +799,12 @@ impl CameraWindow { let first_frame = self.frame_dims.is_none(); let dims_changed = self.frame_dims != Some(frame.dims); self.frame_dims = Some(frame.dims); - self.preview.update(cx, |preview, cx| { + let previous = self.preview.update(cx, |preview, cx| { preview.set_frame(frame.image, frame.dims, cx) }); + if let Some(previous) = previous { + let _ = window.drop_image(previous); + } if dims_changed { self.apply_window_size(window, cx); cx.notify(); @@ -997,6 +1009,23 @@ impl CameraWindow { } } + #[cfg(target_os = "windows")] + if let Some(native) = platform::native_window(window) { + cx.spawn(async move |_, _| { + platform::set_window_logical_frame( + &native, + f64::from(new_x), + f64::from(new_y), + f64::from(width), + f64::from(height), + ); + }) + .detach(); + } else { + window.resize(size(px(width), px(height))); + } + + #[cfg(not(target_os = "windows"))] if let Some(native) = platform::native_window(window) { let (frame_x, frame_y, _, frame_height) = platform::window_frame(&native); // AppKit y grows upward: keep the gpui-space top edge, then apply diff --git a/apps/desktop-gpui/src/diagnostics.rs b/apps/desktop-gpui/src/diagnostics.rs index 26ff285783c..404458359db 100644 --- a/apps/desktop-gpui/src/diagnostics.rs +++ b/apps/desktop-gpui/src/diagnostics.rs @@ -1139,7 +1139,7 @@ mod tests { std::fs::write(target.join(cap_bin_name()), b"dev").unwrap(); assert_eq!( resolve_selftest_binary_in(None, Some(&exe_dir), Some(&bundle), Some(&root)), - Some(target.join(cap_bin_name())) + cfg!(debug_assertions).then(|| target.join(cap_bin_name())) ); // The installed app beats the dev checkout. diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index bb684f7a438..5969858a529 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -424,7 +424,8 @@ impl EditorWindow { compression_bpp: ui.bpp(), cursor_only: ui.cursor_only, }; - let force = ui.force_ffmpeg; + // Match Windows editor playback: a fresh Media Foundation preview seek can return black. + let force = cfg!(target_os = "windows") || ui.force_ffmpeg; ui.preview_error = None; ui.preview_task = Some(cx.spawn_in(window, async move |this, cx| { cx.background_executor() diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 1888df56d23..13b984fda76 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -174,6 +174,14 @@ fn init_logging() -> Option { } fn main() { + #[cfg(target_os = "linux")] + if let Some(config) = cap_utils::linux_package::appimage_alsa_config_path() { + // Logging starts a worker thread, so configure the process environment first. + unsafe { + std::env::set_var("ALSA_CONFIG_PATH", config); + } + } + let _log_guard = init_logging(); // A relaunch means "run the code I just built": take over from any @@ -234,7 +242,25 @@ fn main() { // traffic lights -- the whole shell is custom-drawn. In gpui a // `None` titlebar drops NSClosable/NSMiniaturizable/NSResizable // from the style mask, which is the equivalent. + #[cfg(not(any(target_os = "linux", target_os = "windows")))] titlebar: None, + #[cfg(target_os = "windows")] + titlebar: Some(gpui::TitlebarOptions { + title: Some("Cap".into()), + appears_transparent: true, + ..Default::default() + }), + #[cfg(target_os = "linux")] + titlebar: Some(gpui::TitlebarOptions { + title: Some("Cap".into()), + ..Default::default() + }), + #[cfg(target_os = "linux")] + app_id: Some("Cap".into()), + #[cfg(target_os = "linux")] + window_min_size: Some(size(px(MAIN_WINDOW_WIDTH), px(MAIN_WINDOW_HEIGHT))), + #[cfg(target_os = "linux")] + window_decorations: Some(gpui::WindowDecorations::Client), // Stays `Normal` and gets its panel treatment (level 100, // all Spaces) from `platform::apply_panel_behavior` below. // `WindowKind::Floating` is not the answer: it allocates an @@ -348,6 +374,10 @@ fn main() { shadow: true, }, ); + #[cfg(target_os = "linux")] + if let Err(error) = platform::remove_x11_window_decorations(window) { + tracing::warn!(%error, "could not remove X11 main window decorations"); + } tracing::info!( number = platform::window_number(window), "main window opened" diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index 1b1344d7ae2..07a85442453 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -405,6 +405,9 @@ impl MainWindow { cx: &mut Context, ) -> Self { crate::theme::bind_window(window, cx); + #[cfg(target_os = "windows")] + cx.observe_window_activation(window, |_, _, cx| cx.notify()) + .detach(); let theme = Theme::for_window(window, cx, true); let mut previous_phase = Phase::Idle; cx.observe_in(&session, window, move |this, session, window, cx| { @@ -1130,12 +1133,28 @@ impl MainWindow { let to = self.window_size(); tracing::info!(expanded = self.expanded, "toggling main window size"); + #[cfg(target_os = "linux")] + let uses_wayland = matches!( + raw_window_handle::HasWindowHandle::window_handle(window), + Ok(handle) if matches!(handle.as_raw(), raw_window_handle::RawWindowHandle::Wayland(_)) + ); + // Matches `resizeMainWindow`: 180ms, ease-out cubic. // // Assigning over the previous task drops it, which cancels a toggle // that is still in flight -- otherwise two animations would fight over // `resize` and the window could settle at an interpolated size. self.resize_task = Some(cx.spawn_in(window, async move |this, cx| { + #[cfg(target_os = "linux")] + if uses_wayland { + // Intermediate sizes and half-pixel center shifts accumulate compositor rounding drift. + let height = to.1 + (to.1 - MAIN_WINDOW_HEIGHT).rem_euclid(2.); + let _ = this.update_in(cx, |_this, window, _cx| { + window.resize(gpui::size(px(to.0), px(height))); + }); + return; + } + let start = std::time::Instant::now(); loop { @@ -2083,7 +2102,7 @@ impl MainWindow { fn render_header(&self, _window: &Window, cx: &mut Context) -> impl IntoElement { let theme = self.theme; - div() + let header = div() .flex() .flex_row() .items_center() @@ -2094,8 +2113,101 @@ impl MainWindow { // `divide-y divide-gray-5` between header and body. .border_b_1() .border_color(theme.header_border()) - .child(self.render_traffic_lights(cx)) - .child(self.render_header_actions(cx)) + .when(!cfg!(target_os = "windows"), |header| { + header.child(self.render_traffic_lights(cx)) + }) + .child(self.render_header_actions(cx)); + + #[cfg(target_os = "windows")] + let header = header.child(self.render_windows_caption_controls(_window, cx)); + + header + } + + #[cfg(target_os = "windows")] + fn render_windows_caption_controls( + &self, + window: &Window, + cx: &mut Context, + ) -> impl IntoElement { + let dark = self.theme.is_dark(); + let foreground = Theme::with_alpha( + rgb(if dark { 0xffffff } else { 0x12161f }), + if window.is_window_active() { 0.8 } else { 0.4 }, + ); + let hover = gpui::rgba(if dark { 0xffffff0d } else { 0x0000000d }); + let pressed = gpui::rgba(if dark { 0xe9e9e908 } else { 0x00000008 }); + let button = |id: &'static str, icon: &'static str, height: f32| { + div() + .id(id) + .group(id) + .tab_index(0) + .w(px(46.)) + .h_full() + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .cursor_default() + .hover(move |style| { + style.bg(if id == "caption-close" { + rgb(0xc42b1c) + } else { + hover + }) + }) + .active(move |style| { + style.bg(if id == "caption-close" { + gpui::rgba(0xc42b1ce6) + } else { + pressed + }) + }) + .child( + svg() + .path(icon) + .id("caption-glyph") + .w(px(10.)) + .h(px(height)) + .text_color(foreground) + .when(id == "caption-close", |icon| { + icon.group_hover("caption-close", |style| { + style.text_color(gpui::white()) + }) + .group_active("caption-close", |style| style.text_color(gpui::white())) + }), + ) + }; + + div() + .flex() + .h_full() + .flex_shrink_0() + .child( + button("caption-minimize", "icons/caption-minimize-windows.svg", 1.) + .on_click(|_, window, _| window.minimize_window()), + ) + .child( + button( + "caption-maximize", + if self.expanded { + "icons/caption-restore-windows.svg" + } else { + "icons/caption-maximize-windows.svg" + }, + if self.expanded { 11. } else { 10. }, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_expanded(window, cx); + })), + ) + .child( + button("caption-close", "icons/caption-close-windows.svg", 10.).on_click( + cx.listener(|_, _, _, cx| { + cx.defer(app_windows::request_close_main); + }), + ), + ) } /// `CaptionControlsMacOS`: 14px circles (`size-3.5`), 10px apart @@ -2199,7 +2311,7 @@ impl MainWindow { ui::IconButton::header(&theme, id, path).icon_size(px(size)) }; - div() + let actions = div() .flex() .flex_1() .items_center() @@ -2213,17 +2325,15 @@ impl MainWindow { }, )), ) - // The drag handle, and *only* this. The Tauri header puts - // `data-tauri-drag-region` on the header and this spacer but not on - // the buttons; putting the handler on the header root instead makes - // every mouse-down in the header start a window drag, which eats - // the button clicks before they are delivered. + // Keep drag handlers off the header root: starting native dragging + // on a button's mouse-down consumes its later click. .child( div() .id("drag-region") .flex_1() .min_w_0() .h_full() + .when(cfg!(target_os = "windows"), |region| region.h(px(20.))) .on_mouse_down(gpui::MouseButton::Left, |_, window, _| { window.start_window_move(); }), @@ -2300,7 +2410,31 @@ impl MainWindow { }, )), ), - ) + ); + + #[cfg(target_os = "windows")] + let actions = { + let drag_strip = |id: &'static str| { + div() + .id(id) + .absolute() + .left_0() + .right_0() + .h(px(6.)) + .on_mouse_down(gpui::MouseButton::Left, |_, window, cx| { + window.start_window_move(); + cx.stop_propagation(); + }) + }; + + actions + .relative() + .h_full() + .child(drag_strip("header-drag-top").top_0()) + .child(drag_strip("header-drag-bottom").bottom_0()) + }; + + actions } /// Page root: `px-[13px] gap-2 pb-[8px]`. diff --git a/apps/desktop-gpui/src/menus.rs b/apps/desktop-gpui/src/menus.rs index 19c1678e6a4..e92abd8e751 100644 --- a/apps/desktop-gpui/src/menus.rs +++ b/apps/desktop-gpui/src/menus.rs @@ -97,6 +97,13 @@ pub fn init(cx: &mut App) { cx.on_action(|_: &Minimize, cx: &mut App| with_key_native(cx, platform::minimize_native)); cx.on_action(|_: &Zoom, cx: &mut App| with_key_native(cx, platform::zoom_native)); cx.on_action(|_: &ToggleFullscreen, cx: &mut App| { + #[cfg(target_os = "windows")] + if let Some(handle) = cx.active_window() { + cx.defer(move |cx| { + let _ = handle.update(cx, |_, window, _| window.toggle_fullscreen()); + }); + } + #[cfg(not(target_os = "windows"))] with_key_native(cx, platform::toggle_fullscreen_native) }); diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index ad226139d66..32d45435806 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -1542,7 +1542,13 @@ mod mac { } } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(target_os = "windows")] +pub use windows::*; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] mod stub { use gpui::Window; @@ -1664,6 +1670,52 @@ mod stub { } pub fn hide_native(_native: &NativeWindow) {} pub fn show_native(_native: &NativeWindow) {} + + #[cfg(target_os = "linux")] + pub fn remove_x11_window_decorations(window: &Window) -> anyhow::Result<()> { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use x11rb::connection::Connection; + use x11rb::protocol::xproto::{ConnectionExt, PropMode}; + use x11rb::wrapper::ConnectionExt as _; + + let window_id = match HasWindowHandle::window_handle(window)?.as_raw() { + RawWindowHandle::Xlib(handle) => u32::try_from(handle.window)?, + RawWindowHandle::Xcb(handle) => handle.window.get(), + _ => return Ok(()), + }; + let (connection, _) = x11rb::connect(None)?; + let atom = connection + .intern_atom(false, b"_MOTIF_WM_HINTS")? + .reply()? + .atom; + connection + .change_property32(PropMode::REPLACE, window_id, atom, atom, &[2, 0, 0, 0, 0])? + .check()?; + connection.flush()?; + Ok(()) + } + + #[cfg(target_os = "linux")] + pub fn set_x11_window_visible(window: &Window, visible: bool) -> anyhow::Result<()> { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use x11rb::connection::Connection; + use x11rb::protocol::xproto::ConnectionExt; + + let window_id = match HasWindowHandle::window_handle(window)?.as_raw() { + RawWindowHandle::Xlib(handle) => u32::try_from(handle.window)?, + RawWindowHandle::Xcb(handle) => handle.window.get(), + _ => return Ok(()), + }; + let (connection, _) = x11rb::connect(None)?; + if visible { + connection.map_window(window_id)?.check()?; + } else { + connection.unmap_window(window_id)?.check()?; + } + connection.flush()?; + Ok(()) + } + pub fn window_frame(_native: &NativeWindow) -> (f64, f64, f64, f64) { (0., 0., 0., 0.) } @@ -1793,7 +1845,7 @@ mod stub { pub fn show_about_panel(_name: &str, _version: &str) {} } -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_os = "windows")))] pub use stub::*; #[cfg(test)] diff --git a/apps/desktop-gpui/src/platform/windows.rs b/apps/desktop-gpui/src/platform/windows.rs new file mode 100644 index 00000000000..5b4a8e1ee42 --- /dev/null +++ b/apps/desktop-gpui/src/platform/windows.rs @@ -0,0 +1,685 @@ +use std::ffi::c_void; +use std::path::{Path, PathBuf}; + +use gpui::{ForegroundExecutor, Window, WindowAppearance}; +use raw_window_handle::{HasWindowHandle, RawWindowHandle}; +use windows_sys::Win32::Foundation::{HWND, RECT}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + DefWindowProcW, GetForegroundWindow, GetWindowDisplayAffinity, GetWindowRect, HWND_NOTOPMOST, + HWND_TOP, HWND_TOPMOST, IsIconic, IsWindowVisible, IsZoomed, PostMessageW, SW_HIDE, + SW_MAXIMIZE, SW_MINIMIZE, SW_RESTORE, SW_SHOW, SW_SHOWNOACTIVATE, SWP_ASYNCWINDOWPOS, + SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SWP_SHOWWINDOW, SetForegroundWindow, + SetWindowDisplayAffinity, SetWindowPos, ShowWindowAsync, WDA_EXCLUDEFROMCAPTURE, WDA_NONE, + WM_CLOSE, WM_NCACTIVATE, +}; + +use super::{ForcedAppearance, MaterialKind, PanelBehavior}; + +mod capture_exclusion; + +#[derive(Clone, Copy)] +pub struct NativeWindow(isize); + +impl NativeWindow { + fn hwnd(self) -> HWND { + self.0 as HWND + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Rect { + x: f64, + y: f64, + width: f64, + height: f64, +} + +impl Rect { + fn is_valid(self) -> bool { + self.x.is_finite() + && self.y.is_finite() + && self.width.is_finite() + && self.height.is_finite() + && self.width > 0. + && self.height > 0. + } +} + +#[derive(Clone, Copy, Debug)] +struct DisplayGeometry { + physical: Rect, + logical: Rect, +} + +fn native_handle(window: &Window) -> Option { + let handle = HasWindowHandle::window_handle(window).ok()?; + let RawWindowHandle::Win32(handle) = handle.as_raw() else { + return None; + }; + Some(handle.hwnd.get()) +} + +pub fn native_window(window: &Window) -> Option { + native_handle(window).map(NativeWindow) +} + +#[link(name = "dwmapi")] +unsafe extern "system" { + fn DwmSetWindowAttribute(hwnd: HWND, attribute: u32, value: *const c_void, size: u32) -> i32; +} + +pub fn apply_window_theme( + window: &Window, + appearance: ForcedAppearance, + executor: &ForegroundExecutor, +) { + const DWMWA_USE_IMMERSIVE_DARK_MODE: u32 = 20; + let Some(native) = native_window(window) else { + return; + }; + let dark = i32::from(match appearance { + ForcedAppearance::System => matches!( + window.appearance(), + WindowAppearance::Dark | WindowAppearance::VibrantDark + ), + ForcedAppearance::Light => false, + ForcedAppearance::Dark => true, + }); + executor + .spawn(async move { + let result = unsafe { + DwmSetWindowAttribute( + native.hwnd(), + DWMWA_USE_IMMERSIVE_DARK_MODE, + std::ptr::addr_of!(dark).cast(), + std::mem::size_of_val(&dark) as u32, + ) + }; + if result < 0 { + tracing::debug!(result, "native window theme is not supported"); + return; + } + unsafe { + // As in Tao, redraw the nonclient colors without changing OS focus. + let active = GetForegroundWindow() == native.hwnd(); + DefWindowProcW(native.hwnd(), WM_NCACTIVATE, usize::from(!active), 0); + DefWindowProcW(native.hwnd(), WM_NCACTIVATE, usize::from(active), 0); + } + }) + .detach(); +} + +pub fn window_is_dark(_window: &Window) -> Option { + None +} + +pub fn debug_titlebar_state(_window: &Window) -> Option { + None +} + +pub fn install_window_material(_native: &NativeWindow, _radius: f64) -> Option { + None +} + +pub fn recording_controls_level() -> isize { + 1 +} + +pub fn target_overlay_level() -> isize { + 1 +} + +pub fn teleprompter_level() -> isize { + 1 +} + +pub fn set_window_alpha(_native: &NativeWindow, _alpha: f64) -> f64 { + 1. +} + +pub fn set_window_capture_hidden(native: &NativeWindow, hidden: bool) -> usize { + let streamed = hidden + .then(capture_exclusion::streamed_display_reason) + .flatten(); + if let Some(reason) = &streamed { + tracing::debug!(%reason, "keeping window visible on streamed desktop"); + } + let affinity = if hidden && streamed.is_none() { + WDA_EXCLUDEFROMCAPTURE + } else { + WDA_NONE + }; + unsafe { + if SetWindowDisplayAffinity(native.hwnd(), affinity) == 0 { + return 0; + } + let mut current = WDA_NONE; + if GetWindowDisplayAffinity(native.hwnd(), &mut current) == 0 { + return affinity as usize; + } + current as usize + } +} + +pub fn restore_borderless_style(_native: &NativeWindow) {} + +pub fn remove_popup_window_chrome(_native: &NativeWindow) {} + +fn to_rect(x: f64, y: f64, width: f64, height: f64) -> Rect { + Rect { + x, + y, + width, + height, + } +} + +fn display_geometries() -> Vec { + scap_targets::Display::list() + .into_iter() + .filter_map(|display| { + let physical = display.raw_handle().physical_bounds()?; + let logical = display.raw_handle().logical_bounds()?; + let geometry = DisplayGeometry { + physical: to_rect( + physical.position().x(), + physical.position().y(), + physical.size().width(), + physical.size().height(), + ), + logical: to_rect( + logical.position().x(), + logical.position().y(), + logical.size().width(), + logical.size().height(), + ), + }; + (geometry.physical.is_valid() && geometry.logical.is_valid()).then_some(geometry) + }) + .collect() +} + +fn intersection_area(first: Rect, second: Rect) -> f64 { + if !first.is_valid() || !second.is_valid() { + return 0.; + } + let width = (first.x + first.width).min(second.x + second.width) - first.x.max(second.x); + let height = (first.y + first.height).min(second.y + second.height) - first.y.max(second.y); + if width > 0. && height > 0. { + width * height + } else { + 0. + } +} + +fn center_distance_squared(first: Rect, second: Rect) -> f64 { + let dx = first.x + first.width / 2. - (second.x + second.width / 2.); + let dy = first.y + first.height / 2. - (second.y + second.height / 2.); + dx.mul_add(dx, dy * dy) +} + +fn select_physical_display(rect: Rect, displays: &[DisplayGeometry]) -> Option { + displays + .iter() + .max_by(|first, second| { + intersection_area(rect, first.physical) + .total_cmp(&intersection_area(rect, second.physical)) + .then_with(|| { + center_distance_squared(rect, second.physical) + .total_cmp(¢er_distance_squared(rect, first.physical)) + }) + }) + .copied() +} + +fn select_logical_display( + rect: Rect, + displays: &[DisplayGeometry], + exact: bool, +) -> Option { + if exact + && let Some(display) = displays.iter().find(|display| { + let bounds = display.logical; + (rect.x - bounds.x).abs() <= 1. + && (rect.y - bounds.y).abs() <= 1. + && (rect.width - bounds.width).abs() <= 1. + && (rect.height - bounds.height).abs() <= 1. + }) + { + return Some(*display); + } + displays + .iter() + .max_by(|first, second| { + intersection_area(rect, first.logical) + .total_cmp(&intersection_area(rect, second.logical)) + .then_with(|| { + center_distance_squared(rect, second.logical) + .total_cmp(¢er_distance_squared(rect, first.logical)) + }) + }) + .copied() +} + +fn physical_to_logical(rect: Rect, display: DisplayGeometry) -> Rect { + let scale_x = display.logical.width / display.physical.width; + let scale_y = display.logical.height / display.physical.height; + Rect { + x: display.logical.x + (rect.x - display.physical.x) * scale_x, + y: display.logical.y + (rect.y - display.physical.y) * scale_y, + width: rect.width * scale_x, + height: rect.height * scale_y, + } +} + +fn logical_to_physical(rect: Rect, display: DisplayGeometry) -> Rect { + let scale_x = display.physical.width / display.logical.width; + let scale_y = display.physical.height / display.logical.height; + Rect { + x: display.physical.x + (rect.x - display.logical.x) * scale_x, + y: display.physical.y + (rect.y - display.logical.y) * scale_y, + width: rect.width * scale_x, + height: rect.height * scale_y, + } +} + +fn window_physical_frame(native: &NativeWindow) -> Option { + let mut rect = RECT { + left: 0, + top: 0, + right: 0, + bottom: 0, + }; + unsafe { + (GetWindowRect(native.hwnd(), &mut rect) != 0).then(|| { + to_rect( + rect.left as f64, + rect.top as f64, + (rect.right - rect.left) as f64, + (rect.bottom - rect.top) as f64, + ) + }) + } +} + +fn set_physical_frame(native: &NativeWindow, rect: Rect) { + if !rect.is_valid() { + return; + } + unsafe { + SetWindowPos( + native.hwnd(), + HWND_TOP, + rect.x.round() as i32, + rect.y.round() as i32, + rect.width.round() as i32, + rect.height.round() as i32, + SWP_NOACTIVATE | SWP_NOZORDER | SWP_ASYNCWINDOWPOS, + ); + } +} + +pub fn window_frame(native: &NativeWindow) -> (f64, f64, f64, f64) { + window_physical_frame(native) + .map(|rect| (rect.x, rect.y, rect.width, rect.height)) + .unwrap_or_default() +} + +pub fn set_window_frame(native: &NativeWindow, x: f64, y: f64, width: f64, height: f64) { + set_physical_frame(native, to_rect(x, y, width, height)); +} + +pub fn window_logical_frame(native: &NativeWindow) -> (f64, f64, f64, f64) { + let Some(physical) = window_physical_frame(native) else { + return (0., 0., 0., 0.); + }; + let displays = display_geometries(); + let logical = select_physical_display(physical, &displays) + .map(|display| physical_to_logical(physical, display)) + .unwrap_or(physical); + (logical.x, logical.y, logical.width, logical.height) +} + +pub fn set_window_logical_frame(native: &NativeWindow, x: f64, y: f64, width: f64, height: f64) { + let logical = to_rect(x, y, width, height); + if !logical.is_valid() { + return; + } + let displays = display_geometries(); + let physical = select_logical_display(logical, &displays, false) + .map(|display| logical_to_physical(logical, display)) + .unwrap_or(logical); + set_physical_frame(native, physical); +} + +pub fn place_overlay_panel( + native: &NativeWindow, + x: f64, + y: f64, + width: f64, + height: f64, + _level: isize, +) { + let logical = to_rect(x, y, width, height); + if !logical.is_valid() { + return; + } + let displays = display_geometries(); + let physical = select_logical_display(logical, &displays, true) + .map(|display| logical_to_physical(logical, display)) + .unwrap_or(logical); + if !physical.is_valid() { + return; + } + unsafe { + SetWindowPos( + native.hwnd(), + HWND_TOPMOST, + physical.x.round() as i32, + physical.y.round() as i32, + physical.width.round() as i32, + physical.height.round() as i32, + SWP_NOACTIVATE | SWP_SHOWWINDOW | SWP_ASYNCWINDOWPOS, + ); + } +} + +pub fn apply_panel_behavior(window: &Window, behavior: PanelBehavior) { + let _ = (behavior.join_all_spaces, behavior.shadow); + let Some(native) = native_window(window) else { + return; + }; + let insert_after = if behavior.level > 0 { + HWND_TOPMOST + } else { + HWND_NOTOPMOST + }; + unsafe { + SetWindowPos( + native.hwnd(), + insert_after, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS, + ); + } +} + +pub fn kick_display_link(_window: &Window) {} + +pub fn hide_native(native: &NativeWindow) { + unsafe { + ShowWindowAsync(native.hwnd(), SW_HIDE); + } +} + +pub fn show_native(native: &NativeWindow) { + unsafe { + let command = if IsIconic(native.hwnd()) != 0 { + SW_RESTORE + } else { + SW_SHOW + }; + ShowWindowAsync(native.hwnd(), command); + } +} + +pub fn order_front_native(native: &NativeWindow) { + unsafe { + SetWindowPos( + native.hwnd(), + HWND_TOP, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS, + ); + } +} + +fn overlap_is_visible(first: Rect, second: Rect) -> bool { + const MIN_VISIBLE: f64 = 80.; + if !first.is_valid() || !second.is_valid() { + return false; + } + let overlap_width = + (first.x + first.width).min(second.x + second.width) - first.x.max(second.x); + let overlap_height = + (first.y + first.height).min(second.y + second.height) - first.y.max(second.y); + overlap_width >= MIN_VISIBLE.min(first.width) && overlap_height >= MIN_VISIBLE.min(first.height) +} + +pub fn frame_is_on_screen(x: f64, y: f64, width: f64, height: f64) -> bool { + let frame = to_rect(x, y, width, height); + if !frame.is_valid() { + return false; + } + let displays = display_geometries(); + if displays.is_empty() { + return true; + } + displays + .iter() + .any(|display| overlap_is_visible(frame, display.physical)) +} + +pub fn close_native(native: &NativeWindow) { + unsafe { + PostMessageW(native.hwnd(), WM_CLOSE, 0, 0); + } +} + +pub fn minimize_native(native: &NativeWindow) { + unsafe { + ShowWindowAsync(native.hwnd(), SW_MINIMIZE); + } +} + +pub fn zoom_native(native: &NativeWindow) { + unsafe { + let command = if IsZoomed(native.hwnd()) != 0 { + SW_RESTORE + } else { + SW_MAXIMIZE + }; + ShowWindowAsync(native.hwnd(), command); + } +} + +pub fn maximize_if_larger_than_work_area(window: &Window, cx: &gpui::App) { + let Some(native) = native_window(window) else { + return; + }; + let Some(display) = window.display(cx) else { + return; + }; + let (_, _, width, height) = window_logical_frame(&native); + let work = display.visible_bounds().size; + if width > f64::from(f32::from(work.width)) || height > f64::from(f32::from(work.height)) { + unsafe { + ShowWindowAsync(native.hwnd(), SW_MAXIMIZE); + } + } +} + +pub fn window_is_visible(window: &Window) -> bool { + native_handle(window).is_some_and(|hwnd| unsafe { IsWindowVisible(hwnd as HWND) != 0 }) +} + +pub fn show_window_without_focus(window: &Window) { + let Some(native) = native_window(window) else { + return; + }; + unsafe { + ShowWindowAsync(native.hwnd(), SW_SHOWNOACTIVATE); + SetWindowPos( + native.hwnd(), + HWND_TOP, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW | SWP_ASYNCWINDOWPOS, + ); + } +} + +pub fn window_number(window: &Window) -> Option { + native_handle(window) +} + +pub fn open_image_panel(extensions: &[&str]) -> Option { + let mut dialog = rfd::FileDialog::new(); + if !extensions.is_empty() { + dialog = dialog.add_filter("Images", extensions); + } + dialog.pick_file() +} + +pub fn open_audio_panel() -> Option { + rfd::FileDialog::new() + .add_filter("Audio", &["mp3", "wav", "m4a", "ogg", "flac", "aac"]) + .pick_file() +} + +pub fn confirm_dialog( + title: &str, + message: &str, + accept: &str, + cancel: &str, + warning: bool, +) -> bool { + let level = if warning { + rfd::MessageLevel::Warning + } else { + rfd::MessageLevel::Info + }; + let result = rfd::MessageDialog::new() + .set_title(title) + .set_description(message) + .set_buttons(rfd::MessageButtons::OkCancelCustom( + accept.to_string(), + cancel.to_string(), + )) + .set_level(level) + .show(); + super::confirmation_accepted(result, accept) +} + +pub fn alert_dialog(title: &str, message: &str) { + let _ = rfd::MessageDialog::new() + .set_title(title) + .set_description(message) + .set_buttons(rfd::MessageButtons::Ok) + .set_level(rfd::MessageLevel::Info) + .show(); +} + +pub fn activate_app() {} + +pub fn focus_capture_target_window(id: &scap_targets::WindowId) -> bool { + let Some(window) = scap_targets::Window::from_id(id) else { + return false; + }; + let hwnd = window.raw_handle().inner().0 as HWND; + if hwnd.is_null() { + return false; + } + unsafe { + if IsIconic(hwnd) != 0 { + ShowWindowAsync(hwnd, SW_RESTORE); + } + SetForegroundWindow(hwnd) != 0 + } +} + +pub fn install_url_scheme_handler() {} + +pub fn set_dock_icon(_png: &[u8]) {} + +pub fn save_file_panel(suggested: &str, extensions: &[&str]) -> Option { + let mut dialog = rfd::FileDialog::new().set_file_name(suggested); + if !extensions.is_empty() { + dialog = dialog.add_filter("Export", extensions); + } + dialog.save_file() +} + +pub fn copy_file_to_clipboard(_path: &Path) -> Result<(), String> { + Err("Copy to clipboard is not available yet on Windows".into()) +} + +pub fn copy_image_to_clipboard(path: &Path) -> Result<(), String> { + copy_file_to_clipboard(path) +} + +pub fn desktop_picture_path() -> Option { + None +} + +pub fn set_activation_policy(_regular: bool) -> bool { + false +} + +pub fn activation_policy() -> isize { + -1 +} + +pub fn show_about_panel(_name: &str, _version: &str) {} + +pub fn escape_hotkey_events() -> flume::Receiver<()> { + flume::unbounded().1 +} + +pub fn register_escape_hotkey() {} + +pub fn unregister_escape_hotkey() {} + +#[cfg(test)] +mod tests { + use super::{Rect, logical_to_physical, overlap_is_visible, physical_to_logical, to_rect}; + + fn display(physical: Rect, logical: Rect) -> super::DisplayGeometry { + super::DisplayGeometry { physical, logical } + } + + #[test] + fn converts_negative_mixed_dpi_monitor_coordinates() { + let display = display( + to_rect(-2560., -120., 2560., 1440.), + to_rect(-1706.6666667, -80., 1706.6666667, 960.), + ); + let logical = physical_to_logical(to_rect(-1280., 600., 1280., 720.), display); + assert!((logical.x + 853.3333333).abs() < 0.01); + assert!((logical.y - 400.).abs() < 0.01); + assert!((logical.width - 853.3333333).abs() < 0.01); + assert!((logical.height - 480.).abs() < 0.01); + let physical = logical_to_physical(logical, display); + assert!((physical.x + 1280.).abs() < 0.01); + assert!((physical.y - 600.).abs() < 0.01); + assert!((physical.width - 1280.).abs() < 0.01); + assert!((physical.height - 720.).abs() < 0.01); + } + + #[test] + fn visible_frame_requires_grabbable_overlap() { + let monitor = to_rect(-1920., 0., 1920., 1080.); + assert!(overlap_is_visible( + to_rect(-1910., 20., 100., 100.), + monitor + )); + assert!(!overlap_is_visible(to_rect(-1990., 20., 70., 70.), monitor)); + assert!(!overlap_is_visible( + to_rect(-1900., 1070., 500., 100.), + monitor + )); + assert!(overlap_is_visible( + to_rect(-1900., 1070., 500., 10.), + monitor + )); + assert!(!overlap_is_visible(to_rect(0., 0., 0., 100.), monitor)); + } +} diff --git a/apps/desktop-gpui/src/platform/windows/capture_exclusion.rs b/apps/desktop-gpui/src/platform/windows/capture_exclusion.rs new file mode 100644 index 00000000000..62a19c7bcd3 --- /dev/null +++ b/apps/desktop-gpui/src/platform/windows/capture_exclusion.rs @@ -0,0 +1,290 @@ +use std::ffi::c_void; + +use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_REMOTESESSION}; + +const ENV_OVERRIDE: &str = "CAP_WINDOW_CAPTURE_EXCLUSION"; +const SMBIOS_MARKERS: &[&str] = &[ + "qemu", + "kvm", + "vmware", + "virtualbox", + "innotek", + "xen", + "bochs", + "parallels", + "virtual machine", + "hvm domu", + "amazon ec2", + "google compute engine", + "openstack", + "shadow", +]; +const VIRTUAL_DISPLAY_MARKERS: &[&str] = &[ + "parsec", + "spacedesk", + "iddsample", + "virtual display", + "usbmmidd", + "amyuni", + "shadow", +]; + +#[repr(C)] +struct DisplayDevice { + size: u32, + name: [u16; 32], + description: [u16; 128], + state_flags: u32, + id: [u16; 128], + key: [u16; 128], +} + +#[link(name = "advapi32")] +unsafe extern "system" { + fn RegGetValueW( + key: *mut c_void, + subkey: *const u16, + value: *const u16, + flags: u32, + value_type: *mut u32, + data: *mut c_void, + data_size: *mut u32, + ) -> i32; +} + +#[link(name = "user32")] +unsafe extern "system" { + fn EnumDisplayDevicesW( + device: *const u16, + index: u32, + display: *mut DisplayDevice, + flags: u32, + ) -> i32; +} + +fn exclusion_override(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "on" | "always" | "1" => Some(true), + "off" | "never" | "0" => Some(false), + _ => None, + } +} + +// Match Tauri's streamed-desktop policy: WDA exclusion also hides controls from +// Shadow/RDP viewers, not just from the recording being made. +pub(super) fn streamed_display_reason() -> Option { + match std::env::var(ENV_OVERRIDE) + .ok() + .and_then(|value| exclusion_override(&value)) + { + Some(true) => return None, + Some(false) => return Some(format!("{ENV_OVERRIDE} env override")), + None => {} + } + if unsafe { GetSystemMetrics(SM_REMOTESESSION) } != 0 { + return Some("remote desktop session (SM_REMOTESESSION)".to_string()); + } + if let Some(vendor) = hypervisor_guest() { + return Some(format!("hypervisor guest ({vendor})")); + } + for value in [ + "SystemManufacturer", + "SystemProductName", + "SystemFamily", + "BIOSVendor", + ] { + if let Some(text) = bios_value(value) + && let Some(marker) = find_marker(&text, SMBIOS_MARKERS) + { + return Some(format!( + "virtual machine SMBIOS ({value}=\"{text}\" matched \"{marker}\")" + )); + } + } + virtual_display_adapter().map(|device| format!("virtual display adapter ({device})")) +} + +#[cfg(target_arch = "x86_64")] +fn hypervisor_guest() -> Option { + use std::arch::x86_64::__cpuid; + + if __cpuid(1).ecx & (1 << 31) == 0 { + return None; + } + let hypervisor = __cpuid(0x4000_0000); + let mut vendor = [0u8; 12]; + vendor[0..4].copy_from_slice(&hypervisor.ebx.to_le_bytes()); + vendor[4..8].copy_from_slice(&hypervisor.ecx.to_le_bytes()); + vendor[8..12].copy_from_slice(&hypervisor.edx.to_le_bytes()); + let privileges = if &vendor == b"Microsoft Hv" && hypervisor.eax >= 0x4000_0003 { + __cpuid(0x4000_0003).ebx + } else { + 0 + }; + if is_hyperv_root(&vendor, hypervisor.eax, privileges) { + return None; + } + let vendor = String::from_utf8_lossy(&vendor) + .trim_matches([char::from(0), ' ']) + .to_string(); + Some(if vendor.is_empty() { + "unknown hypervisor".to_string() + } else { + vendor + }) +} + +#[cfg(any(target_arch = "x86_64", test))] +fn is_hyperv_root(vendor: &[u8; 12], maximum_leaf: u32, privileges: u32) -> bool { + // VBS/WSL2 exposes Hyper-V on physical hosts; CreatePartitions identifies + // the root partition, which must retain ordinary capture exclusion. + vendor == b"Microsoft Hv" && maximum_leaf >= 0x4000_0003 && privileges & 1 != 0 +} + +#[cfg(not(target_arch = "x86_64"))] +fn hypervisor_guest() -> Option { + None +} + +fn bios_value(value: &str) -> Option { + const HKEY_LOCAL_MACHINE: *mut c_void = 0x8000_0002u32 as i32 as isize as *mut c_void; + const STRING_FLAGS: u32 = 0x1000_0006; + let subkey: Vec = "HARDWARE\\DESCRIPTION\\System\\BIOS\0" + .encode_utf16() + .collect(); + let value: Vec = value.encode_utf16().chain([0]).collect(); + let mut byte_count = 0u32; + if unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + subkey.as_ptr(), + value.as_ptr(), + STRING_FLAGS, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut byte_count, + ) + } != 0 + || byte_count == 0 + || byte_count > 65_536 + || !byte_count.is_multiple_of(2) + { + return None; + } + let mut data = vec![0u16; byte_count as usize / 2]; + if unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + subkey.as_ptr(), + value.as_ptr(), + STRING_FLAGS, + std::ptr::null_mut(), + data.as_mut_ptr().cast(), + &mut byte_count, + ) + } != 0 + { + return None; + } + Some(wide_text(&data)) +} + +fn virtual_display_adapter() -> Option { + let mut index = 0u32; + loop { + let mut display = DisplayDevice { + size: std::mem::size_of::() as u32, + name: [0; 32], + description: [0; 128], + state_flags: 0, + id: [0; 128], + key: [0; 128], + }; + if unsafe { EnumDisplayDevicesW(std::ptr::null(), index, &mut display, 0) } == 0 { + return None; + } + index += 1; + if display.state_flags & 1 == 0 { + continue; + } + let name = wide_text(&display.description); + if let Some(marker) = find_marker(&name, VIRTUAL_DISPLAY_MARKERS) { + return Some(format!("\"{name}\" matched \"{marker}\"")); + } + } +} + +fn wide_text(value: &[u16]) -> String { + let end = value + .iter() + .position(|character| *character == 0) + .unwrap_or(value.len()); + String::from_utf16_lossy(&value[..end]) +} + +fn find_marker(text: &str, markers: &[&'static str]) -> Option<&'static str> { + let lower = text.to_ascii_lowercase(); + markers + .iter() + .copied() + .find(|marker| lower.contains(marker)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overrides_preserve_auto_and_explicit_choices() { + for value in ["on", " ALWAYS ", "1"] { + assert_eq!(exclusion_override(value), Some(true)); + } + for value in ["off", " NEVER ", "0"] { + assert_eq!(exclusion_override(value), Some(false)); + } + for value in ["", "auto", "unknown"] { + assert_eq!(exclusion_override(value), None); + } + } + + #[test] + fn known_streamed_displays_match_but_physical_hardware_does_not() { + for value in [ + "Shadow Computer", + "Amazon EC2", + "QEMU Standard PC", + "Virtual Machine", + ] { + assert!(find_marker(value, SMBIOS_MARKERS).is_some(), "{value}"); + } + for value in ["Parsec Virtual Display Adapter", "Shadow", "spacedesk"] { + assert!( + find_marker(value, VIRTUAL_DISPLAY_MARKERS).is_some(), + "{value}" + ); + } + for value in [ + "Dell Inc.", + "LENOVO", + "NVIDIA GeForce RTX 3080", + "AMD Radeon RX 7900 XTX", + ] { + assert_eq!(find_marker(value, SMBIOS_MARKERS), None); + assert_eq!(find_marker(value, VIRTUAL_DISPLAY_MARKERS), None); + } + } + + #[test] + fn hyperv_root_is_not_mistaken_for_a_guest() { + assert!(is_hyperv_root(b"Microsoft Hv", 0x4000_0003, 1)); + assert!(!is_hyperv_root(b"Microsoft Hv", 0x4000_0003, 0)); + assert!(!is_hyperv_root(b"Microsoft Hv", 0x4000_0002, 1)); + assert!(!is_hyperv_root(b"VMwareVMware", 0x4000_0003, 1)); + } + + #[test] + fn display_description_ends_at_first_null() { + assert_eq!(wide_text(&[83, 104, 97, 100, 111, 119, 0, 88]), "Shadow"); + assert_eq!(std::mem::size_of::(), 840); + } +} diff --git a/apps/desktop-gpui/src/single_instance.rs b/apps/desktop-gpui/src/single_instance.rs index 5b8f47376d2..1b566df3931 100644 --- a/apps/desktop-gpui/src/single_instance.rs +++ b/apps/desktop-gpui/src/single_instance.rs @@ -85,20 +85,26 @@ pub fn acquire() { } /// Guard against pid reuse: only ever signal a process that is actually this -/// binary. `comm` is the executable path on macOS and the (15-char) image -/// name on Linux; `cap-gpui` fits both. +/// binary. Linux zombies retain their comm name but lose their exe link. #[cfg(unix)] fn is_cap_gpui(pid: i32) -> bool { if unsafe { libc::kill(pid, 0) } != 0 { return false; } - std::process::Command::new("ps") - .args(["-p", &pid.to_string(), "-o", "comm="]) - .output() - .is_ok_and(|output| { - output.status.success() - && is_cap_gpui_image(Path::new(String::from_utf8_lossy(&output.stdout).trim())) - }) + #[cfg(target_os = "linux")] + { + std::fs::read_link(format!("/proc/{pid}/exe")).is_ok_and(|path| is_cap_gpui_image(&path)) + } + #[cfg(not(target_os = "linux"))] + { + std::process::Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "comm="]) + .output() + .is_ok_and(|output| { + output.status.success() + && is_cap_gpui_image(Path::new(String::from_utf8_lossy(&output.stdout).trim())) + }) + } } fn is_cap_gpui_image(path: &Path) -> bool { @@ -107,9 +113,12 @@ fn is_cap_gpui_image(path: &Path) -> bool { #[cfg(not(windows))] const IMAGE_NAME: &str = "cap-gpui"; - path.file_name() - .and_then(std::ffi::OsStr::to_str) - .is_some_and(|name| name.eq_ignore_ascii_case(IMAGE_NAME)) + let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else { + return false; + }; + #[cfg(target_os = "linux")] + let name = name.strip_suffix(" (deleted)").unwrap_or(name); + name.eq_ignore_ascii_case(IMAGE_NAME) } #[cfg(windows)] @@ -451,6 +460,56 @@ mod tests { assert!(!is_cap_gpui_image(Path::new("cap-gpui-helper"))); } + #[cfg(target_os = "linux")] + #[test] + fn linux_process_check_distinguishes_live_unlinked_and_exited_processes() { + let directory = std::env::temp_dir().join(format!( + "cap-gpui-process-test-{}", + crate::store::new_uuid_v4() + )); + std::fs::create_dir(&directory).unwrap(); + let binary = directory.join("cap-gpui"); + std::fs::copy("/bin/sleep", &binary).unwrap(); + let mut child = std::process::Command::new(&binary) + .arg("30") + .spawn() + .unwrap(); + let pid = child.id() as i32; + let started = std::time::Instant::now(); + // spawn can return before /proc stops exposing the child's pre-exec image. + while !std::fs::read_link(format!("/proc/{pid}/exe")).is_ok_and(|path| path == binary) + && started.elapsed() < std::time::Duration::from_secs(5) + { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let live = super::is_cap_gpui(pid); + std::fs::remove_file(&binary).unwrap(); + let unlinked = super::is_cap_gpui(pid); + child.kill().unwrap(); + let started = std::time::Instant::now(); + let mut zombie = false; + while started.elapsed() < std::time::Duration::from_secs(5) { + zombie = std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| { + stat.rsplit_once(") ") + .is_some_and(|(_, state)| state.starts_with('Z')) + }); + if zombie { + break; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let exited = super::is_cap_gpui(pid); + child.wait().unwrap(); + std::fs::remove_dir(directory).unwrap(); + + assert!(live); + assert!(unlinked); + assert!(zombie); + assert!(!exited); + assert!(!super::is_cap_gpui(pid)); + assert!(!super::is_cap_gpui(std::process::id() as i32)); + } + #[test] fn forwarding_endpoint_requires_a_live_instance_shape() { assert_eq!( diff --git a/apps/desktop-gpui/src/target_thumbnails.rs b/apps/desktop-gpui/src/target_thumbnails.rs index dc6a3664930..dcd90e0bf98 100644 --- a/apps/desktop-gpui/src/target_thumbnails.rs +++ b/apps/desktop-gpui/src/target_thumbnails.rs @@ -976,7 +976,7 @@ mod platform { async fn capture_target_thumbnail(target: ScreenCaptureTarget) -> Option { #[cfg(target_os = "linux")] - if cap_recording::screenshot::is_pure_wayland_session() { + if cap_recording::screenshot::uses_wayland_portal() { return None; } diff --git a/apps/desktop-gpui/src/theme.rs b/apps/desktop-gpui/src/theme.rs index e6ed855af9e..07711e4aee3 100644 --- a/apps/desktop-gpui/src/theme.rs +++ b/apps/desktop-gpui/src/theme.rs @@ -67,15 +67,23 @@ fn forced_appearance(preference: AppTheme) -> ForcedAppearance { /// `window.set_theme(...)` — native chrome (traffic lights, materials, menus) /// follows the preference, not only the painted palette. pub fn apply_native(window: &Window, cx: &App) { - platform::apply_window_theme(window, forced_appearance(current_preference(cx))); + let appearance = forced_appearance(current_preference(cx)); + #[cfg(target_os = "windows")] + platform::apply_window_theme(window, appearance, cx.foreground_executor()); + #[cfg(not(target_os = "windows"))] + platform::apply_window_theme(window, appearance); } /// Force the native appearance and invalidate on OS theme changes so a System /// preference picks up a light/dark flip without waiting for another event. pub fn bind_window(window: &mut Window, cx: &mut Context) { apply_native(window, cx); - cx.observe_window_appearance(window, |_, _, cx| cx.notify()) - .detach(); + cx.observe_window_appearance(window, |_, _window, cx| { + #[cfg(target_os = "windows")] + _window.defer(cx, |window, cx| apply_native(window, cx)); + cx.notify(); + }) + .detach(); } /// What the shell paints *over* a native window material, resolved for one diff --git a/apps/desktop-gpui/src/updates.rs b/apps/desktop-gpui/src/updates.rs index 69af5f4916a..d0a5434ce4d 100644 --- a/apps/desktop-gpui/src/updates.rs +++ b/apps/desktop-gpui/src/updates.rs @@ -57,30 +57,36 @@ struct UpdateScheduler { impl Global for UpdateScheduler {} -fn updater_target() -> String { +fn updater_target() -> Result { let arch = if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" }; - if cfg!(target_os = "macos") { - format!("darwin-{arch}") - } else if cfg!(target_os = "linux") { - format!("linux-{arch}-deb") - } else { - format!("windows-{arch}") + #[cfg(target_os = "linux")] + { + cap_utils::linux_package::updater_target(arch) + } + #[cfg(not(target_os = "linux"))] + { + let platform = if cfg!(target_os = "macos") { + "darwin" + } else { + "windows" + }; + Ok(format!("{platform}-{arch}")) } } -fn endpoint(channel: UpdateChannel) -> String { +fn endpoint(channel: UpdateChannel) -> Result { let url = UPDATE_ENDPOINT - .replace("{target}", &updater_target()) + .replace("{target}", &updater_target()?) .replace("{current_version}", env!("CARGO_PKG_VERSION")); - match channel { + Ok(match channel { UpdateChannel::Stable => url, UpdateChannel::Nightly => format!("{url}?channel=nightly"), - } + }) } async fn remote_version(channel: UpdateChannel) -> Result, String> { @@ -88,7 +94,7 @@ async fn remote_version(channel: UpdateChannel) -> Result, Strin .timeout(Duration::from_secs(15)) .build() .map_err(|error| error.to_string())? - .get(endpoint(channel)) + .get(endpoint(channel)?) .send() .await .map_err(|error| error.to_string())?; diff --git a/apps/desktop/scripts/prepare.test.js b/apps/desktop/scripts/prepare.test.js index 2ad7bfc581d..e43e2816d52 100644 --- a/apps/desktop/scripts/prepare.test.js +++ b/apps/desktop/scripts/prepare.test.js @@ -1,29 +1,21 @@ import { describe, expect, it } from "vitest"; +import { createLinuxBundleConfig } from "../../../scripts/linux-bundle-config.mjs"; import { deepMerge } from "./prepare.js"; describe("Tauri platform release configuration", () => { it("preserves generated Linux shared-library mappings when adding GPUI", () => { - const existing = { - bundle: { - linux: { - deb: { - files: { - "/usr/lib/cap/libavcodec.so.61": - "../../../target/native-deps/cap-deb-libs/libavcodec.so.61", - }, - }, - }, - }, - }; + const existing = createLinuxBundleConfig(["libavcodec.so.61"]); const merged = deepMerge(existing, { bundle: { externalBin: ["binaries/cap-gpui"] }, }); expect(merged.bundle.externalBin).toEqual(["binaries/cap-gpui"]); - expect(merged.bundle.linux.deb.files).toEqual( - existing.bundle.linux.deb.files, - ); + for (const format of ["deb", "rpm", "appimage"]) { + expect(merged.bundle.linux[format]).toEqual( + existing.bundle.linux[format], + ); + } }); it("preserves Windows resource mappings when platform overrides are applied", () => { diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 261851f6b60..822939282cc 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -1,3 +1,9 @@ fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") + { + // Export preview command dispatch can exhaust the default 1 MiB UI stack before reaching a Tokio worker. + println!("cargo:rustc-link-arg-bin=cap-desktop=/STACK:16777216"); + } tauri_build::build(); } diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index 8631018858e..8b5886e82f6 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -1351,11 +1351,7 @@ pub async fn get_export_estimates( let meta = RecordingMeta::load_for_project(&path).map_err(|e| e.to_string())?; let project_config = meta.project_config(); - let duration_seconds = if let Some(timeline) = &project_config.timeline { - timeline.duration() - } else { - metadata.duration - }; + let duration_seconds = export_estimate_duration(&project_config, metadata.duration); let (resolution, fps) = match &settings { ExportSettings::Mp4(s) => (s.resolution_base, s.fps), @@ -1444,6 +1440,17 @@ fn estimate_cursor_only_size_mb(total_pixels: f64, total_frames: f64) -> f64 { (bytes_per_frame * total_frames) / (1024.0 * 1024.0) } +fn export_estimate_duration( + project_config: &cap_project::ProjectConfiguration, + source_duration: f64, +) -> f64 { + project_config + .timeline + .as_ref() + .map(cap_project::TimelineConfiguration::duration) + .unwrap_or(source_duration) +} + fn bpp_to_jpeg_quality(bpp: f32) -> u8 { ((bpp - 0.04) / (0.3 - 0.04) * (95.0 - 40.0) + 40.0).clamp(40.0, 95.0) as u8 } @@ -1709,11 +1716,7 @@ async fn generate_export_preview_inner( let fps_f64 = settings.fps as f64; let metadata = get_video_metadata(project_path.clone()).await?; - let duration_seconds = if let Some(timeline) = &project_config.timeline { - timeline.duration() - } else { - metadata.duration - }; + let duration_seconds = export_estimate_duration(&project_config, metadata.duration); let total_frames = (duration_seconds * fps_f64).ceil() as u32; let estimated_size_mb = if settings.cursor_only { @@ -1743,6 +1746,46 @@ mod tests { use super::*; use tempfile::tempdir; + #[test] + fn export_estimates_use_source_duration_without_a_timeline() { + assert_eq!( + export_estimate_duration(&cap_project::ProjectConfiguration::default(), 361.0), + 361.0 + ); + } + + #[test] + fn export_estimates_follow_trims_and_playback_speed() { + for (timescale, expected) in [(1.0, 12.0), (2.0, 6.0)] { + let project = serde_json::from_value(serde_json::json!({ + "timeline": { + "segments": [{"start": 45.0, "end": 57.0, "timescale": timescale}], + "zoomSegments": [] + } + })) + .unwrap(); + + assert_eq!(export_estimate_duration(&project, 361.0), expected); + } + } + + #[test] + fn export_estimates_include_transition_overlap() { + let project = serde_json::from_value(serde_json::json!({ + "timeline": { + "segments": [ + {"start": 5.0, "end": 11.0, "timescale": 1.0}, + {"start": 20.0, "end": 26.0, "timescale": 1.0} + ], + "transitions": [{"segmentIndex": 1, "type": "cross-fade", "duration": 1.0}], + "zoomSegments": [] + } + })) + .unwrap(); + + assert_eq!(export_estimate_duration(&project, 361.0), 11.0); + } + #[test] fn export_settings_exposes_force_ffmpeg_for_mp4_only() { let mp4_settings = ExportSettings::Mp4(cap_export::mp4::Mp4ExportSettings { @@ -2030,7 +2073,7 @@ async fn generate_export_preview_fast_inner( let total_pixels = (settings.resolution_base.x * settings.resolution_base.y) as f64; let fps_f64 = settings.fps as f64; - let duration_seconds = editor.recordings.duration(); + let duration_seconds = export_estimate_duration(&project_config, editor.recordings.duration()); let total_frames = (duration_seconds * fps_f64).ceil() as u32; let estimated_size_mb = if settings.cursor_only { diff --git a/apps/desktop/src-tauri/src/gpui_app.rs b/apps/desktop/src-tauri/src/gpui_app.rs index 8f6d6f1ea89..09147949715 100644 --- a/apps/desktop/src-tauri/src/gpui_app.rs +++ b/apps/desktop/src-tauri/src/gpui_app.rs @@ -351,7 +351,12 @@ fn running_instance_pid() -> Option { .parse::() .ok()?; - #[cfg(unix)] + #[cfg(target_os = "linux")] + { + linux_gpui_process_is_running(pid).then_some(pid) + } + + #[cfg(all(unix, not(target_os = "linux")))] { let alive = std::process::Command::new("ps") .args(["-p", &pid.to_string(), "-o", "comm="]) @@ -378,10 +383,18 @@ fn running_instance_pid() -> Option { } } +#[cfg(target_os = "linux")] +fn linux_gpui_process_is_running(pid: u32) -> bool { + std::fs::read_link(format!("/proc/{pid}/exe")).is_ok_and(|path| is_gpui_process_image(&path)) +} + fn is_gpui_process_image(path: &std::path::Path) -> bool { - path.file_name() - .and_then(std::ffi::OsStr::to_str) - .is_some_and(|name| name.eq_ignore_ascii_case(BINARY_NAME)) + let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else { + return false; + }; + #[cfg(target_os = "linux")] + let name = name.strip_suffix(" (deleted)").unwrap_or(name); + name.eq_ignore_ascii_case(BINARY_NAME) } #[cfg(any(target_os = "macos", windows, test))] @@ -900,6 +913,51 @@ mod tests { ))); } + #[cfg(target_os = "linux")] + #[test] + fn linux_process_check_distinguishes_live_unlinked_and_exited_processes() { + let directory = tempfile::tempdir().unwrap(); + let binary = directory.path().join(BINARY_NAME); + std::fs::copy("/bin/sleep", &binary).unwrap(); + let mut child = std::process::Command::new(&binary) + .arg("30") + .spawn() + .unwrap(); + let pid = child.id(); + let started = std::time::Instant::now(); + // spawn can return before /proc stops exposing the child's pre-exec image. + while !std::fs::read_link(format!("/proc/{pid}/exe")).is_ok_and(|path| path == binary) + && started.elapsed() < std::time::Duration::from_secs(5) + { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let live = super::linux_gpui_process_is_running(pid); + std::fs::remove_file(&binary).unwrap(); + let unlinked = super::linux_gpui_process_is_running(pid); + child.kill().unwrap(); + let started = std::time::Instant::now(); + let mut zombie = false; + while started.elapsed() < std::time::Duration::from_secs(5) { + zombie = std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| { + stat.rsplit_once(") ") + .is_some_and(|(_, state)| state.starts_with('Z')) + }); + if zombie { + break; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let exited = super::linux_gpui_process_is_running(pid); + child.wait().unwrap(); + + assert!(live); + assert!(unlinked); + assert!(zombie); + assert!(!exited); + assert!(!super::linux_gpui_process_is_running(pid)); + assert!(!super::linux_gpui_process_is_running(std::process::id())); + } + #[test] fn gpui_forwarding_endpoint_requires_the_owner_identity() { assert_eq!( diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ab1dc76e715..84fe5ecc022 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -9,6 +9,20 @@ use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt}; const TOKIO_WORKER_THREAD_STACK_SIZE: usize = 16 * 1024 * 1024; fn main() { + #[cfg(target_os = "linux")] + if let Some(config) = cap_utils::linux_package::appimage_alsa_config_path() { + // Configure ALSA before starting threads or handing off to a bundled child process. + unsafe { + std::env::set_var("ALSA_CONFIG_PATH", config); + } + } + + #[cfg(target_os = "linux")] + if let Err(error) = cap_cli_install::appimage::dispatch_cli() { + eprintln!("{error}"); + std::process::exit(1); + } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] if std::env::var_os("ORT_DYLIB_PATH").is_none() && let Some(path) = cap_camera_effects::onnx_runtime_library_path() diff --git a/apps/desktop/src-tauri/src/thumbnails/linux.rs b/apps/desktop/src-tauri/src/thumbnails/linux.rs index fbb3d7a2988..f163cce7bad 100644 --- a/apps/desktop/src-tauri/src/thumbnails/linux.rs +++ b/apps/desktop/src-tauri/src/thumbnails/linux.rs @@ -14,7 +14,7 @@ pub async fn capture_window_thumbnail(window: &scap_targets::Window) -> Option Option { - if cap_recording::screenshot::is_pure_wayland_session() { + if cap_recording::screenshot::uses_wayland_portal() { return None; } diff --git a/apps/desktop/src-tauri/src/updates.rs b/apps/desktop/src-tauri/src/updates.rs index 6934af90570..d2491f8f635 100644 --- a/apps/desktop/src-tauri/src/updates.rs +++ b/apps/desktop/src-tauri/src/updates.rs @@ -69,22 +69,25 @@ fn current_channel(app: &AppHandle) -> UpdateChannel { .unwrap_or_default() } -// Mirrors `updaterTarget()` in src/utils/updater.ts; the plugin's built-in -// target reports "macos"/"linux" while CrabNebula releases are keyed on -// "darwin-*" / "linux-*-deb". -fn updater_target() -> String { +fn updater_target() -> Result { let arch = if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" }; - if cfg!(target_os = "macos") { - format!("darwin-{arch}") - } else if cfg!(target_os = "linux") { - format!("linux-{arch}-deb") - } else { - format!("windows-{arch}") + #[cfg(target_os = "linux")] + { + cap_utils::linux_package::updater_target(arch) + } + #[cfg(not(target_os = "linux"))] + { + let platform = if cfg!(target_os = "macos") { + "darwin" + } else { + "windows" + }; + Ok(format!("{platform}-{arch}")) } } @@ -103,7 +106,7 @@ async fn check_channel( ) -> Result, String> { let builder = app .updater_builder() - .target(updater_target()) + .target(updater_target()?) .endpoints(vec![endpoint(channel)?]) .map_err(|e| e.to_string())?; diff --git a/apps/desktop/src/components/titlebar/controls/CaptionControlsWindows11.tsx b/apps/desktop/src/components/titlebar/controls/CaptionControlsWindows11.tsx index d55b689bd17..422dc6e7b37 100644 --- a/apps/desktop/src/components/titlebar/controls/CaptionControlsWindows11.tsx +++ b/apps/desktop/src/components/titlebar/controls/CaptionControlsWindows11.tsx @@ -94,7 +94,7 @@ export default function ( disabled={!titlebarState.closable} class={cx( "max-h-20 w-[46px] rounded-none bg-transparent hover:text-gray-1", - "hover:bg-[#c42b1c] dark:hover:bg-[#c42b1c active:bg-[#c42b1c]/90 dark:active:bg-[#c42b1c]/90", + "hover:bg-[#c42b1c] dark:hover:bg-[#c42b1c] active:bg-[#c42b1c]/90 dark:active:bg-[#c42b1c]/90", "disabled:hover:bg-transparent dark:disabled:hover:bg-transparent disabled:text-black-transparent-40", )} > diff --git a/crates/cli-install/src/appimage.rs b/crates/cli-install/src/appimage.rs new file mode 100644 index 00000000000..0e673b929ee --- /dev/null +++ b/crates/cli-install/src/appimage.rs @@ -0,0 +1,227 @@ +use std::{ + ffi::OsString, + fs, + io::Write, + os::unix::{ + ffi::{OsStrExt, OsStringExt}, + fs::OpenOptionsExt, + }, + path::{Path, PathBuf}, +}; + +const SHIM_PREFIX: &[u8] = b"#!/bin/sh\nexec '"; +const SHIM_SUFFIX: &[u8] = b"' --cap-cli \"$@\"\n"; + +pub fn current_path() -> Option { + #[cfg(target_os = "linux")] + { + appimage_path( + Path::new(&std::env::var_os("APPIMAGE")?), + Path::new(&std::env::var_os("APPDIR")?), + &std::env::current_exe().ok()?, + ) + } + #[cfg(not(target_os = "linux"))] + None +} + +#[cfg(any(target_os = "linux", test))] +fn appimage_path(image: &Path, directory: &Path, executable: &Path) -> Option { + (image.is_absolute() && directory.is_absolute() && executable.starts_with(directory)) + .then(|| image.to_path_buf()) +} + +fn shim_contents(target: &Path) -> Result, String> { + if !target.is_absolute() { + return Err("The AppImage CLI launcher requires an absolute application path".into()); + } + + let mut contents = SHIM_PREFIX.to_vec(); + for byte in target.as_os_str().as_bytes() { + if *byte == b'\'' { + contents.extend_from_slice(b"'\\''"); + } else { + contents.push(*byte); + } + } + contents.extend_from_slice(SHIM_SUFFIX); + Ok(contents) +} + +pub fn shim_target(contents: &[u8]) -> Option { + let mut encoded = contents + .strip_prefix(SHIM_PREFIX)? + .strip_suffix(SHIM_SUFFIX)?; + let mut decoded = Vec::with_capacity(encoded.len()); + while let Some((&byte, remaining)) = encoded.split_first() { + if byte == b'\'' { + encoded = encoded.strip_prefix(b"'\\''")?; + } else { + if byte == 0 { + return None; + } + encoded = remaining; + } + decoded.push(byte); + } + let target = PathBuf::from(OsString::from_vec(decoded)); + target.is_absolute().then_some(target) +} + +pub fn write_shim(shim: &Path, target: &Path) -> Result<(), String> { + let contents = shim_contents(target)?; + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o755) + .open(shim) + .map_err(|error| format!("Could not create AppImage CLI launcher: {error}"))?; + file.write_all(&contents) + .map_err(|error| format!("Could not write AppImage CLI launcher: {error}")) +} + +#[cfg(any(target_os = "linux", test))] +fn cli_command( + executable: &Path, + original_directory: Option<&Path>, +) -> Result { + let directory = original_directory + .filter(|directory| directory.is_absolute() && directory.is_dir()) + .ok_or_else(|| "The AppImage original working directory is unavailable".to_string())?; + let mut command = std::process::Command::new(executable); + command.current_dir(directory); + Ok(command) +} + +#[cfg(target_os = "linux")] +pub fn dispatch_cli() -> Result<(), String> { + use std::os::unix::process::CommandExt; + + let mut arguments = std::env::args_os().skip(1); + if arguments.next().as_deref() != Some(std::ffi::OsStr::new("--cap-cli")) { + return Ok(()); + } + if current_path().is_none() { + return Err("The --cap-cli launcher is only available inside a Cap AppImage".into()); + } + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let directory = executable + .parent() + .ok_or_else(|| "Could not locate the AppImage executable directory".to_string())?; + let original_directory = std::env::var_os("OWD").map(PathBuf::from); + let error = cli_command(&directory.join("cap-cli"), original_directory.as_deref())? + .args(arguments) + .exec(); + Err(format!("Could not launch the bundled Cap CLI: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ignores_appimage_environment_from_unrelated_applications() { + assert_eq!( + appimage_path( + Path::new("/home/user/Cap.AppImage"), + Path::new("/tmp/.mount_cap"), + Path::new("/tmp/.mount_cap/usr/bin/Cap"), + ), + Some(PathBuf::from("/home/user/Cap.AppImage")) + ); + assert!( + appimage_path( + Path::new("/home/user/Cap.AppImage"), + Path::new("/tmp/.mount_cap"), + Path::new("/usr/bin/Cap"), + ) + .is_none() + ); + } + + #[test] + fn launcher_round_trips_shell_metacharacters_and_non_unicode_paths() { + for raw in [ + b"/home/user/Cap.AppImage".as_slice(), + b"/home/user/Cap's $(example) `command`.AppImage".as_slice(), + b"/home/user/Cap-\xff.AppImage".as_slice(), + ] { + let target = PathBuf::from(OsString::from_vec(raw.to_vec())); + assert_eq!(shim_target(&shim_contents(&target).unwrap()), Some(target)); + } + assert!(shim_contents(Path::new("relative.AppImage")).is_err()); + assert!(shim_target(b"#!/bin/sh\nexec '/tmp/Cap'bad' --cap-cli \"$@\"\n").is_none()); + assert!( + shim_target(b"#!/bin/sh\nexec '/tmp/Cap' --cap-cli \"$@\"\necho extra\n").is_none() + ); + } + + #[test] + fn launcher_preserves_arguments_exit_status_and_in_place_updates() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let image = directory.path().join("Cap's portable.AppImage"); + let shim = directory.path().join("cap"); + fs::write(&image, b"#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 23\n").unwrap(); + fs::set_permissions(&image, fs::Permissions::from_mode(0o755)).unwrap(); + write_shim(&shim, &image).unwrap(); + let output = std::process::Command::new(&shim) + .args(["--version", "two words", "$(literal)"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(23)); + assert_eq!( + output.stdout, + b"--cap-cli\n--version\ntwo words\n$(literal)\n" + ); + fs::write(&image, b"#!/bin/sh\nprintf replacement\n").unwrap(); + let output = std::process::Command::new(&shim).output().unwrap(); + assert!(output.status.success()); + assert_eq!(output.stdout, b"replacement"); + assert!(write_shim(&shim, &image).is_err()); + } + + #[test] + fn cli_launch_resolves_relative_paths_from_the_original_directory() { + let parent_directory = std::env::current_dir().unwrap(); + let root = tempfile::tempdir().unwrap(); + for name in [ + b"caller's directory".as_slice(), + "caller λ".as_bytes(), + #[cfg(target_os = "linux")] + b"caller-\xff".as_slice(), + ] { + let directory = root.path().join(OsString::from_vec(name.to_vec())); + fs::create_dir(&directory).unwrap(); + fs::write(directory.join("relative-project.txt"), b"caller project").unwrap(); + let mut command = cli_command(Path::new("/bin/sh"), Some(&directory)).unwrap(); + assert_eq!(command.get_current_dir(), Some(directory.as_path())); + let output = command + .args(["-c", "cat relative-project.txt"]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!(output.stdout, b"caller project"); + } + assert_eq!(std::env::current_dir().unwrap(), parent_directory); + } + + #[test] + fn cli_launch_never_substitutes_a_different_directory_for_invalid_owd() { + let root = tempfile::tempdir().unwrap(); + let executable = Path::new("/bin/sh"); + assert!(cli_command(executable, Some(Path::new("relative"))).is_err()); + assert!(cli_command(executable, Some(&root.path().join("missing"))).is_err()); + let file = root.path().join("file"); + fs::write(&file, b"not a directory").unwrap(); + assert!(cli_command(executable, Some(&file)).is_err()); + assert!(cli_command(executable, None).is_err()); + + let directory = root.path().join("removed-before-launch"); + fs::create_dir(&directory).unwrap(); + let mut command = cli_command(executable, Some(&directory)).unwrap(); + fs::remove_dir(&directory).unwrap(); + assert!(command.output().is_err()); + } +} diff --git a/crates/cli-install/src/lib.rs b/crates/cli-install/src/lib.rs index 3b86484c1c3..08a4ccce037 100644 --- a/crates/cli-install/src/lib.rs +++ b/crates/cli-install/src/lib.rs @@ -13,6 +13,9 @@ use std::{ path::{Path, PathBuf}, }; +#[cfg(unix)] +pub mod appimage; + const CAP_DIR_NAME: &str = ".cap"; const BIN_DIR_NAME: &str = "bin"; const CLI_BINARY_STEM: &str = "cap-cli"; @@ -83,6 +86,11 @@ fn shim_path() -> Result { } fn target_path() -> Result { + #[cfg(target_os = "linux")] + if let Some(path) = appimage::current_path() { + return Ok(path); + } + let exe = env::current_exe().map_err(|e| format!("Could not locate Cap executable: {e}"))?; // When `cap` runs through the installed shim (a symlink), macOS `current_exe()` returns the // symlink path; resolve it to the real binary so the sibling `cap-cli` resolves to the bundled @@ -215,14 +223,20 @@ fn shim_points_to(shim_path: &Path, target_path: &Path) -> Result // current_exe spells differently; compare the resolved paths too so a Cap-managed shim is still // recognized by status/install/uninstall. Ok(link) => Ok(link == target_path || same_file(&link, target_path)), - // A non-symlink regular file (read_link → InvalidInput) or a missing path is simply not a - // Cap-managed shim — let the caller report that as a conflict rather than surfacing a raw error. - Err(err) - if matches!( - err.kind(), - std::io::ErrorKind::NotFound | std::io::ErrorKind::InvalidInput - ) => - { + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) if err.kind() == std::io::ErrorKind::InvalidInput => { + #[cfg(target_os = "linux")] + { + if !shim_path.is_file() { + return Ok(false); + } + let contents = fs::read(shim_path) + .map_err(|error| format!("Could not read AppImage CLI launcher: {error}"))?; + Ok(appimage::shim_target(&contents) + .is_some_and(|target| target == target_path || same_file(&target, target_path))) + } + + #[cfg(not(target_os = "linux"))] Ok(false) } Err(err) => Err(format!("Could not read CLI shim: {err}")), @@ -257,6 +271,17 @@ fn shim_is_cap_managed(shim_path: &Path) -> bool { Ok(link) => link .file_name() .is_some_and(cli_binary_file_name_is_cap_managed), + Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => { + #[cfg(target_os = "linux")] + return shim_path.is_file() + && fs::read(shim_path) + .ok() + .and_then(|contents| appimage::shim_target(&contents)) + .is_some(); + + #[cfg(not(target_os = "linux"))] + false + } Err(_) => false, } } @@ -438,6 +463,11 @@ pub fn status() -> Result { #[cfg(unix)] fn write_shim(shim_path: &Path, target_path: &Path) -> Result<(), String> { + #[cfg(target_os = "linux")] + if appimage::current_path().as_deref() == Some(target_path) { + return appimage::write_shim(shim_path, target_path); + } + std::os::unix::fs::symlink(target_path, shim_path) .map_err(|e| format!("Could not create CLI symlink: {e}")) } @@ -628,17 +658,53 @@ pub fn uninstall() -> Result { let shim_path = shim_path()?; let target_path = target_path()?; - if shim_points_to(&shim_path, &target_path)? { - fs::remove_file(&shim_path).map_err(|e| format!("Could not remove CLI shim: {e}"))?; - } - + uninstall_shim(&shim_path, &target_path)?; status() } +fn uninstall_shim(shim: &Path, target: &Path) -> Result<(), String> { + let removable = shim_points_to(shim, target)?; + #[cfg(target_os = "linux")] + let removable = removable + || (fs::symlink_metadata(shim).is_ok_and(|metadata| metadata.is_file()) + && fs::read(shim) + .ok() + .and_then(|contents| appimage::shim_target(&contents)) + .is_some_and(|previous| !path_is_present(&previous))); + + if removable { + fs::remove_file(shim).map_err(|error| format!("Could not remove CLI shim: {error}"))?; + } + Ok(()) +} + #[cfg(all(test, unix))] mod tests { use super::*; + #[cfg(target_os = "linux")] + #[test] + fn uninstall_handles_moved_appimages_without_removing_other_installations() { + let directory = tempfile::tempdir().unwrap(); + let shim = directory.path().join("cap"); + let previous = directory.path().join("previous.AppImage"); + let current = directory.path().join("current.AppImage"); + appimage::write_shim(&shim, &previous).unwrap(); + uninstall_shim(&shim, ¤t).unwrap(); + assert!(!path_is_present(&shim)); + + fs::write(&previous, b"another installed AppImage").unwrap(); + appimage::write_shim(&shim, &previous).unwrap(); + uninstall_shim(&shim, ¤t).unwrap(); + assert!(path_is_present(&shim)); + uninstall_shim(&shim, &previous).unwrap(); + assert!(!path_is_present(&shim)); + + fs::write(&shim, b"#!/bin/sh\necho user script\n").unwrap(); + uninstall_shim(&shim, ¤t).unwrap(); + assert_eq!(fs::read(&shim).unwrap(), b"#!/bin/sh\necho user script\n"); + } + #[test] fn shell_profile_selection() { let home = Path::new("/home/u"); @@ -699,6 +765,25 @@ mod tests { // A missing path is not Cap-managed. fs::remove_file(&shim).unwrap(); assert!(!shim_is_cap_managed(&shim)); + + fs::create_dir(&shim).unwrap(); + let target = Path::new("/home/u/Cap.AppImage"); + assert!(!shim_is_cap_managed(&shim)); + assert!(!shim_points_to(&shim, target).unwrap()); + uninstall_shim(&shim, target).unwrap(); + assert!(shim.is_dir()); + } + + #[cfg(target_os = "linux")] + #[test] + fn appimage_shims_remain_managed_when_the_application_moves() { + let directory = tempfile::tempdir().unwrap(); + let shim = directory.path().join(SHIM_NAME); + let target = Path::new("/home/u/Cap.AppImage"); + appimage::write_shim(&shim, target).unwrap(); + assert!(shim_is_cap_managed(&shim)); + assert!(shim_points_to(&shim, target).unwrap()); + assert!(!shim_points_to(&shim, Path::new("/home/u/New.Cap.AppImage")).unwrap()); } #[test] diff --git a/crates/editor/src/audio_output.rs b/crates/editor/src/audio_output.rs index 655003ecb65..e5810988e8d 100644 --- a/crates/editor/src/audio_output.rs +++ b/crates/editor/src/audio_output.rs @@ -188,7 +188,6 @@ struct ActiveSource { generation: u64, buffer: PrerenderedAudioBuffer, playhead_rx: watch::Receiver, - last_video_playhead: f64, ack: Option>, #[cfg(not(target_os = "windows"))] latency_corrector: LatencyCorrector, @@ -277,15 +276,14 @@ fn render_source_block>( ) { if source.playhead_rx.has_changed().unwrap_or(false) { let video_playhead = *source.playhead_rx.borrow_and_update(); - let jump = (video_playhead - source.last_video_playhead).abs(); let audible_playhead = source.buffer.current_audible_playhead(latency_secs); let drift = (video_playhead - audible_playhead).abs(); - if jump > 0.05 || drift > 0.04 { + // Normal frame updates coalesce on this watch too; only audible drift + // establishes whether the audio needs to move to the latest target. + if drift > 0.04 { source.buffer.set_playhead(video_playhead + latency_secs); } - - source.last_video_playhead = video_playhead; } source.buffer.fill(buffer); @@ -372,7 +370,6 @@ fn install_source>( generation, buffer, playhead_rx, - last_video_playhead: start_playhead_secs, ack: Some(ack), #[cfg(not(target_os = "windows"))] latency_corrector, @@ -560,6 +557,14 @@ fn ensure_stream(state: &mut Option) -> bool { } } +fn playback_output_info(config: &cpal::SupportedStreamConfig) -> AudioInfo { + // ALSA's supported maximum is not the queue size used by BufferSize::Default. + let buffer_size = cfg!(target_os = "linux").then_some(1024); + let mut info = AudioInfo::from_stream_config_with_buffer(config, buffer_size); + info.sample_format = info.sample_format.packed(); + info.for_ffmpeg_output() +} + fn build_stream( device: cpal::Device, supported_config: cpal::SupportedStreamConfig, @@ -568,12 +573,9 @@ fn build_stream( where T: FromSampleBytes + cpal::SizedSample + cpal::FromSample, { - let mut output_info = AudioInfo::from_stream_config(&supported_config); - output_info.sample_format = output_info.sample_format.packed(); + let output_info = playback_output_info(&supported_config); // Clamp for FFmpeg compatibility (max 8 channels); the stream config must // match what the pre-render buffer produces. - output_info = output_info.for_ffmpeg_output(); - let mut config = supported_config.config(); config.channels = output_info.channels as u16; @@ -633,3 +635,115 @@ where remove, }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn source(sample_rate: u32) -> (ActiveSource, watch::Sender) { + source_with_info( + AudioInfo::new_raw(AudioData::SAMPLE_FORMAT, sample_rate, 2), + false, + ) + } + + fn source_with_info( + output_info: AudioInfo, + use_device_latency_hint: bool, + ) -> (ActiveSource, watch::Sender) { + ffmpeg::init().unwrap(); + let (playhead_tx, playhead_rx) = watch::channel(0.0); + let (install_tx, install_rx) = std_mpsc::channel(); + let (ack_tx, _ack_rx) = std_mpsc::channel(); + install_source( + Box::new(PlaySpec { + segments: Vec::new(), + music: MusicTracks::new(), + project: ProjectConfiguration::default(), + duration_secs: 2.0, + start_playhead_secs: 0.0, + playhead_rx, + }), + 0, + ack_tx, + output_info, + use_device_latency_hint, + &install_tx, + ) + .unwrap(); + let SourceCommand::Install(source) = install_rx.recv().unwrap() else { + panic!("expected installed audio source"); + }; + (*source, playhead_tx) + } + + #[cfg(target_os = "linux")] + #[test] + fn supported_buffer_maximum_does_not_skip_the_start_of_playback() { + for sample_rate in [44_100, 48_000] { + let config = cpal::SupportedStreamConfig::new( + 2, + cpal::SampleRate(sample_rate), + cpal::SupportedBufferSize::Range { + min: 1, + max: 4_194_304, + }, + SampleFormat::F32, + ); + let (source, _) = source_with_info(playback_output_info(&config), true); + let playhead = source.buffer.current_playhead_secs(); + assert!( + (playhead - 0.03).abs() < 1.0 / f64::from(sample_rate), + "sample_rate={sample_rate}, playhead={playhead}" + ); + } + } + + #[test] + fn coalesced_video_updates_preserve_synchronized_audio_position() { + for sample_rate in [44_100, 48_000] { + for latency_secs in [0.0, 0.025, 0.15] { + let (mut source, playhead_tx) = source(sample_rate); + let mut elapsed = vec![0.0; sample_rate as usize / 4 * 2]; + render_source_block(&mut source, &mut elapsed, latency_secs); + let before = source.buffer.current_playhead_secs(); + let audible = source.buffer.current_audible_playhead(latency_secs); + playhead_tx.send(audible - 0.02).unwrap(); + playhead_tx.send(audible - 0.002).unwrap(); + + let mut block = [0.0; 512 * 2]; + render_source_block(&mut source, &mut block, latency_secs); + + let expected = before + 512.0 / f64::from(sample_rate); + let actual = source.buffer.current_playhead_secs(); + assert!( + (actual - expected).abs() < 1.0 / f64::from(sample_rate), + "sample_rate={sample_rate}, latency={latency_secs}, expected={expected}, actual={actual}" + ); + } + } + } + + #[test] + fn video_drift_reseats_audio_for_forward_and_backward_seeks() { + for sample_rate in [44_100, 48_000] { + for latency_secs in [0.0, 0.025, 0.15] { + let (mut source, playhead_tx) = source(sample_rate); + let mut elapsed = vec![0.0; sample_rate as usize / 4 * 2]; + render_source_block(&mut source, &mut elapsed, latency_secs); + let mut block = [0.0; 512 * 2]; + + for target in [0.03, 0.6, 0.1] { + playhead_tx.send(target).unwrap(); + render_source_block(&mut source, &mut block, latency_secs); + let expected = target + latency_secs + 512.0 / f64::from(sample_rate); + let actual = source.buffer.current_playhead_secs(); + assert!( + (actual - expected).abs() <= 1.0 / f64::from(sample_rate), + "sample_rate={sample_rate}, latency={latency_secs}, target={target}, expected={expected}, actual={actual}" + ); + } + } + } + } +} diff --git a/crates/editor/src/playback.rs b/crates/editor/src/playback.rs index 88a46b75ad0..9ed8a42df78 100644 --- a/crates/editor/src/playback.rs +++ b/crates/editor/src/playback.rs @@ -138,6 +138,7 @@ pub struct PlaybackHandle { } struct PrefetchedFrame { + seek_generation: u64, frame_number: u32, segment_frames: DecodedSegmentFrames, segment_index: u32, @@ -177,6 +178,33 @@ impl PrefetchedFrame { } } +fn receive_prefetched_frame( + receiver: &std_mpsc::Receiver, + seek_generation: u64, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + let frame = receiver.recv_timeout(deadline.saturating_duration_since(Instant::now()))?; + // A completed decode can already be queued when the producer observes a seek. + if frame.seek_generation == seek_generation { + return Ok(frame); + } + } +} + +fn should_reset_prefetch( + seek_generation: u64, + next_frame: u32, + requested_generation: u64, + requested_frame: u32, + has_in_flight: bool, +) -> bool { + requested_generation != seek_generation + || requested_frame > next_frame + || (requested_frame < next_frame && !has_in_flight) +} + type CachedTransition = (Arc, u32, ClipTransitionType, f32); type CachedFrame = (Arc, u32, Option); @@ -360,6 +388,7 @@ struct TransitionDecodeRequest { } struct PrefetchDecodeRequest { + seek_generation: u64, frame_number: u32, decoders: RecordingSegmentDecoders, segment_time: f64, @@ -371,6 +400,7 @@ struct PrefetchDecodeRequest { } type PrefetchDecodeResult = ( + u64, u32, u32, Option, @@ -379,6 +409,7 @@ type PrefetchDecodeResult = ( async fn decode_prefetched_frame(request: PrefetchDecodeRequest) -> PrefetchDecodeResult { let PrefetchDecodeRequest { + seek_generation, frame_number, decoders, segment_time, @@ -431,7 +462,13 @@ async fn decode_prefetched_frame(request: PrefetchDecodeRequest) -> PrefetchDeco }; let (segment_frames, transition) = tokio::join!(primary, transition); - (frame_number, segment_index, segment_frames, transition) + ( + seek_generation, + frame_number, + segment_index, + segment_frames, + transition, + ) } fn transition_decode_request( @@ -502,7 +539,8 @@ impl Playback { let (prefetch_tx, prefetch_rx) = std_mpsc::sync_channel::(PREFETCH_CHANNEL_SIZE); - let (frame_request_tx, mut frame_request_rx) = watch::channel(self.start_frame_number); + let (frame_request_tx, mut frame_request_rx) = + watch::channel((0u64, self.start_frame_number)); let (playback_position_tx, playback_position_rx) = watch::channel(self.start_frame_number); let prefetch_frame_limit = Arc::new(AtomicUsize::new(INITIAL_PREFETCH_BUFFER_SIZE)); @@ -541,7 +579,9 @@ impl Playback { } type PrefetchFuture = std::pin::Pin + Send>>; - let mut next_prefetch_frame = *frame_request_rx.borrow(); + let (mut seek_generation, mut next_prefetch_frame) = + *frame_request_rx.borrow_and_update(); + let mut request_changed = false; let mut in_flight: FuturesUnordered = FuturesUnordered::new(); let mut frames_decoded: u32 = 0; let mut cached_project = prefetch_project.borrow().clone(); @@ -552,28 +592,23 @@ impl Playback { break; } - if let Some(frame) = pending_frame.take() { - match prefetch_tx.try_send(frame) { - Ok(()) => {} - Err(std_mpsc::TrySendError::Full(frame)) => { - pending_frame = Some(frame); - tokio::select! { - _ = prefetch_stop_rx.changed() => {} - _ = tokio::time::sleep(Duration::from_millis(1)) => {} - } - continue; - } - Err(std_mpsc::TrySendError::Disconnected(_)) => break, - } - } - if prefetch_project.has_changed().unwrap_or(false) { cached_project = prefetch_project.borrow_and_update().clone(); } - if let Ok(true) = frame_request_rx.has_changed() { - let requested = *frame_request_rx.borrow_and_update(); - if requested != next_prefetch_frame { + if request_changed || frame_request_rx.has_changed().unwrap_or(false) { + request_changed = false; + let (requested_generation, requested) = *frame_request_rx.borrow_and_update(); + let generation_changed = requested_generation != seek_generation; + let reset = should_reset_prefetch( + seek_generation, + next_prefetch_frame, + requested_generation, + requested, + !in_flight.is_empty(), + ); + seek_generation = requested_generation; + if reset { let old_frame = next_prefetch_frame; let is_backward_seek = requested < old_frame; let seek_distance = if is_backward_seek { @@ -591,9 +626,38 @@ impl Playback { let reset_distance = producer_prefetch_frame_limit.load(Ordering::Relaxed).max(2) as u32 / 2; - if is_backward_seek || seek_distance > reset_distance { + if generation_changed || is_backward_seek || seek_distance > reset_distance + { in_flight = FuturesUnordered::new(); } + if generation_changed { + pending_frame = None; + } + } + } + + if let Some(frame) = pending_frame.take() { + match prefetch_tx.try_send(frame) { + Ok(()) => {} + Err(std_mpsc::TrySendError::Full(frame)) => { + pending_frame = Some(frame); + tokio::select! { + result = prefetch_stop_rx.changed() => { + if result.is_err() { + break; + } + } + result = frame_request_rx.changed() => { + if result.is_err() { + break; + } + request_changed = true; + } + _ = tokio::time::sleep(Duration::from_millis(1)) => {} + } + continue; + } + Err(std_mpsc::TrySendError::Disconnected(_)) => break, } } @@ -663,6 +727,7 @@ impl Playback { } in_flight.push(Box::pin(decode_prefetched_frame(PrefetchDecodeRequest { + seek_generation, frame_number: frame_num, decoders, segment_time, @@ -680,7 +745,19 @@ impl Playback { tokio::select! { biased; - Some((frame_num, segment_index, result, transition)) = in_flight.next() => { + result = prefetch_stop_rx.changed() => { + if result.is_err() { + break; + } + } + result = frame_request_rx.changed() => { + if result.is_err() { + break; + } + request_changed = true; + } + + Some((generation, frame_num, segment_index, result, transition)) = in_flight.next() => { if let Ok(mut in_flight_guard) = prefetch_in_flight.write() { in_flight_guard.remove(&frame_num); } @@ -688,6 +765,7 @@ impl Playback { if let Some(segment_frames) = result { let frame = PrefetchedFrame { + seek_generation: generation, frame_number: frame_num, segment_frames, segment_index, @@ -744,6 +822,7 @@ impl Playback { let frame_duration = Duration::from_secs_f64(1.0 / fps_f64); let mut frame_number = self.start_frame_number; + let mut seek_generation = 0; let mut prefetch_buffer: VecDeque = VecDeque::with_capacity(MAX_PREFETCH_BUFFER_SIZE + PREFETCH_CHANNEL_SIZE); let mut frame_cache = FrameCache::new(FRAME_CACHE_SIZE, MAX_FRAME_CACHE_BYTES); @@ -766,7 +845,7 @@ impl Playback { Duration::from_millis(500) }; let warmup_no_frames_timeout = Duration::from_secs(5); - let warmup_start = Instant::now(); + let mut warmup_start = Instant::now(); let mut first_frame_time: Option = None; let mut boost_until: Option = None; let mut starvation_skips = 0u64; @@ -778,19 +857,14 @@ impl Playback { boost_until, ); - // A seek that lands during warmup retargets the warmup itself: - // the buffer keeps only frames at or past the target and the - // producer re-anchors through the same watch a mid-playback - // seek uses. if seek_rx.has_changed().unwrap_or(false) { - let (_, target) = *seek_rx.borrow_and_update(); - if target != frame_number { - frame_number = target; - prefetch_buffer.retain(|p| p.frame_number >= frame_number); - let _ = frame_request_tx.send(frame_number); - let _ = playback_position_tx.send(frame_number); - event_tx.send(PlaybackEvent::Frame(frame_number)).ok(); - } + (seek_generation, frame_number) = *seek_rx.borrow_and_update(); + prefetch_buffer.clear(); + first_frame_time = None; + warmup_start = Instant::now(); + let _ = frame_request_tx.send((seek_generation, frame_number)); + let _ = playback_position_tx.send(frame_number); + event_tx.send(PlaybackEvent::Frame(frame_number)).ok(); } let should_start = if let Some(first_time) = first_frame_time { @@ -821,7 +895,11 @@ impl Playback { return; } - match prefetch_rx.recv_timeout(Duration::from_millis(50)) { + match receive_prefetched_frame( + &prefetch_rx, + seek_generation, + Duration::from_millis(50), + ) { Ok(prefetched) => { if prefetched.frame_number >= frame_number { observe_prefetched_frame( @@ -836,7 +914,11 @@ impl Playback { while prefetch_buffer.len() < warmup_target_frames.min(prefetch_frame_limit.load(Ordering::Relaxed)) { - match prefetch_rx.try_recv() { + match receive_prefetched_frame( + &prefetch_rx, + seek_generation, + Duration::ZERO, + ) { Ok(p) => { if p.frame_number >= frame_number { observe_prefetched_frame( @@ -859,6 +941,7 @@ impl Playback { .make_contiguous() .sort_by_key(|p| p.frame_number); + let playback_start_frame = frame_number; let mut cached_project = self.project.borrow().clone(); let build_cursor_timelines = @@ -1050,7 +1133,7 @@ impl Playback { while prefetch_buffer.len() < warmup_target_frames.min(prefetch_frame_limit.load(Ordering::Relaxed)) { - match prefetch_rx.try_recv() { + match receive_prefetched_frame(&prefetch_rx, seek_generation, Duration::ZERO) { Ok(prefetched) => { if prefetched.frame_number >= frame_number { observe_prefetched_frame( @@ -1072,7 +1155,7 @@ impl Playback { elapsed: warmup_start.elapsed(), buffered_frames: prefetch_buffer.len(), target_frames: warmup_target_frames, - start_frame_number: self.start_frame_number, + start_frame_number: playback_start_frame, }); } @@ -1083,6 +1166,7 @@ impl Playback { // stream. Blocks until the live callback is consuming the source, // so the clock below never runs ahead of audible audio. let audio_spawn_start = Instant::now(); + let _ = audio_playhead_tx.send(playback_start_frame as f64 / fps_f64); let audio_generation = if !has_playback_audio(&audio_segments, !self.music.is_empty()) { info!("No audio segments found, skipping audio playback."); None @@ -1092,7 +1176,7 @@ impl Playback { music: self.music.clone(), project: self.project.borrow().clone(), duration_secs: duration, - start_playhead_secs: self.start_frame_number as f64 / fps_f64, + start_playhead_secs: playback_start_frame as f64 / fps_f64, playhead_rx: audio_playhead_rx, }) }; @@ -1107,7 +1191,7 @@ impl Playback { }); } let mut start = Instant::now(); - let mut clock_anchor_frame = self.start_frame_number; + let mut clock_anchor_frame = playback_start_frame; 'playback: loop { let limit_now = refresh_prefetch_limit( @@ -1116,30 +1200,22 @@ impl Playback { boost_until, ); - // A live seek re-anchors the clock in place instead of tearing - // the engine down: the producer resets through the same watch - // its skip paths use, the last rendered picture holds until - // the target's frames arrive, and audio reseats itself off the - // playhead watch (>50ms jumps re-seat in render_source_block). if seek_rx.has_changed().unwrap_or(false) { - let (_, target) = *seek_rx.borrow_and_update(); - if target != frame_number { - tracing::debug!(from = frame_number, to = target, "playback live seek"); - frame_number = target; - clock_anchor_frame = target; - start = Instant::now(); - let keep_until = target.saturating_add(limit_now as u32); - prefetch_buffer - .retain(|p| p.frame_number >= target && p.frame_number <= keep_until); - frame_cache.evict_far_from(target, limit_now as u32); - let _ = frame_request_tx.send(target); - let _ = playback_position_tx.send(target); - event_tx.send(PlaybackEvent::Frame(target)).ok(); - if has_audio && audio_playhead_tx.send(target as f64 / fps_f64).is_err() { - break 'playback; - } - continue; + let (generation, target) = *seek_rx.borrow_and_update(); + tracing::debug!(from = frame_number, to = target, "playback live seek"); + seek_generation = generation; + frame_number = target; + clock_anchor_frame = target; + start = Instant::now(); + prefetch_buffer.clear(); + frame_cache.evict_far_from(target, limit_now as u32); + let _ = frame_request_tx.send((seek_generation, target)); + let _ = playback_position_tx.send(target); + event_tx.send(PlaybackEvent::Frame(target)).ok(); + if has_audio && audio_playhead_tx.send(target as f64 / fps_f64).is_err() { + break 'playback; } + continue; } if self.project.has_changed().unwrap_or(false) { @@ -1175,7 +1251,7 @@ impl Playback { frame_number, prefetch_frame_limit.load(Ordering::Relaxed) as u32, ); - let _ = frame_request_tx.send(frame_number); + let _ = frame_request_tx.send((seek_generation, frame_number)); let _ = playback_position_tx.send(frame_number); if let Some(telemetry) = &self.telemetry { telemetry.emit(PlaybackTelemetryEvent::FrameSkipped { @@ -1201,7 +1277,7 @@ impl Playback { while prefetch_buffer.len() < prefetch_frame_limit.load(Ordering::Relaxed) && drained < drain_budget { - match prefetch_rx.try_recv() { + match receive_prefetched_frame(&prefetch_rx, seek_generation, Duration::ZERO) { Ok(prefetched) => { drained += 1; if prefetched.frame_number >= frame_number { @@ -1248,14 +1324,21 @@ impl Playback { prefetch_hits += 1; Some(prefetched.into_cached()) } else if prefetch_buffer.is_empty() { - let _ = frame_request_tx.send(frame_number); + let _ = frame_request_tx.send((seek_generation, frame_number)); let wait_ms = if total_frames_rendered < 15 { 20 } else { 8 }; - let prefetched_opt = match prefetch_rx - .recv_timeout(Duration::from_millis(wait_ms)) - { + let prefetched_opt = match receive_prefetched_frame( + &prefetch_rx, + seek_generation, + Duration::from_millis(wait_ms), + ) { Ok(p) => Some(p), - Err(std_mpsc::RecvTimeoutError::Timeout) => prefetch_rx.try_recv().ok(), + Err(std_mpsc::RecvTimeoutError::Timeout) => receive_prefetched_frame( + &prefetch_rx, + seek_generation, + Duration::ZERO, + ) + .ok(), Err(std_mpsc::RecvTimeoutError::Disconnected) => { break 'playback; } @@ -1310,7 +1393,7 @@ impl Playback { total_frames_skipped += 1; starvation_skips += 1; boost_until = Some(Instant::now() + BOOST_CLEAN_WINDOW); - let _ = frame_request_tx.send(frame_number); + let _ = frame_request_tx.send((seek_generation, frame_number)); let _ = playback_position_tx.send(frame_number); if let Some(telemetry) = &self.telemetry { telemetry.emit(PlaybackTelemetryEvent::FrameSkipped { @@ -1355,7 +1438,11 @@ impl Playback { .load(Ordering::Relaxed) .saturating_add(PREFETCH_CHANNEL_SIZE); while prefetch_buffer.len() < late_drain_limit { - match prefetch_rx.try_recv() { + match receive_prefetched_frame( + &prefetch_rx, + seek_generation, + Duration::ZERO, + ) { Ok(p) => { if p.frame_number >= frame_number { observe_prefetched_frame( @@ -1617,7 +1704,7 @@ impl Playback { frame_number, prefetch_frame_limit.load(Ordering::Relaxed) as u32, ); - let _ = frame_request_tx.send(frame_number); + let _ = frame_request_tx.send((seek_generation, frame_number)); let _ = playback_position_tx.send(frame_number); if let Some(telemetry) = &self.telemetry { telemetry.emit(PlaybackTelemetryEvent::FrameSkipped { @@ -1688,6 +1775,85 @@ mod tests { } } + fn prefetched_frame(seek_generation: u64, frame_number: u32) -> PrefetchedFrame { + PrefetchedFrame { + seek_generation, + frame_number, + segment_frames: decoded_frames(4), + segment_index: 0, + transition: None, + } + } + + #[test] + fn backward_seek_discards_queued_frames_from_the_previous_generation() { + let (sender, receiver) = std_mpsc::sync_channel(4); + for (generation, frame) in [(1, 3000), (1, 480), (2, 480), (2, 481)] { + assert!(sender.send(prefetched_frame(generation, frame)).is_ok()); + } + + let target = receive_prefetched_frame(&receiver, 2, Duration::ZERO).unwrap(); + assert_eq!(target.frame_number, 480); + assert_eq!(target.seek_generation, 2); + let next = receive_prefetched_frame(&receiver, 2, Duration::ZERO).unwrap(); + assert_eq!(next.frame_number, 481); + } + + #[test] + fn repeated_seeks_to_the_same_frame_require_the_latest_generation() { + let (sender, receiver) = std_mpsc::sync_channel(3); + for generation in 0..3 { + assert!(sender.send(prefetched_frame(generation, 480)).is_ok()); + } + + let target = receive_prefetched_frame(&receiver, 2, Duration::ZERO).unwrap(); + assert_eq!(target.seek_generation, 2); + assert_eq!(target.frame_number, 480); + } + + #[test] + fn stale_prefetch_frames_do_not_hide_timeout_or_disconnection() { + let (sender, receiver) = std_mpsc::sync_channel(1); + assert!(sender.send(prefetched_frame(1, 3000)).is_ok()); + assert!(matches!( + receive_prefetched_frame(&receiver, 2, Duration::ZERO), + Err(std_mpsc::RecvTimeoutError::Timeout) + )); + + assert!(sender.send(prefetched_frame(1, 3001)).is_ok()); + drop(sender); + assert!(matches!( + receive_prefetched_frame(&receiver, 2, Duration::ZERO), + Err(std_mpsc::RecvTimeoutError::Disconnected) + )); + } + + #[tokio::test] + async fn buffering_retries_preserve_in_flight_decodes() { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let mut in_flight = FuturesUnordered::new(); + in_flight.push(async move { receiver.await.unwrap() }); + + for requested in [480, 481, 482] { + if should_reset_prefetch(2, 484, 2, requested, !in_flight.is_empty()) { + in_flight = FuturesUnordered::new(); + } + } + + assert!(sender.send(483).is_ok()); + assert_eq!(in_flight.next().await, Some(483)); + } + + #[test] + fn prefetch_retries_recover_when_idle_and_new_seeks_always_reset() { + assert!(should_reset_prefetch(2, 484, 2, 480, false)); + assert!(should_reset_prefetch(2, 484, 2, 600, true)); + assert!(!should_reset_prefetch(2, 484, 2, 484, false)); + for requested in [480, 484, 600] { + assert!(should_reset_prefetch(2, 484, 3, requested, true)); + } + } + #[test] fn timeline_music_enables_audio_playback_without_recorded_audio() { assert!(has_playback_audio(&[], true)); diff --git a/crates/enc-ffmpeg/src/remux.rs b/crates/enc-ffmpeg/src/remux.rs index bf17ad6e2e0..c7be1976410 100644 --- a/crates/enc-ffmpeg/src/remux.rs +++ b/crates/enc-ffmpeg/src/remux.rs @@ -43,6 +43,8 @@ pub enum RemuxError { NoFragments, #[error("Fragment not found: {0}")] FragmentNotFound(PathBuf), + #[error("Fragment path cannot be written to an FFmpeg concat list: {0}")] + InvalidConcatPath(PathBuf), #[error("No audio stream found")] NoAudioStream, #[error("Opus encoder error: {0}")] @@ -65,16 +67,7 @@ pub fn concatenate_video_fragments(fragments: &[PathBuf], output: &Path) -> Resu } let concat_list_path = output.with_extension("concat.txt"); - { - let mut file = std::fs::File::create(&concat_list_path)?; - for fragment in fragments { - writeln!( - file, - "file '{}'", - fragment.to_string_lossy().replace('\'', "'\\''") - )?; - } - } + write_concat_list(fragments, &concat_list_path)?; let result = concatenate_with_concat_demuxer(&concat_list_path, output); @@ -83,6 +76,35 @@ pub fn concatenate_video_fragments(fragments: &[PathBuf], output: &Path) -> Resu result } +fn concat_fragment_entry(fragment: &Path, concat_list: &Path) -> Result { + let fragment = std::path::absolute(fragment)?; + let concat_list = std::path::absolute(concat_list)?; + let directory = concat_list.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Concat list has no parent directory", + ) + })?; + let relative = fragment.strip_prefix(directory).ok(); + let reference = relative.unwrap_or(&fragment); + let text = reference + .to_str() + .filter(|text| !text.contains(['\r', '\n'])) + .ok_or_else(|| RemuxError::InvalidConcatPath(fragment.clone()))?; + let prefix = if relative.is_some() { "./" } else { "" }; + + Ok(format!("file '{prefix}{}'\n", text.replace('\'', "'\\''"))) +} + +fn write_concat_list(fragments: &[PathBuf], concat_list: &Path) -> Result<(), RemuxError> { + let entries = fragments + .iter() + .map(|fragment| concat_fragment_entry(fragment, concat_list)) + .collect::, _>>()?; + std::fs::write(concat_list, entries.concat())?; + Ok(()) +} + fn open_input_with_format( path: &Path, format_name: &str, @@ -96,7 +118,13 @@ fn open_input_with_format( return Err(RemuxError::ConcatDemuxerNotFound); } - let path_cstr = CString::new(path.to_string_lossy().as_bytes()).map_err(|_| { + let path_text = path.to_str().ok_or_else(|| { + RemuxError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "FFmpeg input path is not valid UTF-8", + )) + })?; + let path_cstr = CString::new(path_text).map_err(|_| { RemuxError::Io(std::io::Error::new( std::io::ErrorKind::InvalidInput, "Invalid path", @@ -221,16 +249,7 @@ pub fn concatenate_audio_to_ogg(fragments: &[PathBuf], output: &Path) -> Result< } let concat_list_path = output.with_extension("concat.txt"); - { - let mut file = std::fs::File::create(&concat_list_path)?; - for fragment in fragments { - writeln!( - file, - "file '{}'", - fragment.to_string_lossy().replace('\'', "'\\''") - )?; - } - } + write_concat_list(fragments, &concat_list_path)?; let result = transcode_audio_to_ogg(&concat_list_path, output); @@ -955,9 +974,184 @@ fn merge_video_audio_inner( #[cfg(test)] mod tests { - use super::build_seek_probe_positions; - use super::probe_video_pts_ladder; - use std::time::Duration; + use super::{ + build_seek_probe_positions, concat_fragment_entry, concatenate_audio_to_ogg, + concatenate_video_fragments, probe_video_pts_ladder, write_concat_list, + }; + use std::{path::Path, time::Duration}; + + #[test] + fn concat_entries_resolve_fragments_from_the_list_directory() { + assert_eq!( + concat_fragment_entry( + Path::new("recording/segment/audio.m4a"), + Path::new("recording/segment/audio.concat.txt"), + ) + .unwrap(), + "file './audio.m4a'\n" + ); + + let directory = tempfile::tempdir().unwrap(); + let fragment = directory.path().join("input.m4a"); + let list = directory.path().join("other/output.concat.txt"); + assert_eq!( + concat_fragment_entry(&fragment, &list).unwrap(), + format!("file '{}'\n", fragment.display()) + ); + } + + #[test] + fn concat_entries_escape_apostrophes_without_changing_parent_paths() { + let directory = tempfile::tempdir().unwrap(); + let parent = directory.path().join("parent 'quoted'"); + assert_eq!( + concat_fragment_entry( + &parent.join("fragment 'quoted'.m4a"), + &parent.join("output.concat.txt"), + ) + .unwrap(), + concat!(r"file './fragment '\''quoted'\''.m4a'", "\n") + ); + } + + #[cfg(unix)] + #[test] + fn concat_relative_entries_cannot_be_interpreted_as_url_schemes() { + assert_eq!( + concat_fragment_entry( + Path::new("recording/https:fragment.m4a"), + Path::new("recording/output.concat.txt"), + ) + .unwrap(), + "file './https:fragment.m4a'\n" + ); + } + + #[test] + fn unrepresentable_concat_entries_fail_before_creating_the_list() { + let directory = tempfile::tempdir().unwrap(); + let list = directory.path().join("output.concat.txt"); + for name in ["fragment\nname.m4a", "fragment\rname.m4a"] { + assert!(write_concat_list(&[directory.path().join(name)], &list).is_err()); + assert!(!list.exists()); + } + } + + #[cfg(unix)] + #[test] + fn concat_entries_reject_non_unicode_names_without_lossy_conversion() { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + let fragment = + Path::new("recording").join(OsString::from_vec(b"fragment-\xff.m4a".to_vec())); + assert!(matches!( + concat_fragment_entry(&fragment, Path::new("recording/output.concat.txt")), + Err(super::RemuxError::InvalidConcatPath(_)) + )); + } + + #[cfg(unix)] + #[test] + fn concat_entries_preserve_symlink_spelling() { + let directory = tempfile::tempdir().unwrap(); + let actual = directory.path().join("actual"); + std::fs::create_dir(&actual).unwrap(); + std::fs::write(actual.join("fragment.m4a"), []).unwrap(); + let alias = directory.path().join("alias"); + std::os::unix::fs::symlink(&actual, &alias).unwrap(); + + assert_eq!( + concat_fragment_entry( + &alias.join("fragment.m4a"), + &directory.path().join("output.concat.txt"), + ) + .unwrap(), + "file './alias/fragment.m4a'\n" + ); + } + + #[cfg(unix)] + #[test] + fn concat_input_rejects_non_unicode_paths_explicitly() { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + let path = std::path::PathBuf::from(OsString::from_vec(b"input-\xff.concat.txt".to_vec())); + assert!(matches!( + super::open_input_with_format(&path, "concat", ffmpeg::Dictionary::new()), + Err(super::RemuxError::Io(error)) + if error.kind() == std::io::ErrorKind::InvalidInput + )); + } + + fn encode_test_audio(directory: &Path) -> std::path::PathBuf { + use crate::fragmented_audio::FragmentedAudioFile; + use cap_media_info::AudioInfo; + use ffmpeg::{ChannelLayout, format::Sample, format::sample::Type}; + + let path = directory.join("fragment.m4a"); + let info = AudioInfo::new_raw(Sample::F32(Type::Packed), 48_000, 1); + let mut output = FragmentedAudioFile::init(path.clone(), info).unwrap(); + for block in 0..10 { + let mut frame = + ffmpeg::frame::Audio::new(Sample::F32(Type::Packed), 1024, ChannelLayout::MONO); + frame.set_rate(48_000); + frame.set_pts(Some(block * 1024)); + for (index, value) in frame.data_mut(0)[..1024 * 4] + .chunks_exact_mut(4) + .enumerate() + { + let position = (block * 1024) as f32 + index as f32; + let sample = 0.25 * (position * 440.0 * std::f32::consts::TAU / 48_000.0).sin(); + value.copy_from_slice(&sample.to_ne_bytes()); + } + output + .queue_frame( + frame, + Duration::from_secs_f64(block as f64 * 1024.0 / 48_000.0), + ) + .unwrap(); + } + output.finish().unwrap().unwrap(); + path + } + + fn assert_concat_roundtrip(parent_name: &str) { + let directory = tempfile::tempdir().unwrap(); + let parent = directory.path().join(parent_name); + std::fs::create_dir(&parent).unwrap(); + let timestamps: Vec<_> = (0..12) + .map(|frame| Duration::from_nanos(frame * 1_000_000_000 / 30)) + .collect(); + let first = encode_test_mp4(&parent, ×tamps); + let second = parent.join("second.mp4"); + std::fs::copy(&first, &second).unwrap(); + let output = parent.join("combined.mp4"); + concatenate_video_fragments(&[first, second], &output).unwrap(); + let mut input = ffmpeg::format::input(&output).unwrap(); + assert_eq!(input.packets().count(), 24); + assert!(!output.with_extension("concat.txt").exists()); + + let first = encode_test_audio(&parent.join("audio-input")); + let second = parent.join("audio-input/second.m4a"); + std::fs::copy(&first, &second).unwrap(); + let output = parent.join("combined.ogg"); + concatenate_audio_to_ogg(&[first, second], &output).unwrap(); + let input = ffmpeg::format::input(&output).unwrap(); + let duration = input.duration() as f64 / f64::from(ffmpeg::ffi::AV_TIME_BASE); + assert!((0.35..0.6).contains(&duration), "duration={duration}"); + assert!(!output.with_extension("concat.txt").exists()); + } + + #[test] + fn concat_audio_and_video_with_quoted_parent_directories() { + assert_concat_roundtrip("recording 'quoted' λ"); + } + + #[cfg(unix)] + #[test] + fn concat_audio_and_video_with_newline_parent_directories() { + assert_concat_roundtrip("recording\n"); + } /// Encodes a small mp4 whose frames are stamped with the given /// timestamps, mirroring how recordings reach disk. diff --git a/crates/recording/Cargo.toml b/crates/recording/Cargo.toml index 92732a0569d..75a2488cc63 100644 --- a/crates/recording/Cargo.toml +++ b/crates/recording/Cargo.toml @@ -102,7 +102,7 @@ scap-cpal = { path = "../scap-cpal" } [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.11.0", default-features = false, features = ["tokio"] } pipewire = "0.10.0" -x11rb = { version = "0.13.2", features = ["xfixes"] } +x11rb = { version = "0.13.2", features = ["composite", "xfixes"] } [dev-dependencies] tempfile = "3.20.0" diff --git a/crates/recording/src/cursor.rs b/crates/recording/src/cursor.rs index 48f3ef05953..aeeacc01810 100644 --- a/crates/recording/src/cursor.rs +++ b/crates/recording/src/cursor.rs @@ -44,6 +44,93 @@ pub struct IncrementalCaptureOutputs { pub keyboard: Option, } +pub struct CursorCaptureTarget { + pub crop_bounds: CursorCropBounds, + pub display: scap_targets::Display, + #[cfg(target_os = "linux")] + pub window: Option, +} + +#[cfg(target_os = "linux")] +struct X11WindowCursor { + connection: x11rb::rust_connection::RustConnection, + window: u32, +} + +#[cfg(target_os = "linux")] +impl X11WindowCursor { + fn new(id: &scap_targets::WindowId) -> anyhow::Result { + let (connection, _) = x11rb::connect(None)?; + Ok(Self { + connection, + window: id.to_string().parse()?, + }) + } + + fn position(&self) -> Option<(f64, f64)> { + use x11rb::protocol::xproto::ConnectionExt as _; + + let geometry = self + .connection + .get_geometry(self.window) + .ok()? + .reply() + .ok()?; + let pointer = self + .connection + .query_pointer(self.window) + .ok()? + .reply() + .ok()?; + if !pointer.same_screen { + return None; + } + normalized_window_cursor( + pointer.win_x, + pointer.win_y, + geometry.width, + geometry.height, + ) + } +} + +#[cfg(target_os = "linux")] +fn normalized_window_cursor(x: i16, y: i16, width: u16, height: u16) -> Option<(f64, f64)> { + (width != 0 && height != 0).then(|| { + ( + f64::from(x) / f64::from(width), + f64::from(y) / f64::from(height), + ) + }) +} + +#[cfg(all(test, target_os = "linux"))] +mod window_cursor_tests { + use super::normalized_window_cursor; + + #[test] + fn window_local_cursor_coordinates_follow_resized_content() { + assert_eq!( + normalized_window_cursor(300, 150, 600, 300), + Some((0.5, 0.5)) + ); + assert_eq!( + normalized_window_cursor(300, 150, 1200, 600), + Some((0.25, 0.25)) + ); + assert_eq!( + normalized_window_cursor(-60, 330, 600, 300), + Some((-0.1, 1.1)) + ); + } + + #[test] + fn empty_windows_cannot_produce_cursor_coordinates() { + assert_eq!(normalized_window_cursor(1, 1, 0, 300), None); + assert_eq!(normalized_window_cursor(1, 1, 600, 0), None); + } +} + impl CursorActor { pub fn stop(&mut self) { drop(self.stop.take()); @@ -207,8 +294,7 @@ fn keycode_to_string(key: &device_query::Keycode) -> (String, String) { #[tracing::instrument(name = "cursor", skip_all)] pub fn spawn_cursor_recorder( - crop_bounds: CursorCropBounds, - display: scap_targets::Display, + target: CursorCaptureTarget, cursors_dir: PathBuf, prev_cursors: Cursors, next_cursor_id: u32, @@ -244,6 +330,16 @@ pub fn spawn_cursor_recorder( let stop_token_child = stop_token.child_token(); let thread = std::thread::spawn(move || { + let crop_bounds = target.crop_bounds; + let display = target.display; + #[cfg(target_os = "linux")] + let window_cursor = target.window.as_ref().and_then(|id| { + X11WindowCursor::new(id) + .inspect_err(|error| tracing::error!(%error, "X11 window cursor setup failed")) + .ok() + }); + #[cfg(target_os = "linux")] + let mut last_window_position = None; let device_state = DeviceState::new(); let mut last_mouse_state = device_state.get_mouse(); let mut last_keys: Vec = device_state.get_keys(); @@ -285,6 +381,15 @@ pub fn spawn_cursor_recorder( if position_changed { last_position = position; } + #[cfg(target_os = "linux")] + let window_position = window_cursor.as_ref().and_then(X11WindowCursor::position); + #[cfg(target_os = "linux")] + let position_changed = position_changed + || (target.window.is_some() && window_position != last_window_position); + #[cfg(target_os = "linux")] + { + last_window_position = window_position; + } let cursor_id = if let Some(data) = get_cursor_data() { let hash_bytes = Sha256::digest(&data.image); @@ -337,15 +442,22 @@ pub fn spawn_cursor_recorder( let cropped_norm_pos = position .relative_to_display(display) .and_then(|p| p.normalize()) - .map(|p| p.with_crop(crop_bounds)); + .map(|p| p.with_crop(crop_bounds)) + .map(|p| (p.x(), p.y())); + #[cfg(target_os = "linux")] + let cropped_norm_pos = if target.window.is_some() { + window_position + } else { + cropped_norm_pos + }; - if let Some(pos) = cropped_norm_pos { + if let Some((x, y)) = cropped_norm_pos { let mouse_event = CursorMoveEvent { active_modifiers: vec![], cursor_id: cursor_id.clone(), time_ms: elapsed, - x: pos.x(), - y: pos.y(), + x, + y, }; response.moves.push(mouse_event); } diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index 4b36f968b1c..c75ca22372a 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -1,5 +1,8 @@ #[cfg(target_os = "macos")] use crate::SendableShareableContent; +#[cfg(target_os = "linux")] +mod linux_camera; + use crate::{ RecordingBaseInputs, capture_pipeline::{ @@ -307,15 +310,27 @@ pub struct CompletedRecording { pub health: crate::RecordingHealth, } +struct ScreenPipelineInput { + source: crate::sources::screen_capture::VideoSourceConfig, + info: VideoInfo, + #[cfg(target_os = "linux")] + camera_feed: Option>, +} + async fn create_pipeline( content_dir: PathBuf, - screen_capture: crate::sources::screen_capture::VideoSourceConfig, - screen_info: cap_media_info::VideoInfo, + screen: ScreenPipelineInput, mic_feed: Option>, system_audio_source: Option, max_output_size: Option, start_time: Timestamps, ) -> anyhow::Result { + let ScreenPipelineInput { + source: screen_capture, + info: screen_info, + #[cfg(target_os = "linux")] + camera_feed, + } = screen; let output_resolution = max_output_size .map(|max_output_size| { clamp_size( @@ -343,6 +358,7 @@ async fn create_pipeline( let segment_tx_for_video = segment_channel.as_ref().map(|(tx, _)| tx.clone()); + #[cfg(not(target_os = "linux"))] let video = ScreenCaptureMethod::make_instant_segmented_video_pipeline( screen_capture, segments_dir.clone(), @@ -352,11 +368,42 @@ async fn create_pipeline( ) .await?; + #[cfg(target_os = "linux")] + let video = if let Some(camera_feed) = camera_feed { + OutputPipeline::builder(segments_dir.clone()) + .with_video::(linux_camera::Config { + screen_capture, + camera_feed, + }) + .with_timestamps(start_time) + .build::(crate::ffmpeg::SegmentedVideoMuxerConfig { + segment_duration: std::time::Duration::from_secs(2), + preset: cap_enc_ffmpeg::h264::H264Preset::Ultrafast, + output_size: Some(output_resolution), + shared_pause_state: None, + segment_tx: segment_tx_for_video, + }) + .await? + } else { + ScreenCaptureMethod::make_instant_segmented_video_pipeline( + screen_capture, + segments_dir.clone(), + output_resolution, + start_time, + segment_tx_for_video, + ) + .await? + }; + let has_audio = mic_feed.is_some() || system_audio_source.is_some(); let audio = if has_audio { let audio_dir = content_dir.join("audio"); let mut builder = output_pipeline::OutputPipeline::builder(audio_dir.clone()).with_timestamps(start_time); + #[cfg(target_os = "linux")] + { + builder = builder.with_audio_anchor(output_pipeline::AudioAnchor::PipelineEpoch); + } if let Some(sys_audio) = system_audio_source { builder = builder @@ -413,6 +460,8 @@ pub struct ActorBuilder { system_audio: bool, mic_feed: Option>, camera_feed: Option>, + #[cfg(target_os = "linux")] + composite_camera: bool, max_output_size: Option, max_fps: u32, #[cfg(target_os = "macos")] @@ -427,6 +476,8 @@ impl ActorBuilder { system_audio: false, mic_feed: None, camera_feed: None, + #[cfg(target_os = "linux")] + composite_camera: false, max_output_size: None, max_fps: crate::defaults::DEFAULT_INSTANT_MODE_FPS, #[cfg(target_os = "macos")] @@ -457,6 +508,12 @@ impl ActorBuilder { self } + #[cfg(target_os = "linux")] + pub fn with_linux_camera_composition(mut self) -> Self { + self.composite_camera = true; + self + } + pub fn with_max_fps(mut self, max_fps: u32) -> Self { self.max_fps = max_fps.clamp(1, 120); self @@ -472,7 +529,7 @@ impl ActorBuilder { self, #[cfg(target_os = "macos")] shareable_content: Option, ) -> anyhow::Result { - spawn_instant_recording_actor( + spawn_instant_recording_actor_inner( self.output_path, RecordingBaseInputs { capture_target: self.capture_target, @@ -486,17 +543,37 @@ impl ActorBuilder { }, self.max_output_size, self.max_fps, + #[cfg(target_os = "linux")] + self.composite_camera, ) .await } } -#[tracing::instrument("instant_recording", skip_all)] pub async fn spawn_instant_recording_actor( recording_dir: PathBuf, inputs: RecordingBaseInputs, max_output_size: Option, max_fps: u32, +) -> anyhow::Result { + spawn_instant_recording_actor_inner( + recording_dir, + inputs, + max_output_size, + max_fps, + #[cfg(target_os = "linux")] + false, + ) + .await +} + +#[tracing::instrument("instant_recording", skip_all)] +async fn spawn_instant_recording_actor_inner( + recording_dir: PathBuf, + inputs: RecordingBaseInputs, + max_output_size: Option, + max_fps: u32, + #[cfg(target_os = "linux")] composite_camera: bool, ) -> anyhow::Result { ensure_dir(&recording_dir)?; @@ -653,13 +730,22 @@ pub async fn spawn_instant_recording_actor( debug!("screen capture: {screen_source:#?}"); + #[cfg(not(target_os = "linux"))] let screen_info = screen_source.info(); let (screen_capture, system_audio_source) = screen_source.to_sources().await?; + #[cfg(target_os = "linux")] + let screen_info = screen_capture.video_info(); + #[cfg(target_os = "linux")] + let timestamps = Timestamps::now(); let pipeline = create_pipeline( content_dir.clone(), - screen_capture, - screen_info, + ScreenPipelineInput { + source: screen_capture, + info: screen_info, + #[cfg(target_os = "linux")] + camera_feed: inputs.camera_feed.clone().filter(|_| composite_camera), + }, inputs.mic_feed.clone(), system_audio_source, max_output_size, @@ -802,6 +888,8 @@ mod tests { #[test] fn test_clamp_size_16_9_ish_landscape() { + assert_eq!(clamp_size((2880, 1800), (1920, 1080)), (1920, 1200)); + // Test 16:9 aspect ratio (boundary case) let result = clamp_size((1920, 1080), (1920, 1080)); assert_eq!(result, (1920, 1080)); diff --git a/crates/recording/src/instant_recording/linux_camera.rs b/crates/recording/src/instant_recording/linux_camera.rs new file mode 100644 index 00000000000..5502e874c46 --- /dev/null +++ b/crates/recording/src/instant_recording/linux_camera.rs @@ -0,0 +1,811 @@ +use crate::{ + feeds::camera::{self, CameraFeedLock}, + ffmpeg::FFmpegVideoFrame, + output_pipeline::{ + self, SetupCtx, StallSendOutcome, VideoSource, send_with_stall_budget_futures, + }, + sources::screen_capture, +}; +use anyhow::{Context, anyhow}; +use cap_media_info::VideoInfo; +use cap_timestamp::Timestamp; +use ffmpeg::format::Pixel; +use futures::{FutureExt, StreamExt, channel::mpsc}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use tokio_util::sync::CancellationToken; + +const CAMERA_ATTACH_TIMEOUT: Duration = Duration::from_millis(1500); +const CAMERA_STALE_AFTER: Duration = Duration::from_secs(1); +const SCREEN_CHANNEL_CAPACITY: usize = 4; + +pub(super) struct Config { + pub(super) screen_capture: screen_capture::VideoSourceConfig, + pub(super) camera_feed: Arc, +} + +pub(super) struct CameraCompositeSource { + inner: screen_capture::VideoSource, + info: VideoInfo, + _camera_feed: Arc, +} + +impl VideoSource for CameraCompositeSource { + type Config = Config; + type Frame = FFmpegVideoFrame; + + async fn setup( + config: Self::Config, + video_tx: mpsc::Sender, + ctx: &mut SetupCtx, + ) -> anyhow::Result + where + Self: Sized, + { + let (camera_tx, camera_rx) = flume::bounded(1); + tokio::time::timeout( + CAMERA_ATTACH_TIMEOUT, + config.camera_feed.ask(camera::AddSender(camera_tx)), + ) + .await + .map_err(|_| { + anyhow!("Camera compositor timed out attaching to feed after {CAMERA_ATTACH_TIMEOUT:?}") + })? + .map_err(|error| anyhow!("Camera compositor failed to attach to feed: {error}"))?; + + let (screen_tx, screen_rx) = mpsc::channel(SCREEN_CHANNEL_CAPACITY); + let inner = screen_capture::VideoSource::setup(config.screen_capture, screen_tx, ctx) + .await + .context("screen source setup for camera compositor")?; + let info = inner.video_info(); + let stop_token = ctx.stop_token(); + let health_tx = ctx.health_tx().clone(); + ctx.tasks() + .spawn_thread("linux-instant-camera-compositor", move || { + compositor_thread( + screen_rx, + Some(camera_rx), + video_tx, + stop_token, + health_tx, + info, + ) + }); + + Ok(Self { + inner, + info, + _camera_feed: config.camera_feed, + }) + } + + fn start(&mut self) -> futures::future::BoxFuture<'_, anyhow::Result<()>> { + self.inner.start() + } + + fn video_info(&self) -> VideoInfo { + self.info + } + + fn stop(&mut self) -> futures::future::BoxFuture<'_, anyhow::Result<()>> { + self.inner.stop() + } +} + +fn compositor_thread( + screen_rx: mpsc::Receiver, + camera_rx: Option>, + mut video_tx: mpsc::Sender, + stop_token: CancellationToken, + health_tx: output_pipeline::HealthSender, + info: VideoInfo, +) -> anyhow::Result<()> { + futures::executor::block_on(async move { + let mut screen_rx = screen_rx.fuse(); + let mut camera_closed = camera_rx.is_none(); + let camera_started_at = Instant::now(); + let mut camera_seen = camera_rx.is_none(); + let mut latest_camera: Option<(FFmpegVideoFrame, Instant)> = None; + let mut compositor = Compositor::new(info); + let mut logged_compose_failure = false; + let mut logged_camera_disconnect = false; + let mut logged_camera_stale = false; + + loop { + let stop = stop_token.cancelled().fuse(); + let active_camera = camera_rx.as_ref().filter(|_| !camera_closed); + let camera = async { + match active_camera { + Some(receiver) => receiver.recv_async().await, + None => std::future::pending().await, + } + } + .fuse(); + let screen = screen_rx.next().fuse(); + futures::pin_mut!(stop, camera, screen); + + futures::select! { + _ = stop => break, + camera_result = camera => match camera_result { + Ok(frame) => { + camera_seen = true; + let received_at = Instant::now(); + let freshness_anchor = camera_freshness_anchor(&frame, received_at); + latest_camera = Some((frame, freshness_anchor)); + } + Err(_) => { + camera_closed = true; + latest_camera = None; + if !logged_camera_disconnect { + tracing::warn!("Camera feed disconnected; continuing with screen-only output"); + output_pipeline::emit_health( + &health_tx, + output_pipeline::PipelineHealthEvent::DeviceLost { + subsystem: "camera".to_string(), + }, + ); + logged_camera_disconnect = true; + } + } + }, + screen_result = screen => { + let Some(screen) = screen_result else { break }; + + if let Some(camera_rx) = camera_rx.as_ref() { + loop { + match camera_rx.try_recv() { + Ok(frame) => { + camera_seen = true; + let received_at = Instant::now(); + let freshness_anchor = + camera_freshness_anchor(&frame, received_at); + latest_camera = Some((frame, freshness_anchor)); + } + Err(flume::TryRecvError::Empty) => break, + Err(flume::TryRecvError::Disconnected) => { + camera_closed = true; + latest_camera = None; + if !logged_camera_disconnect { + tracing::warn!("Camera feed disconnected; continuing with screen-only output"); + output_pipeline::emit_health( + &health_tx, + output_pipeline::PipelineHealthEvent::DeviceLost { + subsystem: "camera".to_string(), + }, + ); + logged_camera_disconnect = true; + } + break; + } + } + } + } + + let mut clear_latest_camera = false; + let now = Instant::now(); + let camera_is_stale = latest_camera + .as_ref() + .is_some_and(|(_, received_at)| !camera_is_fresh(*received_at, now)); + let camera_waiting_for_first_frame = !camera_closed + && !camera_seen + && now.saturating_duration_since(camera_started_at) > CAMERA_STALE_AFTER; + if (camera_is_stale || camera_waiting_for_first_frame) && !logged_camera_stale { + tracing::warn!( + stale_after_ms = CAMERA_STALE_AFTER.as_millis(), + "Camera feed is stale; continuing with screen-only output" + ); + output_pipeline::emit_health( + &health_tx, + output_pipeline::PipelineHealthEvent::Stalled { + source: "camera-compositor".to_string(), + waited_ms: CAMERA_STALE_AFTER.as_millis() as u64, + }, + ); + logged_camera_stale = true; + } + if !camera_is_stale && !camera_waiting_for_first_frame { + logged_camera_stale = false; + } + let output = match latest_camera + .as_ref() + .filter(|(_, received_at)| camera_is_fresh(*received_at, now)) + { + Some((camera, _)) => match compositor.compose(&screen, camera) { + Ok(frame) => frame, + Err(error) => { + clear_latest_camera = true; + if !logged_compose_failure { + tracing::warn!(error = %error, "Camera compositor degraded to screen-only output"); + logged_compose_failure = true; + } + screen + } + }, + None => screen, + }; + if clear_latest_camera { + latest_camera = None; + } + + if matches!( + send_with_stall_budget_futures( + &mut video_tx, + output, + "linux-instant-camera-compositor", + &health_tx, + ), + StallSendOutcome::Disconnected + ) { + break; + } + }, + } + } + + Ok(()) + }) +} + +fn camera_is_fresh(received_at: Instant, now: Instant) -> bool { + now.saturating_duration_since(received_at) <= CAMERA_STALE_AFTER +} + +fn camera_freshness_anchor(frame: &FFmpegVideoFrame, received_at: Instant) -> Instant { + match frame.timestamp { + Timestamp::Instant(captured_at) => captured_at, + _ => received_at, + } +} + +struct Compositor { + info: VideoInfo, + screen_to_rgba: Option, + camera_to_rgba: Option, + rgba_to_screen: Option, +} + +impl Compositor { + fn new(info: VideoInfo) -> Self { + Self { + info, + screen_to_rgba: None, + camera_to_rgba: None, + rgba_to_screen: None, + } + } + + fn compose( + &mut self, + screen: &FFmpegVideoFrame, + camera: &FFmpegVideoFrame, + ) -> anyhow::Result { + let screen_rgba = self.convert_screen(&screen.inner)?; + let rect = overlay_rect(self.info.width, self.info.height); + if rect.2 == 0 { + return Err(anyhow!("screen dimensions are empty")); + } + let camera_rgba = self.convert_camera(&camera.inner, rect.2)?; + let camera_overlay = center_crop_square(&camera_rgba, rect.2)?; + let mut composed = screen_rgba; + blend_rgba(&mut composed, &camera_overlay, rect.0, rect.1)?; + let mut output = self.convert_to_screen(&composed)?; + output.set_pts(screen.inner.pts()); + + Ok(FFmpegVideoFrame { + inner: output, + timestamp: screen.timestamp, + }) + } + + fn convert_screen( + &mut self, + input: &ffmpeg::frame::Video, + ) -> anyhow::Result { + let needs_converter = self.screen_to_rgba.as_ref().is_none_or(|converter| { + converter.source_format != input.format() + || converter.source_width != input.width() + || converter.source_height != input.height() + }); + if needs_converter { + self.screen_to_rgba = Some(RgbaConverter::new( + input.format(), + input.width(), + input.height(), + Pixel::RGBA, + self.info.width, + self.info.height, + )?); + } + self.screen_to_rgba + .as_mut() + .expect("screen converter initialized") + .convert(input) + } + + fn convert_camera( + &mut self, + input: &ffmpeg::frame::Video, + size: u32, + ) -> anyhow::Result { + let (destination_width, destination_height) = + aspect_fill_dimensions(input.width(), input.height(), size)?; + let needs_converter = self.camera_to_rgba.as_ref().is_none_or(|converter| { + converter.source_format != input.format() + || converter.source_width != input.width() + || converter.source_height != input.height() + || converter.destination_width != destination_width + || converter.destination_height != destination_height + }); + if needs_converter { + self.camera_to_rgba = Some(RgbaConverter::new( + input.format(), + input.width(), + input.height(), + Pixel::RGBA, + destination_width, + destination_height, + )?); + } + self.camera_to_rgba + .as_mut() + .expect("camera converter initialized") + .convert(input) + } + + fn convert_to_screen( + &mut self, + input: &ffmpeg::frame::Video, + ) -> anyhow::Result { + if self.rgba_to_screen.is_none() { + self.rgba_to_screen = Some(RgbaConverter::new( + Pixel::RGBA, + self.info.width, + self.info.height, + self.info.pixel_format, + self.info.width, + self.info.height, + )?); + } + self.rgba_to_screen + .as_mut() + .expect("screen output converter initialized") + .convert(input) + } +} + +struct RgbaConverter { + context: ffmpeg::software::scaling::Context, + source_format: ffmpeg::format::Pixel, + source_width: u32, + source_height: u32, + destination_format: ffmpeg::format::Pixel, + destination_width: u32, + destination_height: u32, +} + +impl RgbaConverter { + fn new( + source_format: ffmpeg::format::Pixel, + source_width: u32, + source_height: u32, + destination_format: ffmpeg::format::Pixel, + destination_width: u32, + destination_height: u32, + ) -> anyhow::Result { + if source_width == 0 + || source_height == 0 + || destination_width == 0 + || destination_height == 0 + { + return Err(anyhow!("invalid video dimensions for compositor")); + } + let context = ffmpeg::software::scaling::Context::get( + source_format, + source_width, + source_height, + destination_format, + destination_width, + destination_height, + ffmpeg::software::scaling::Flags::BILINEAR, + )?; + Ok(Self { + context, + source_format, + source_width, + source_height, + destination_format, + destination_width, + destination_height, + }) + } + + fn convert(&mut self, input: &ffmpeg::frame::Video) -> anyhow::Result { + if self.source_format != input.format() + || self.source_width != input.width() + || self.source_height != input.height() + { + let destination_format = self.destination_format; + let destination_width = self.destination_width; + let destination_height = self.destination_height; + *self = Self::new( + input.format(), + input.width(), + input.height(), + destination_format, + destination_width, + destination_height, + )?; + } + let mut output = ffmpeg::frame::Video::empty(); + self.context.run(input, &mut output)?; + output.set_pts(input.pts()); + Ok(output) + } +} + +fn overlay_rect(width: u32, height: u32) -> (usize, usize, u32) { + if width == 0 || height == 0 { + return (0, 0, 0); + } + let short = width.min(height); + let size = ((u64::from(short) * 30) / 100).clamp(1, u64::from(short)) as u32; + let margin = (u64::from(short) * 2 / 100) as u32; + let x = width.saturating_sub(size.saturating_add(margin)); + let y = height.saturating_sub(size.saturating_add(margin)); + (x as usize, y as usize, size) +} + +fn aspect_fill_dimensions(width: u32, height: u32, size: u32) -> anyhow::Result<(u32, u32)> { + if width == 0 || height == 0 || size == 0 { + return Err(anyhow!("invalid dimensions for camera scaling")); + } + if width >= height { + let scaled_width = (u64::from(size) * u64::from(width)).div_ceil(u64::from(height)); + Ok((scaled_width.min(u64::from(u32::MAX)) as u32, size)) + } else { + let scaled_height = (u64::from(size) * u64::from(height)).div_ceil(u64::from(width)); + Ok((size, scaled_height.min(u64::from(u32::MAX)) as u32)) + } +} + +fn center_crop_square( + source: &ffmpeg::frame::Video, + size: u32, +) -> anyhow::Result { + if size == 0 || source.width() == 0 || source.height() == 0 { + return Err(anyhow!("invalid camera dimensions for compositor")); + } + let source_width = source.width() as usize; + let source_height = source.height() as usize; + let crop_size = source_width.min(source_height); + if crop_size < size as usize { + return Err(anyhow!("scaled camera frame is smaller than overlay")); + } + let crop_x = (source_width - size as usize) / 2; + let crop_y = (source_height - size as usize) / 2; + let mut output = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, size, size); + let source_stride = source.stride(0); + let output_stride = output.stride(0); + let source_data = source.data(0); + let output_data = output.data_mut(0); + for y in 0..size as usize { + let source_y = crop_y + y; + for x in 0..size as usize { + let source_x = crop_x + x; + let source_offset = source_y * source_stride + source_x * 4; + let output_offset = y * output_stride + x * 4; + if source_offset + 4 > source_data.len() || output_offset + 4 > output_data.len() { + return Err(anyhow!( + "RGBA camera frame layout is smaller than dimensions" + )); + } + output_data[output_offset..output_offset + 4] + .copy_from_slice(&source_data[source_offset..source_offset + 4]); + } + } + output.set_pts(source.pts()); + Ok(output) +} + +fn blend_rgba( + destination: &mut ffmpeg::frame::Video, + source: &ffmpeg::frame::Video, + x: usize, + y: usize, +) -> anyhow::Result<()> { + if destination.format() != ffmpeg::format::Pixel::RGBA + || source.format() != ffmpeg::format::Pixel::RGBA + { + return Err(anyhow!("RGBA blend requires RGBA frames")); + } + let destination_stride = destination.stride(0); + let source_stride = source.stride(0); + let destination_width = destination.width() as usize; + let destination_height = destination.height() as usize; + let source_width = source.width() as usize; + let source_height = source.height() as usize; + if x.saturating_add(source_width) > destination_width + || y.saturating_add(source_height) > destination_height + { + return Err(anyhow!("camera overlay exceeds screen frame")); + } + let source_data = source.data(0); + let destination_data = destination.data_mut(0); + for row in 0..source_height { + for column in 0..source_width { + let source_offset = row * source_stride + column * 4; + let destination_offset = (y + row) * destination_stride + (x + column) * 4; + let alpha = u16::from(source_data[source_offset + 3]); + let inverse_alpha = 255 - alpha; + for channel in 0..3 { + destination_data[destination_offset + channel] = + ((u16::from(source_data[source_offset + channel]) * alpha + + u16::from(destination_data[destination_offset + channel]) + * inverse_alpha + + 127) + / 255) as u8; + } + destination_data[destination_offset + 3] = 255; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::SinkExt; + + #[test] + fn camera_freshness_expires_after_one_second() { + let now = Instant::now(); + assert!(!camera_is_fresh( + now, + now + CAMERA_STALE_AFTER + Duration::from_millis(1) + )); + assert!(camera_is_fresh(now, now + CAMERA_STALE_AFTER)); + } + + #[test] + fn camera_freshness_uses_capture_time_for_queued_frames() { + ffmpeg::init().expect("FFmpeg initializes"); + let now = Instant::now(); + let captured_at = now + .checked_sub(CAMERA_STALE_AFTER + Duration::from_millis(1)) + .expect("test instant has enough history"); + let frame = FFmpegVideoFrame { + inner: ffmpeg::frame::Video::new(Pixel::RGBA, 1, 1), + timestamp: Timestamp::Instant(captured_at), + }; + assert!(!camera_is_fresh(camera_freshness_anchor(&frame, now), now)); + assert_eq!(camera_freshness_anchor(&frame, now), captured_at); + } + + #[test] + fn overlay_rect_is_bottom_right_and_safe_for_tiny_frames() { + assert_eq!(overlay_rect(100, 50), (84, 34, 15)); + assert_eq!(overlay_rect(1, 1), (0, 0, 1)); + assert_eq!(overlay_rect(0, 50), (0, 0, 0)); + assert_eq!(overlay_rect(100, 0), (0, 0, 0)); + } + + #[test] + fn center_crop_square_preserves_center_pixel() { + ffmpeg::init().expect("FFmpeg initializes"); + let mut source = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, 6, 4); + source.data_mut(0).fill(0); + let stride = source.stride(0); + let center = 2 * stride + 2 * 4; + source.data_mut(0)[center..center + 4].copy_from_slice(&[1, 2, 3, 255]); + let cropped = center_crop_square(&source, 2).expect("crop succeeds"); + assert_eq!( + &cropped.data(0)[cropped.stride(0)..cropped.stride(0) + 4], + &[1, 2, 3, 255] + ); + } + + #[test] + fn compositor_converts_nv12_camera_without_mutating_screen_and_preserves_pts() { + ffmpeg::init().expect("FFmpeg initializes"); + let timestamp = Timestamp::Instant(Instant::now()); + let info = VideoInfo::from_raw_ffmpeg(ffmpeg::format::Pixel::BGRA, 8, 8, 30); + let mut screen = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, 8, 8); + for pixel in screen.data_mut(0).chunks_exact_mut(4) { + pixel.copy_from_slice(&[0, 255, 0, 255]); + } + screen.set_pts(Some(1234)); + let original = screen.data(0).to_vec(); + let mut camera = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::NV12, 4, 4); + camera.data_mut(0).fill(235); + camera.data_mut(1).fill(128); + let camera_before = (camera.data(0).to_vec(), camera.data(1).to_vec()); + let screen_frame = FFmpegVideoFrame { + inner: screen, + timestamp, + }; + let camera_frame = FFmpegVideoFrame { + inner: camera, + timestamp, + }; + let mut compositor = Compositor::new(info); + let output = compositor + .compose(&screen_frame, &camera_frame) + .expect("composition succeeds"); + assert_eq!(screen_frame.inner.data(0), original.as_slice()); + assert_eq!(camera_frame.inner.data(0), camera_before.0.as_slice()); + assert_eq!(camera_frame.inner.data(1), camera_before.1.as_slice()); + assert_eq!(output.inner.pts(), Some(1234)); + assert!(matches!( + (output.timestamp, timestamp), + (Timestamp::Instant(actual), Timestamp::Instant(expected)) if actual == expected + )); + assert_ne!( + &output.inner.data(0)[output.inner.stride(0) * 7 + 7 * 4..][..3], + &[0, 255, 0] + ); + } + + #[test] + fn compositor_converts_nv12_screen_and_preserves_input() { + ffmpeg::init().expect("FFmpeg initializes"); + let timestamp = Timestamp::Instant(Instant::now()); + let info = VideoInfo::from_raw_ffmpeg(Pixel::NV12, 8, 8, 30); + let mut screen = ffmpeg::frame::Video::new(Pixel::NV12, 8, 8); + screen.data_mut(0).fill(16); + screen.data_mut(1).fill(128); + screen.set_pts(Some(4321)); + let original_y = screen.data(0).to_vec(); + let mut camera = ffmpeg::frame::Video::new(Pixel::BGRA, 4, 4); + for pixel in camera.data_mut(0).chunks_exact_mut(4) { + pixel.copy_from_slice(&[0, 0, 255, 255]); + } + let screen_frame = FFmpegVideoFrame { + inner: screen, + timestamp, + }; + let camera_frame = FFmpegVideoFrame { + inner: camera, + timestamp, + }; + let mut compositor = Compositor::new(info); + let output = compositor + .compose(&screen_frame, &camera_frame) + .expect("composition succeeds"); + assert_eq!(screen_frame.inner.data(0), original_y.as_slice()); + assert_eq!(output.inner.format(), Pixel::NV12); + assert_eq!(output.inner.pts(), Some(4321)); + assert_ne!(output.inner.data(0)[output.inner.stride(0) * 7 + 7], 16); + } + + #[tokio::test] + async fn compositor_passes_screen_without_camera_and_closes_on_cancel() { + ffmpeg::init().expect("FFmpeg initializes"); + let timestamp = Timestamp::Instant(Instant::now()); + let info = VideoInfo::from_raw_ffmpeg(Pixel::BGRA, 4, 4, 30); + let mut inner = ffmpeg::frame::Video::new(Pixel::BGRA, 4, 4); + inner.data_mut(0).fill(17); + inner.set_pts(Some(77)); + let expected = inner.data(0).to_vec(); + let frame = FFmpegVideoFrame { inner, timestamp }; + let (mut screen_tx, screen_rx) = mpsc::channel(1); + let (video_tx, mut video_rx) = mpsc::channel(1); + let (health_tx, _health_rx) = tokio::sync::mpsc::channel(1); + let stop_token = CancellationToken::new(); + let _stop_guard = stop_token.clone().drop_guard(); + let worker_stop = stop_token.clone(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = compositor_thread(screen_rx, None, video_tx, worker_stop, health_tx, info); + let _ = done_tx.send(result); + }); + + screen_tx.send(frame).await.expect("screen frame sent"); + let output = tokio::time::timeout(Duration::from_secs(2), video_rx.next()) + .await + .expect("screen-only output arrives promptly") + .expect("screen-only frame emitted"); + assert_eq!(output.inner.data(0), expected.as_slice()); + assert_eq!(output.inner.pts(), Some(77)); + assert!(matches!( + (output.timestamp, timestamp), + (Timestamp::Instant(actual), Timestamp::Instant(expected)) if actual == expected + )); + + stop_token.cancel(); + drop(screen_tx); + done_rx + .recv_timeout(Duration::from_secs(2)) + .expect("compositor exits promptly") + .expect("compositor stopped"); + worker.join().expect("compositor thread joined"); + assert!(video_rx.next().await.is_none()); + } + + #[tokio::test] + async fn compositor_reports_silent_attached_camera_and_keeps_screen_output() { + ffmpeg::init().expect("FFmpeg initializes"); + let timestamp = Timestamp::Instant(Instant::now()); + let info = VideoInfo::from_raw_ffmpeg(Pixel::BGRA, 4, 4, 30); + let mut first_inner = ffmpeg::frame::Video::new(Pixel::BGRA, 4, 4); + first_inner.data_mut(0).fill(17); + first_inner.set_pts(Some(77)); + let first_frame = FFmpegVideoFrame { + inner: first_inner, + timestamp, + }; + let mut second_inner = ffmpeg::frame::Video::new(Pixel::BGRA, 4, 4); + second_inner.data_mut(0).fill(23); + second_inner.set_pts(Some(88)); + let second_frame = FFmpegVideoFrame { + inner: second_inner, + timestamp, + }; + let (mut screen_tx, screen_rx) = mpsc::channel(1); + let (camera_tx, camera_rx) = flume::bounded(1); + let (video_tx, mut video_rx) = mpsc::channel(1); + let (health_tx, mut health_rx) = tokio::sync::mpsc::channel(4); + let stop_token = CancellationToken::new(); + let _stop_guard = stop_token.clone().drop_guard(); + let worker_stop = stop_token.clone(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = compositor_thread( + screen_rx, + Some(camera_rx), + video_tx, + worker_stop, + health_tx, + info, + ); + let _ = done_tx.send(result); + }); + + screen_tx + .send(first_frame) + .await + .expect("first screen frame sent"); + let first_output = tokio::time::timeout(Duration::from_secs(2), video_rx.next()) + .await + .expect("first screen-only output arrives promptly") + .expect("first screen-only frame emitted"); + assert_eq!(first_output.inner.pts(), Some(77)); + + tokio::time::sleep(CAMERA_STALE_AFTER + Duration::from_millis(20)).await; + screen_tx + .send(second_frame) + .await + .expect("second screen frame sent"); + let second_output = tokio::time::timeout(Duration::from_secs(2), video_rx.next()) + .await + .expect("silent-camera screen output arrives promptly") + .expect("silent-camera screen frame emitted"); + assert_eq!(second_output.inner.pts(), Some(88)); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(2), health_rx.recv()) + .await + .expect("silent camera reports its health promptly"), + Some(output_pipeline::PipelineHealthEvent::Stalled { source, .. }) + if source == "camera-compositor" + )); + + stop_token.cancel(); + drop(camera_tx); + drop(screen_tx); + done_rx + .recv_timeout(Duration::from_secs(2)) + .expect("compositor exits promptly") + .expect("compositor stopped"); + worker.join().expect("compositor thread joined"); + assert!(video_rx.next().await.is_none()); + } + + #[test] + fn blend_rejects_overlay_outside_screen_bounds() { + ffmpeg::init().expect("FFmpeg initializes"); + let mut destination = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, 2, 2); + let source = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, 2, 2); + assert!(blend_rgba(&mut destination, &source, 1, 0).is_err()); + } +} diff --git a/crates/recording/src/output_pipeline/core.rs b/crates/recording/src/output_pipeline/core.rs index b02ec139641..75b4cbf9ccd 100644 --- a/crates/recording/src/output_pipeline/core.rs +++ b/crates/recording/src/output_pipeline/core.rs @@ -2141,6 +2141,7 @@ fn spawn_video_encoder, TVideo: V let mut source_clock = SourceClockState::new("video"); let mut dropped_during_pause: u64 = 0; let mut last_frame = None; + let mut first_frame_offset = None; let res = stop_token .run_until_cancelled(async { @@ -2207,6 +2208,10 @@ fn spawn_video_encoder, TVideo: V } }; + if first_frame_offset.is_none() { + first_frame_offset = Some(remap.duration().saturating_sub(total_pause_duration)); + } + if anomaly_tracker.take_resync_flag() { info!( raw_duration_ms = raw_duration.as_millis(), @@ -2320,6 +2325,12 @@ fn spawn_video_encoder, TVideo: V } }; + if first_frame_offset.is_none() { + first_frame_offset = Some( + remap.duration().saturating_sub(shared_pause.total_pause_duration()), + ); + } + let _ = anomaly_tracker.take_resync_flag(); let duration = drift_tracker.calculate_timestamp(raw_duration, wall_clock_elapsed); @@ -2359,6 +2370,8 @@ fn spawn_video_encoder, TVideo: V } let final_pause_duration = shared_pause.total_pause_duration(); + // Video PTS begin at the first frame, which may arrive minutes after a portal request starts. + let video_stopped_at = stopped_at.saturating_sub(first_frame_offset.unwrap_or(stopped_at)); if was_cancelled && !shared_pause.check().0 @@ -2366,7 +2379,7 @@ fn spawn_video_encoder, TVideo: V && let Some((_, last_timestamp)) = timestamp_span.get() && let Some(final_timestamp) = static_video_tail_timestamp( last_timestamp, - stopped_at, + video_stopped_at, Duration::from_nanos(frame_duration_ns), ) { @@ -5165,10 +5178,10 @@ mod tests { } } - #[tokio::test] - async fn static_capture_finishes_with_two_nominally_spaced_frames() { + async fn record_static_capture(startup_delay: Duration) -> (Vec, Duration) { let temp_dir = tempfile::tempdir().unwrap(); let clock = Timestamps::now(); + tokio::time::sleep(startup_delay).await; let (sender, receiver) = flume::bounded(4); let sent = Arc::new(std::sync::Mutex::new(Vec::new())); let pipeline = OutputPipeline::builder(temp_dir.path().join("static.mp4")) @@ -5181,16 +5194,24 @@ mod tests { .await .unwrap(); + let first_frame_at = Instant::now(); sender .send_async(StaticFrame { - timestamp: Timestamp::Instant(clock.instant()), + timestamp: Timestamp::Instant(first_frame_at), }) .await .unwrap(); tokio::time::sleep(Duration::from_millis(180)).await; + let capture_duration = first_frame_at.elapsed(); pipeline.stop().await.unwrap(); let timestamps = sent.lock().unwrap().clone(); + (timestamps, capture_duration) + } + + #[tokio::test] + async fn static_capture_finishes_with_two_nominally_spaced_frames() { + let (timestamps, _) = record_static_capture(Duration::ZERO).await; assert_eq!(timestamps.len(), 3); let final_frame = timestamps[timestamps.len() - 1]; let penultimate_frame = timestamps[timestamps.len() - 2]; @@ -5200,6 +5221,16 @@ mod tests { ); assert!(final_frame > Duration::from_millis(100)); } + + #[tokio::test] + async fn static_tail_excludes_source_initialization_delay() { + let (timestamps, capture_duration) = + record_static_capture(Duration::from_secs(1)).await; + assert_eq!(timestamps.first(), Some(&Duration::ZERO)); + let final_frame = *timestamps.last().unwrap(); + assert!(final_frame > capture_duration.saturating_sub(Duration::from_millis(100))); + assert!(final_frame < capture_duration.saturating_add(Duration::from_millis(250))); + } } mod blocking_thread_finish { diff --git a/crates/recording/src/screenshot.rs b/crates/recording/src/screenshot.rs index 11f1bfb9b56..f17f7205042 100644 --- a/crates/recording/src/screenshot.rs +++ b/crates/recording/src/screenshot.rs @@ -1,6 +1,8 @@ use crate::sources::screen_capture::ScreenCaptureTarget; #[cfg(target_os = "linux")] -use crate::sources::screen_capture::{X11Grabber, X11InputConfig, x11_capture_rect}; +use crate::sources::screen_capture::{ + X11Grabber, X11InputConfig, prefers_wayland_portal, x11_capture_rect, +}; #[cfg(target_os = "macos")] use anyhow::Context; #[cfg(target_os = "linux")] @@ -819,7 +821,7 @@ fn try_fast_capture(target: &ScreenCaptureTarget) -> Option { #[cfg(target_os = "linux")] pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result { - if is_pure_wayland_session() { + if uses_wayland_portal() { let image = capture_screenshot_wayland(&target).await?; return Ok(finalize_screenshot(image, &target)); } @@ -829,19 +831,8 @@ pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result bool { - is_pure_wayland_environment( - std::env::var_os("WAYLAND_DISPLAY").as_deref(), - std::env::var_os("DISPLAY").as_deref(), - ) -} - -#[cfg(target_os = "linux")] -fn is_pure_wayland_environment( - wayland_display: Option<&std::ffi::OsStr>, - x11_display: Option<&std::ffi::OsStr>, -) -> bool { - wayland_display.is_some() && x11_display.is_none() +pub fn uses_wayland_portal() -> bool { + prefers_wayland_portal() } #[cfg(target_os = "linux")] @@ -1437,6 +1428,12 @@ fn capture_screenshot_x11_blocking(target: &ScreenCaptureTarget) -> anyhow::Resu let (display_name, x, y, width, height) = linux_capture_geometry(target)?; let config = X11InputConfig { display_name, + window_id: match target { + ScreenCaptureTarget::Window { id } => { + Some(id.to_string().parse().context("Invalid X11 window ID")?) + } + _ => None, + }, x, y, width, @@ -1477,34 +1474,17 @@ fn linux_capture_geometry( ScreenCaptureTarget::Window { id } => { let window = scap_targets::Window::from_id(id).ok_or_else(|| anyhow!("Window not found"))?; - let display = window - .display() - .ok_or_else(|| anyhow!("Window display unavailable"))?; - let display_position = display - .raw_handle() - .physical_position() - .ok_or_else(|| anyhow!("Display position unavailable"))?; - let display_size = display - .physical_size() - .ok_or_else(|| anyhow!("Display size unavailable"))?; let bounds = window .raw_handle() .physical_bounds() .ok_or_else(|| anyhow!("Window bounds unavailable"))?; - let crop = ( - bounds.position().x() - display_position.x(), - bounds.position().y() - display_position.y(), - bounds.size().width(), - bounds.size().height(), - ); - let (x, y, width, height) = x11_capture_rect( - display_position.x(), - display_position.y(), - display_size.width(), - display_size.height(), - Some(crop), - )?; - Ok((display_name, x, y, width, height)) + Ok(( + display_name, + 0, + 0, + bounds.size().width() as u32, + bounds.size().height() as u32, + )) } ScreenCaptureTarget::Area { screen, bounds } => { let display = scap_targets::Display::from_id(screen) @@ -1541,7 +1521,6 @@ fn linux_capture_geometry( mod wayland_screenshot_tests { use super::*; use scap_targets::bounds::{LogicalBounds, LogicalPosition, LogicalSize}; - use std::ffi::OsStr; fn area_target(x: f64, y: f64, width: f64, height: f64) -> ScreenCaptureTarget { ScreenCaptureTarget::Area { @@ -1550,20 +1529,6 @@ mod wayland_screenshot_tests { } } - #[test] - fn pure_wayland_requires_wayland_socket_without_x11_display() { - assert!(is_pure_wayland_environment( - Some(OsStr::new("wayland-1")), - None - )); - assert!(!is_pure_wayland_environment( - Some(OsStr::new("wayland-1")), - Some(OsStr::new(":99")) - )); - assert!(!is_pure_wayland_environment(None, None)); - assert!(!is_pure_wayland_environment(None, Some(OsStr::new(":99")))); - } - #[test] fn only_local_file_screenshot_uris_are_accepted() { assert!(is_local_wayland_screenshot_uri("file", None)); diff --git a/crates/recording/src/sources/screen_capture/linux.rs b/crates/recording/src/sources/screen_capture/linux.rs index bf9291e4c74..fee1e5befe5 100644 --- a/crates/recording/src/sources/screen_capture/linux.rs +++ b/crates/recording/src/sources/screen_capture/linux.rs @@ -1,3 +1,4 @@ +use super::cadence::FrameCadenceGate; use super::*; use crate::feeds::microphone::{self, MicrophoneFeed, MicrophoneFeedLock}; use crate::ffmpeg::FFmpegVideoFrame; @@ -26,7 +27,11 @@ use std::{ }; use tokio_util::sync::CancellationToken; use x11rb::connection::Connection as _; -use x11rb::protocol::xproto::{ConnectionExt as _, ImageFormat, ImageOrder}; +use x11rb::protocol::Event; +use x11rb::protocol::composite::{ConnectionExt as _, Redirect}; +use x11rb::protocol::xproto::{ + ChangeWindowAttributesAux, ConnectionExt as _, EventMask, ImageFormat, ImageOrder, MapState, +}; use x11rb::rust_connection::RustConnection; #[derive(Debug)] @@ -54,6 +59,12 @@ pub struct VideoSourceConfig { input: LinuxInputConfig, } +impl VideoSourceConfig { + pub(crate) fn video_info(&self) -> VideoInfo { + self.video_info + } +} + enum LinuxInputConfig { X11(X11InputConfig), Wayland(WaylandInputConfig), @@ -61,6 +72,7 @@ enum LinuxInputConfig { pub(crate) struct X11InputConfig { pub display_name: String, + pub window_id: Option, pub x: i32, pub y: i32, pub width: u32, @@ -91,79 +103,75 @@ impl ScreenCaptureConfig { pub async fn to_sources( &self, ) -> anyhow::Result<(VideoSourceConfig, Option)> { - let system_audio = if self.system_audio { - Some(create_system_audio_source_config().await?) - } else { - None - }; - - if prefers_wayland_portal() { - match create_wayland_source_config(self).await { - Ok((video_info, input)) => { - return Ok(( - VideoSourceConfig { - video_info, - input: LinuxInputConfig::Wayland(input), - }, - system_audio, - )); - } - Err(error) if std::env::var_os("DISPLAY").is_some() => { - tracing::warn!( - error = %error, - "Wayland portal capture failed; falling back to X11 capture" - ); - } - Err(error) => return Err(error), + let source = if prefers_wayland_portal() { + let (video_info, input) = create_wayland_source_config(self).await?; + VideoSourceConfig { + video_info, + input: LinuxInputConfig::Wayland(input), } - } - - let display = - Display::from_id(&self.config.display).ok_or_else(|| anyhow!("Display not found"))?; - let display_position = display - .raw_handle() - .physical_position() - .ok_or_else(|| anyhow!("Display position unavailable"))?; - let display_size = display - .physical_size() - .ok_or_else(|| anyhow!("Display size unavailable"))?; - - let crop = self.config.crop_bounds.map(|crop| { - ( - crop.position().x(), - crop.position().y(), - crop.size().width(), - crop.size().height(), - ) - }); - let (x, y, width, height) = x11_capture_rect( - display_position.x(), - display_position.y(), - display_size.width(), - display_size.height(), - crop, - )?; - let video_info = VideoInfo { - width, - height, - ..self.video_info - }; + } else { + let display = Display::from_id(&self.config.display) + .ok_or_else(|| anyhow!("Display not found"))?; + let display_position = display + .raw_handle() + .physical_position() + .ok_or_else(|| anyhow!("Display position unavailable"))?; + let display_size = display + .physical_size() + .ok_or_else(|| anyhow!("Display size unavailable"))?; + + let crop = self.config.crop_bounds.map(|crop| { + ( + crop.position().x(), + crop.position().y(), + crop.size().width(), + crop.size().height(), + ) + }); + let (x, y, width, height) = x11_capture_rect( + display_position.x(), + display_position.y(), + display_size.width(), + display_size.height(), + crop, + )?; + let video_info = + if matches!(&self.config.linux_source, LinuxCaptureSource::Window { .. }) { + self.video_info + } else { + VideoInfo { + width, + height, + ..self.video_info + } + }; - Ok(( VideoSourceConfig { video_info, input: LinuxInputConfig::X11(X11InputConfig { display_name: std::env::var("DISPLAY").unwrap_or_else(|_| ":0".to_string()), + window_id: match &self.config.linux_source { + LinuxCaptureSource::Window { id } => { + Some(id.to_string().parse().context("Invalid X11 window ID")?) + } + LinuxCaptureSource::Display | LinuxCaptureSource::Area => None, + }, x, y, - width, - height, + width: video_info.width, + height: video_info.height, fps: self.config.fps, show_cursor: self.config.show_cursor, }), - }, - system_audio, - )) + } + }; + let system_audio = if self.system_audio { + Some(create_system_audio_source_config().await?) + } else { + None + }; + + Ok((source, system_audio)) } } @@ -286,6 +294,9 @@ struct PipewireCaptureState { fatal_error: Arc>>, sent: Arc, dropped: Arc, + rate_limited: Arc, + capture_clock: Instant, + cadence_gate: FrameCadenceGate, } impl PipewireCaptureState { @@ -300,10 +311,11 @@ impl PipewireCaptureState { async fn create_wayland_source_config( config: &ScreenCaptureConfig, ) -> anyhow::Result<(VideoInfo, WaylandInputConfig)> { - let portal = open_wayland_portal(config.config.linux_source, config.config.show_cursor).await?; - let crop_bounds = match config.config.linux_source { + let portal = + open_wayland_portal(&config.config.linux_source, config.config.show_cursor).await?; + let crop_bounds = match &config.config.linux_source { LinuxCaptureSource::Area => config.config.crop_bounds, - LinuxCaptureSource::Display | LinuxCaptureSource::Window => None, + LinuxCaptureSource::Display | LinuxCaptureSource::Window { .. } => None, }; let video_info = wayland_video_info(&portal.stream, config.video_info, crop_bounds); @@ -320,7 +332,7 @@ async fn create_wayland_source_config( } async fn open_wayland_portal( - source: LinuxCaptureSource, + source: &LinuxCaptureSource, show_cursor: bool, ) -> anyhow::Result { let proxy: Screencast<'static> = Screencast::new() @@ -374,19 +386,21 @@ async fn open_wayland_portal( }) } -fn prefers_wayland_portal() -> bool { - if std::env::var_os("WAYLAND_DISPLAY").is_none() { - return false; - } +pub(crate) fn prefers_wayland_portal() -> bool { + prefers_wayland_environment( + std::env::var_os("WAYLAND_DISPLAY").is_some(), + std::env::var_os("DISPLAY").is_some(), + std::env::var("XDG_SESSION_TYPE").ok().as_deref(), + ) +} - std::env::var_os("DISPLAY").is_none() - || std::env::var("XDG_SESSION_TYPE") - .is_ok_and(|session| session.eq_ignore_ascii_case("wayland")) +fn prefers_wayland_environment(wayland: bool, x11: bool, session: Option<&str>) -> bool { + wayland && (!x11 || session.is_some_and(|session| session.eq_ignore_ascii_case("wayland"))) } -fn wayland_source_type(source: LinuxCaptureSource) -> ashpd::enumflags2::BitFlags { +fn wayland_source_type(source: &LinuxCaptureSource) -> ashpd::enumflags2::BitFlags { match source { - LinuxCaptureSource::Window => SourceType::Window.into(), + LinuxCaptureSource::Window { .. } => SourceType::Window.into(), LinuxCaptureSource::Display | LinuxCaptureSource::Area => SourceType::Monitor.into(), } } @@ -427,6 +441,7 @@ fn capture_wayland( let fatal_error = Arc::new(parking_lot::Mutex::new(None)); let sent = Arc::new(AtomicU64::new(0)); let dropped = Arc::new(AtomicU64::new(0)); + let rate_limited = Arc::new(AtomicU64::new(0)); let started = Instant::now(); let thread_loop = unsafe { pw::thread_loop::ThreadLoopBox::new(Some("cap-wayland"), None) } @@ -448,6 +463,9 @@ fn capture_wayland( fatal_error: fatal_error.clone(), sent: sent.clone(), dropped: dropped.clone(), + rate_limited: rate_limited.clone(), + capture_clock: started, + cadence_gate: FrameCadenceGate::new(1_000_000_000 / i64::from(input.fps.max(1))), }; let stream = pw::stream::StreamBox::new( @@ -524,6 +542,7 @@ fn capture_wayland( tracing::info!( sent = sent.load(Ordering::Relaxed), dropped = dropped.load(Ordering::Relaxed), + rate_limited = rate_limited.load(Ordering::Relaxed), elapsed_ms = started.elapsed().as_millis() as u64, "Linux Wayland PipeWire capture stopped" ); @@ -582,12 +601,24 @@ fn process_pipewire_frame( return Ok(None); } + let captured_at = Instant::now(); + let ticks = i64::try_from( + captured_at + .saturating_duration_since(state.capture_clock) + .as_nanos(), + ) + .unwrap_or(i64::MAX); + if !state.cadence_gate.admit(ticks) { + state.rate_limited.fetch_add(1, Ordering::Relaxed); + return Ok(None); + } + let Some(raw_frame) = frame_from_pipewire_data(&mut datas[0], state.format, state.crop_bounds)? else { return Ok(Some(StallSendOutcome::StalledAndDropped { waited_ms: 0 })); }; let frame = prepare_pipewire_frame(raw_frame, &mut state.scaler, state.video_info)?; - let timestamp = Timestamp::Instant(Instant::now()); + let timestamp = Timestamp::Instant(captured_at); Ok(Some(send_with_stall_budget_futures( &mut state.video_tx, @@ -1370,6 +1401,7 @@ fn capture_x11( while !stop_token.is_cancelled() { let mut frame = match grabber.grab() { Ok(frame) => frame, + Err(error) if input_config.window_id.is_some() => return Err(error), Err(error) => { // X11 servers can transiently fail GetImage (e.g. while the // root geometry changes). Log, back off one interval, retry. @@ -1425,6 +1457,7 @@ fn capture_x11( pub(crate) struct X11Grabber { conn: RustConnection, root: x11rb::protocol::xproto::Window, + window: Option, x: i16, y: i16, width: u16, @@ -1435,6 +1468,12 @@ pub(crate) struct X11Grabber { show_cursor: bool, } +struct X11WindowCapture { + id: u32, + pixmap: u32, + border_width: u16, +} + impl X11Grabber { pub(crate) fn new(config: &X11InputConfig) -> anyhow::Result { ffmpeg::init().context("initialize FFmpeg")?; @@ -1448,22 +1487,38 @@ impl X11Grabber { .get(screen_num) .ok_or_else(|| anyhow!("X11 screen {screen_num} not found"))?; let root = screen.root; - let root_depth = screen.root_depth; - let root_visual_id = screen.root_visual; + let visual_id = match config.window_id { + Some(id) => { + if id == root || id == 0 { + bail!("X11 window capture requires a non-root window"); + } + conn.get_window_attributes(id) + .context("request X11 window attributes")? + .reply() + .context("read X11 window attributes")? + .visual + } + None => screen.root_visual, + }; - let visual = screen + let (depth, visual) = screen .allowed_depths .iter() - .flat_map(|depth| depth.visuals.iter()) - .find(|visual| visual.visual_id == root_visual_id) - .ok_or_else(|| anyhow!("X11 root visual {root_visual_id} not found"))?; + .find_map(|depth| { + depth + .visuals + .iter() + .find(|visual| visual.visual_id == visual_id) + .map(|visual| (depth.depth, visual)) + }) + .ok_or_else(|| anyhow!("X11 visual {visual_id} not found"))?; let bits_per_pixel = setup .pixmap_formats .iter() - .find(|format| format.depth == root_depth) + .find(|format| format.depth == depth) .map(|format| format.bits_per_pixel) - .ok_or_else(|| anyhow!("X11 pixmap format for depth {root_depth} not found"))?; + .ok_or_else(|| anyhow!("X11 pixmap format for depth {depth} not found"))?; let source_pixel = x11_source_pixel( setup.image_byte_order == ImageOrder::MSB_FIRST, @@ -1502,9 +1557,37 @@ impl X11Grabber { config.fps.max(1), ); - Ok(Self { + let window = if let Some(id) = config.window_id { + let version = conn + .composite_query_version(0, 4) + .context("XComposite is required for isolated window capture")? + .reply() + .context("query XComposite version")?; + if version.major_version == 0 && version.minor_version < 2 { + bail!("XComposite 0.2 or later is required for isolated window capture"); + } + conn.change_window_attributes( + id, + &ChangeWindowAttributesAux::new().event_mask(EventMask::STRUCTURE_NOTIFY), + )? + .check()?; + conn.composite_redirect_window(id, Redirect::AUTOMATIC) + .context("redirect X11 window for isolated capture")? + .check() + .context("enable isolated X11 window capture")?; + Some(X11WindowCapture { + id, + pixmap: 0, + border_width: 0, + }) + } else { + None + }; + + let mut grabber = Self { conn, root, + window, x, y, width, @@ -1513,18 +1596,99 @@ impl X11Grabber { output, scaler: None, show_cursor, - }) + }; + grabber.refresh_window_pixmap()?; + Ok(grabber) + } + + fn refresh_window_pixmap(&mut self) -> anyhow::Result<()> { + let Some(window) = self.window.as_mut() else { + return Ok(()); + }; + let mut storage_changed = false; + while let Some(event) = self.conn.poll_for_event()? { + match event { + Event::UnmapNotify(event) if event.window == window.id => { + bail!("Selected X11 window was unmapped"); + } + Event::DestroyNotify(event) if event.window == window.id => { + bail!("Selected X11 window was closed"); + } + Event::ConfigureNotify(event) if event.window == window.id => { + storage_changed = true + } + Event::ReparentNotify(event) if event.window == window.id => storage_changed = true, + Event::MapNotify(event) if event.window == window.id => storage_changed = true, + _ => {} + } + } + let attributes = self + .conn + .get_window_attributes(window.id)? + .reply() + .context("selected X11 window is no longer available")?; + if attributes.map_state != MapState::VIEWABLE { + bail!("Selected X11 window is no longer viewable"); + } + let geometry = self + .conn + .get_geometry(window.id)? + .reply() + .context("read selected X11 window geometry")?; + if geometry.width == 0 || geometry.height == 0 { + bail!("Selected X11 window has no content"); + } + + if storage_changed + || window.pixmap == 0 + || self.width != geometry.width + || self.height != geometry.height + || window.border_width != geometry.border_width + { + let pixmap = self.conn.generate_id()?; + self.conn + .composite_name_window_pixmap(window.id, pixmap)? + .check() + .context("access isolated X11 window pixels")?; + let previous = std::mem::replace(&mut window.pixmap, pixmap); + if previous != 0 { + self.conn.free_pixmap(previous)?.check()?; + } + self.width = geometry.width; + self.height = geometry.height; + window.border_width = geometry.border_width; + } + + if self.show_cursor { + let position = self + .conn + .translate_coordinates(window.id, self.root, 0, 0)? + .reply() + .context("locate selected X11 window cursor")?; + self.x = position.dst_x; + self.y = position.dst_y; + } + Ok(()) } /// Capture one frame of the configured region as a BGRZ video frame. pub(crate) fn grab(&mut self) -> anyhow::Result { + self.refresh_window_pixmap()?; + let (drawable, x, y) = match &self.window { + Some(window) => { + let border = i16::try_from(window.border_width) + .context("X11 window border exceeds capture limits")?; + (window.pixmap, border, border) + } + None => (self.root, self.x, self.y), + }; let reply = self .conn .get_image( ImageFormat::Z_PIXMAP, - self.root, - self.x, - self.y, + drawable, + x, + y, self.width, self.height, u32::MAX, @@ -1564,29 +1728,12 @@ impl X11Grabber { .copy_from_slice(&reply.data[src_start..src_start + copy]); } - // Convert to BGRZ only when the server's visual differs; the common - // case (32-bit little-endian BGRX) is already BGRZ and short-circuits. - let mut frame = if self.source_pixel == self.output.pixel_format { - source - } else { - if self.scaler.is_none() { - self.scaler = Some(FrameScaler::new( - self.source_pixel, - u32::from(self.width), - u32::from(self.height), - self.output, - )?); - } - self.scaler - .as_mut() - .expect("scaler initialized") - .scale(&source, self.output)? - }; + let mut frame = prepare_pipewire_frame(source, &mut self.scaler, self.output)?; - if self.show_cursor { - if let Err(error) = self.composite_cursor(&mut frame) { - tracing::trace!(error = %error, "X11 cursor composite skipped"); - } + if self.show_cursor + && let Err(error) = self.composite_cursor(&mut frame) + { + tracing::trace!(error = %error, "X11 cursor composite skipped"); } Ok(frame) @@ -1605,21 +1752,26 @@ impl X11Grabber { .reply() .context("read X11 cursor image")?; - let cursor_width = i32::from(cursor.width); - let cursor_height = i32::from(cursor.height); - if cursor_width <= 0 || cursor_height <= 0 { + if cursor.width == 0 || cursor.height == 0 { return Ok(()); } - if cursor.cursor_image.len() != (cursor_width * cursor_height) as usize { + if cursor.cursor_image.len() != usize::from(cursor.width) * usize::from(cursor.height) { return Ok(()); } - // Top-left of the cursor image in capture-region coordinates. - let origin_x = i32::from(cursor.x) - i32::from(cursor.xhot) - i32::from(self.x); - let origin_y = i32::from(cursor.y) - i32::from(cursor.yhot) - i32::from(self.y); - - let frame_width = i32::from(self.width); - let frame_height = i32::from(self.height); + let scale_x = f64::from(frame.width()) / f64::from(self.width); + let scale_y = f64::from(frame.height()) / f64::from(self.height); + let cursor_width = (f64::from(cursor.width) * scale_x).ceil() as i32; + let cursor_height = (f64::from(cursor.height) * scale_y).ceil() as i32; + let origin_x = ((f64::from(cursor.x) - f64::from(cursor.xhot) - f64::from(self.x)) + * scale_x) + .floor() as i32; + let origin_y = ((f64::from(cursor.y) - f64::from(cursor.yhot) - f64::from(self.y)) + * scale_y) + .floor() as i32; + + let frame_width = frame.width() as i32; + let frame_height = frame.height() as i32; let stride = frame.stride(0); let buf = frame.data_mut(0); @@ -1633,7 +1785,11 @@ impl X11Grabber { if fx < 0 || fx >= frame_width { continue; } - let pixel = cursor.cursor_image[(cy * cursor_width + cx) as usize]; + let source_x = + ((f64::from(cx) / scale_x) as usize).min(usize::from(cursor.width) - 1); + let source_y = + ((f64::from(cy) / scale_y) as usize).min(usize::from(cursor.height) - 1); + let pixel = cursor.cursor_image[source_y * usize::from(cursor.width) + source_x]; let alpha = (pixel >> 24) & 0xff; if alpha == 0 { continue; @@ -1826,7 +1982,17 @@ mod system_audio_tests { #[cfg(test)] mod pipewire_frame_tests { - use super::{FrameScaler, VideoInfo, prepare_pipewire_frame}; + use super::{FrameScaler, VideoInfo, prefers_wayland_environment, prepare_pipewire_frame}; + + #[test] + fn active_wayland_sessions_use_the_portal_even_with_xwayland() { + assert!(prefers_wayland_environment(true, false, None)); + assert!(prefers_wayland_environment(true, true, Some("wayland"))); + assert!(prefers_wayland_environment(true, true, Some("Wayland"))); + assert!(!prefers_wayland_environment(true, true, Some("x11"))); + assert!(!prefers_wayland_environment(true, true, None)); + assert!(!prefers_wayland_environment(false, true, Some("wayland"))); + } #[test] fn matching_pipewire_frames_reuse_owned_pixel_storage_without_a_scaler() { diff --git a/crates/recording/src/sources/screen_capture/mod.rs b/crates/recording/src/sources/screen_capture/mod.rs index 2282ccad857..6a61479f0fd 100644 --- a/crates/recording/src/sources/screen_capture/mod.rs +++ b/crates/recording/src/sources/screen_capture/mod.rs @@ -73,10 +73,10 @@ pub enum ScreenCaptureTarget { } #[cfg(target_os = "linux")] -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub enum LinuxCaptureSource { Display, - Window, + Window { id: WindowId }, Area, } @@ -84,7 +84,7 @@ pub enum LinuxCaptureSource { impl LinuxCaptureSource { pub fn from_target(target: &ScreenCaptureTarget) -> Self { match target { - ScreenCaptureTarget::Window { .. } => Self::Window, + ScreenCaptureTarget::Window { id } => Self::Window { id: id.clone() }, ScreenCaptureTarget::Area { .. } => Self::Area, ScreenCaptureTarget::Display { .. } | ScreenCaptureTarget::CameraOnly => Self::Display, } @@ -714,6 +714,18 @@ fn list_windows_inner(_include_accessory_panels: bool) -> Vec<(CaptureWindow, Wi mod tests { use super::*; + #[test] + #[cfg(target_os = "linux")] + fn linux_window_source_preserves_selected_window_id() { + let id: WindowId = "247".parse().unwrap(); + let target = ScreenCaptureTarget::Window { id: id.clone() }; + let LinuxCaptureSource::Window { id: selected } = LinuxCaptureSource::from_target(&target) + else { + panic!("window target must retain a window source"); + }; + assert_eq!(selected, id); + } + #[test] fn logical_area_to_physical_bounds_scales_each_axis() { let bounds = LogicalBounds::new( diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 1130576913d..909f23369fa 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -1411,6 +1411,10 @@ async fn create_segment_pipeline( base_inputs.capture_target, screen_capture::ScreenCaptureTarget::CameraOnly ); + #[cfg(target_os = "linux")] + let custom_cursor_capture = custom_cursor_capture && !screen_capture::prefers_wayland_portal(); + #[cfg(target_os = "linux")] + let mut start_time = start_time; let (screen, system_audio, cursor_display) = if camera_only { #[cfg(target_os = "linux")] @@ -1535,6 +1539,10 @@ async fn create_segment_pipeline( ); let (capture_source, system_audio) = screen_config.to_sources().await?; + #[cfg(target_os = "linux")] + { + start_time = Timestamps::now(); + } let screen = ScreenCaptureMethod::make_studio_mode_pipeline( capture_source, @@ -1728,8 +1736,12 @@ async fn create_segment_pipeline( let cursor_display = cursor_display.ok_or(CreateSegmentPipelineError::NoDisplay)?; let cursor = spawn_cursor_recorder( - cursor_crop_bounds, - cursor_display, + crate::cursor::CursorCaptureTarget { + crop_bounds: cursor_crop_bounds, + display: cursor_display, + #[cfg(target_os = "linux")] + window: base_inputs.capture_target.window(), + }, cursors_dir.to_path_buf(), prev_cursors, next_cursors_id, diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index c5210822b25..7b428feea60 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -10,6 +10,8 @@ use aho_corasick::{AhoCorasickBuilder, MatchKind}; use tracing::Instrument; pub mod disk_space; +#[cfg(any(target_os = "linux", test))] +pub mod linux_package; #[cfg(target_os = "macos")] pub mod macos_qos; @@ -45,10 +47,13 @@ pub fn ensure_dir(path: &PathBuf) -> Result { /// # Example /// /// ```rust +/// use cap_utils::ensure_unique_filename; +/// let recordings_dir = std::path::Path::new("recordings"); /// let unique_name = ensure_unique_filename("My Recording.cap", &recordings_dir,); /// // If "My Recording.cap" exists, returns "My Recording (1).cap" /// // If that exists too, returns "My Recording (2).cap", etc. /// +/// let documents_dir = std::path::Path::new("documents"); /// let unique_name = ensure_unique_filename("document.pdf", &documents_dir); /// // If "document.pdf" exists, returns "document (1).pdf" /// ``` @@ -161,7 +166,7 @@ pub fn ensure_unique_filename_with_attempts( /// /// ## Examples /// -/// ``` +/// ```text /// // Basic formats /// YYYY-MM-DD HH:mm → %Y-%m-%d %H:%M /// // Output: "2025-01-15 14:30" diff --git a/crates/utils/src/linux_package.rs b/crates/utils/src/linux_package.rs new file mode 100644 index 00000000000..2922524e7f3 --- /dev/null +++ b/crates/utils/src/linux_package.rs @@ -0,0 +1,211 @@ +use std::{ + env, + ffi::{OsStr, OsString}, + fs, + path::{Path, PathBuf}, +}; + +fn append_alsa_config(config: &Path, existing: Option<&OsStr>) -> Option { + let config_bytes = config.as_os_str().as_encoded_bytes(); + if config_bytes.contains(&b':') { + return None; + } + let existing = existing + .filter(|value| !value.is_empty()) + .unwrap_or(OsStr::new("/usr/share/alsa/alsa.conf")); + if existing + .as_encoded_bytes() + .split(|byte| *byte == b':') + .any(|path| path == config_bytes) + { + return None; + } + let mut paths = existing.to_os_string(); + if !paths.is_empty() { + paths.push(":"); + } + paths.push(config); + Some(paths) +} + +pub fn appimage_alsa_config_path() -> Option { + let executable = env::current_exe().ok()?; + let appimage = env::var_os("APPIMAGE").map(PathBuf::from); + let appdir = env::var_os("APPDIR").map(PathBuf::from)?; + if package_format(&executable, None, appimage.as_deref(), Some(&appdir), None) + != PackageFormat::AppImage + { + return None; + } + let config = appdir.join("usr/lib/cap/alsa-pulse.conf"); + let plugin = appdir.join("usr/lib/alsa-lib/libasound_module_pcm_pulse.so"); + if !config.is_file() || !plugin.is_file() { + return None; + } + append_alsa_config(&config, env::var_os("ALSA_CONFIG_PATH").as_deref()) +} + +#[derive(Debug, PartialEq, Eq)] +enum PackageFormat { + Deb, + AppImage, + ExtractedAppImage, + Rpm, + Arch, + Unknown, +} + +fn package_format( + executable: &Path, + marker: Option<&str>, + appimage: Option<&Path>, + appdir: Option<&Path>, + debian_files: Option<&str>, +) -> PackageFormat { + if appimage.is_some_and(Path::is_absolute) + && appdir + .is_some_and(|directory| directory.is_absolute() && executable.starts_with(directory)) + { + return PackageFormat::AppImage; + } + + match marker.map(str::trim) { + Some("deb") => PackageFormat::Deb, + Some("rpm") => PackageFormat::Rpm, + Some("arch") => PackageFormat::Arch, + Some("appimage") => PackageFormat::ExtractedAppImage, + None if debian_files + .is_some_and(|files| files.lines().any(|file| Path::new(file) == executable)) => + { + PackageFormat::Deb + } + _ => PackageFormat::Unknown, + } +} + +fn target_for(format: PackageFormat, arch: &str) -> Result { + let suffix = match format { + PackageFormat::Deb => "deb", + PackageFormat::AppImage => "appimage", + PackageFormat::Rpm => { + return Err("Update Cap through your RPM package manager or install the latest RPM from cap.so/download.".into()); + } + PackageFormat::Arch => { + return Err("Update Cap through your Arch package manager or install the latest Arch package from cap.so/download.".into()); + } + PackageFormat::ExtractedAppImage => { + return Err("Launch the original Cap AppImage to use automatic updates. An extracted AppImage cannot update itself.".into()); + } + PackageFormat::Unknown => { + return Err("Automatic updates are unavailable for this Cap installation. Update it through your package manager or cap.so/download.".into()); + } + }; + Ok(format!("linux-{arch}-{suffix}")) +} + +pub fn updater_target(arch: &str) -> Result { + let executable = env::current_exe().map_err(|error| error.to_string())?; + let marker = executable + .parent() + .and_then(Path::parent) + .and_then(|directory| fs::read_to_string(directory.join("lib/cap/package-format")).ok()); + let appimage = env::var_os("APPIMAGE").map(PathBuf::from); + let appdir = env::var_os("APPDIR").map(PathBuf::from); + let debian_files = fs::read_to_string("/var/lib/dpkg/info/cap.list").ok(); + target_for( + package_format( + &executable, + marker.as_deref(), + appimage.as_deref(), + appdir.as_deref(), + debian_files.as_deref(), + ), + arch, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn appimage_audio_preserves_host_and_custom_configuration() { + let config = Path::new("/tmp/.mount_cap/usr/lib/cap/alsa-pulse.conf"); + for existing in [None, Some(OsStr::new(""))] { + assert_eq!( + append_alsa_config(config, existing).unwrap(), + OsString::from( + "/usr/share/alsa/alsa.conf:/tmp/.mount_cap/usr/lib/cap/alsa-pulse.conf" + ) + ); + } + let current = OsStr::new("/home/user/audio.conf:/home/user/devices.conf"); + let combined = append_alsa_config(config, Some(current)).unwrap(); + assert_eq!( + combined, + OsString::from( + "/home/user/audio.conf:/home/user/devices.conf:/tmp/.mount_cap/usr/lib/cap/alsa-pulse.conf" + ) + ); + assert!(append_alsa_config(config, Some(&combined)).is_none()); + assert!(append_alsa_config(Path::new("/tmp/invalid:path/alsa.conf"), None).is_none()); + } + + #[test] + fn appimage_updates_require_the_current_executable_inside_its_appdir() { + let root = std::env::temp_dir().join("cap-linux-package-test"); + let image = root.join("Cap.AppImage"); + let directory = root.join(".mount_cap"); + let format = package_format( + &directory.join("usr/bin/Cap"), + Some("appimage\n"), + Some(&image), + Some(&directory), + None, + ); + assert_eq!( + target_for(format, "x86_64").unwrap(), + "linux-x86_64-appimage" + ); + let format = package_format( + &root.join("usr/bin/Cap"), + Some("rpm\n"), + Some(&image), + Some(&directory), + None, + ); + assert_eq!(format, PackageFormat::Rpm); + assert!(target_for(format, "x86_64").is_err()); + } + + #[test] + fn packages_never_select_an_incompatible_updater() { + for (marker, expected) in [ + ("rpm", PackageFormat::Rpm), + ("arch", PackageFormat::Arch), + ("appimage", PackageFormat::ExtractedAppImage), + ("other", PackageFormat::Unknown), + ] { + let format = package_format(Path::new("/usr/bin/Cap"), Some(marker), None, None, None); + assert_eq!(format, expected); + assert!(target_for(format, "aarch64").is_err()); + } + let format = package_format(Path::new("/usr/bin/Cap"), Some("deb\n"), None, None, None); + assert_eq!(target_for(format, "aarch64").unwrap(), "linux-aarch64-deb"); + } + + #[test] + fn legacy_debian_packages_require_executable_ownership() { + let files = Some("/usr/bin/Cap\n/usr/bin/cap-gpui\n"); + for executable in ["/usr/bin/Cap", "/usr/bin/cap-gpui"] { + assert_eq!( + package_format(Path::new(executable), None, None, None, files), + PackageFormat::Deb + ); + } + assert_eq!( + package_format(Path::new("/tmp/Cap"), None, None, None, files), + PackageFormat::Unknown + ); + } +} diff --git a/packaging/linux/README.md b/packaging/linux/README.md new file mode 100644 index 00000000000..76018808017 --- /dev/null +++ b/packaging/linux/README.md @@ -0,0 +1,54 @@ +# Linux packages + +Build release packages on Linux with the desktop dependencies installed: + +```sh +pnpm with-env node scripts/build-linux-packages.mjs x86_64-unknown-linux-gnu --config src-tauri/tauri.prod.conf.json +``` + +The wrapper builds the CLI, GPUI, Tauri, DEB, RPM, and AppImage artifacts. It requires `TAURI_SIGNING_PRIVATE_KEY` and optionally `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. Release CI supplies these through secrets; use a disposable key and matching updater public-key configuration for sandbox tests. + +Prerelease versions containing a hyphen build DEB and AppImage but skip RPM, whose version format does not accept that character. Stable releases build all three formats. Arch packages can be built from either stable or prerelease DEBs. + +## AppImage finalization + +Use the Linux wrapper for distributable AppImages. The generic `build:tauri` command produces an intermediate AppImage that still needs finalization. + +Bundled Wayland and PipeWire libraries can conflict with newer host Mesa drivers and ALSA plugins. The finalizer extracts the produced AppImage, removes `libwayland-client.so*` and the `usr/lib/libpipewire-0.3.so*` copy, and rebuilds it with Tauri's cached `linuxdeploy-plugin-appimage.AppImage`. It preserves other libraries, the existing AppRun, and GTK/GStreamer hooks. It supplies the input image's runtime to the output plugin through `LDAI_RUNTIME_FILE`, avoiding a new runtime download during finalization. It checks every runtime byte except the 16-byte payload checksum that appimagetool regenerates, locating that section with `readelf` from binutils. It does not replace cached packaging tools and fails if the output plugin is unavailable. Inputs must use Tauri's external updater signature; embedded GPG signatures are not supported. + +The original launcher is retained as `AppRun.cap-original`. A small wrapper captures the caller's working directory before AppRun changes it, for both normal and `--appimage-extract-and-run` launches. CLI dispatch restores that directory so relative project and output paths work. An unavailable original directory produces an error instead of resolving paths inside the mounted image. GUI working-directory behavior is unchanged. + +The finalizer signs the rebuilt bytes and replaces the artifact and its adjacent `.sig` only after packaging and signing succeed. With `createUpdaterArtifacts: true`, the updater consumes this `.AppImage` and `.AppImage.sig` pair, not a tar archive. Do not upload the signature from the intermediate image. + +For an explicitly unsigned local test artifact: + +```sh +node scripts/finalize-linux-appimage.mjs --unsigned target/x86_64-unknown-linux-gnu/release/bundle/appimage/Cap_0.6.0_amd64.AppImage +``` + +This removes any old signature. The host must provide its Wayland client and PipeWire libraries alongside its graphics drivers and audio plugins. + +## Arch Linux and Omarchy + +Run the package builder as an ordinary user in an Arch environment with `makepkg` and `bsdtar`: + +```sh +bash scripts/build-linux-arch-package.sh Cap_0.6.0_amd64.deb ./arch-packages +sudo pacman -U ./arch-packages/cap-bin-0.6.0-1-x86_64.pkg.tar.zst +``` + +The builder reuses the exact DEB payload, changes the package-format marker, and declares Arch dependencies. It does not strip or rebuild the executables. Release CI uses a pinned Arch container without network access during packaging. + +On Omarchy, screen capture uses the installed Hyprland desktop portal and PipeWire. Package installation alone does not verify recording, playback, export, or device access; these require tests in the target desktop session. + +## CLI camera in Instant mode + +On Linux, `cap record start --mode instant --camera DEVICE` composites the camera directly into the recorded screen or window. The CLI uses an unmirrored square at the bottom right, sized to 30% of the shorter screen edge with a 2% margin. Camera images are center-cropped without stretching. If camera frames stop arriving, screen recording continues without a stale camera image. + +This CLI default does not change desktop camera-window settings or Studio's separate, editable camera track. + +## Updates and verification + +DEB and AppImage installations use separate updater targets. RPM and Arch installations direct users to update through their package manager or install the latest package, avoiding an incompatible DEB update. + +The release workflow checks bundled executables, native FFmpeg libraries, package markers, AppImage audio configuration, RPM version metadata, and final updater signatures. Runtime validation must additionally cover GPUI/Tauri switching, capture permissions, screen/window/area selection, camera and audio, playback, export, and CLI behavior on each supported desktop. diff --git a/packaging/linux/alsa-pulse.conf b/packaging/linux/alsa-pulse.conf new file mode 100644 index 00000000000..f458469a5c0 --- /dev/null +++ b/packaging/linux/alsa-pulse.conf @@ -0,0 +1,16 @@ +pcm_type.pulse { + lib { + @func concat + strings [ + { @func getenv vars [ APPDIR ] default "" } + "/usr/lib/alsa-lib/libasound_module_pcm_pulse.so" + ] + } +} + +pcm.pulse { + type pulse + hint { + description "PulseAudio Sound Server" + } +} diff --git a/packaging/linux/appimage b/packaging/linux/appimage new file mode 100644 index 00000000000..3bd8135f5de --- /dev/null +++ b/packaging/linux/appimage @@ -0,0 +1 @@ +appimage diff --git a/packaging/linux/deb b/packaging/linux/deb new file mode 100644 index 00000000000..e619f9ccdd6 --- /dev/null +++ b/packaging/linux/deb @@ -0,0 +1 @@ +deb diff --git a/packaging/linux/rpm b/packaging/linux/rpm new file mode 100644 index 00000000000..916f55df7a4 --- /dev/null +++ b/packaging/linux/rpm @@ -0,0 +1 @@ +rpm diff --git a/scripts/build-linux-arch-package.sh b/scripts/build-linux-arch-package.sh new file mode 100644 index 00000000000..806c0df1636 --- /dev/null +++ b/scripts/build-linux-arch-package.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + printf 'Usage: %s \n' "$0" >&2 + exit 1 +fi + +if [[ $(id -u) -eq 0 ]]; then + printf 'Build the Arch package as an ordinary user, as required by makepkg.\n' >&2 + exit 1 +fi + +deb="$(realpath "$1")" +mkdir -p "$2" +output="$(realpath "$2")" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +control_member="$(bsdtar -tf "$deb" | awk '/^control\.tar(\.|$)/ { print; exit }')" +data_member="$(bsdtar -tf "$deb" | awk '/^data\.tar(\.|$)/ { print; exit }')" +if [[ ! "$control_member" =~ ^control\.tar(\.(gz|xz|zst|bz2|lzma))?$ || ! "$data_member" =~ ^data\.tar(\.(gz|xz|zst|bz2|lzma))?$ ]]; then + printf 'The input is not a Debian package with control and data archives.\n' >&2 + exit 1 +fi + +mkdir "$work/control" +bsdtar -xOf "$deb" "$control_member" | bsdtar -xf - -C "$work/control" +version="$(awk '/^Version:/ { print $2 }' "$work/control/control")" +architecture="$(awk '/^Architecture:/ { print $2 }' "$work/control/control")" +if [[ ! "$version" =~ ^[0-9][0-9A-Za-z.+~_-]*$ ]]; then + printf 'Unsupported package version: %s\n' "$version" >&2 + exit 1 +fi + +case "$architecture" in + amd64) architecture=x86_64 ;; + arm64) architecture=aarch64 ;; + *) printf 'Unsupported package architecture: %s\n' "$architecture" >&2; exit 1 ;; +esac + +cp "$deb" "$work/Cap.deb" +checksum="$(sha256sum "$work/Cap.deb" | cut -d ' ' -f 1)" +cat > "$work/PKGBUILD" < "\$pkgdir/usr/lib/cap/package-format" +} +EOF + +cd "$work" +PKGDEST="$output" makepkg --nodeps --noconfirm diff --git a/scripts/build-linux-packages.mjs b/scripts/build-linux-packages.mjs new file mode 100644 index 00000000000..a841655fa89 --- /dev/null +++ b/scripts/build-linux-packages.mjs @@ -0,0 +1,68 @@ +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + finalizeLinuxAppImage, + runCommand, +} from "./finalize-linux-appimage.mjs"; +import { supportedLinuxBundles } from "./linux-bundle-config.mjs"; + +const [target, ...args] = process.argv.slice(2); +if ( + process.platform !== "linux" || + !/^\w+-unknown-linux-gnu$/.test(target ?? "") || + args.some((arg) => /^--(?:target|bundles|debug|profile)(?:=|$)/.test(arg)) +) { + throw new Error( + "Run on Linux: node scripts/build-linux-packages.mjs [tauri build options]", + ); +} +if (!process.env.TAURI_SIGNING_PRIVATE_KEY) { + throw new Error( + "TAURI_SIGNING_PRIVATE_KEY is required for Linux release packages", + ); +} + +const desktopDirectory = fileURLToPath( + new URL("../apps/desktop/", import.meta.url), +); +const metadata = runCommand( + "cargo", + ["metadata", "--no-deps", "--format-version", "1"], + { + cwd: path.join(desktopDirectory, "src-tauri"), + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }, +); +const workspace = JSON.parse(metadata.stdout); +const targetDirectory = workspace.target_directory; +const version = workspace.packages.find( + (pkg) => pkg.name === "cap-desktop", +)?.version; +if (!version) throw new Error("Desktop version is missing from Cargo metadata"); +const bundles = supportedLinuxBundles(version).join(","); + +runCommand( + "pnpm", + ["build:tauri", "--target", target, "--bundles", bundles, ...args], + { + cwd: desktopDirectory, + env: { ...process.env, RUST_TARGET_TRIPLE: target }, + }, +); + +const bundleDirectory = path.join( + targetDirectory, + target, + "release/bundle/appimage", +); +const images = (await readdir(bundleDirectory)).filter((file) => + file.endsWith(".AppImage"), +); +if (images.length !== 1) { + throw new Error( + `Expected one AppImage in ${bundleDirectory}, found ${images.length}`, + ); +} +await finalizeLinuxAppImage(path.join(bundleDirectory, images[0])); diff --git a/scripts/finalize-linux-appimage.mjs b/scripts/finalize-linux-appimage.mjs new file mode 100644 index 00000000000..f0e3eabe066 --- /dev/null +++ b/scripts/finalize-linux-appimage.mjs @@ -0,0 +1,309 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { constants, createReadStream, createWriteStream } from "node:fs"; +import { + access, + chmod, + copyFile, + mkdtemp, + readdir, + readFile, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const desktopDirectory = fileURLToPath( + new URL("../apps/desktop/", import.meta.url), +); + +// The sentinel preserves directory names ending in newlines through shell substitution. +const appRunWrapper = `#!/bin/sh +unset OWD +if cap_original_directory="$(pwd -P && printf '.')"; then + OWD="\${cap_original_directory%?}" + OWD="\${OWD%?}" + export OWD +fi +case "$0" in + */*) cap_appdir="\${0%/*}" ;; + *) cap_appdir=. ;; +esac +exec "$cap_appdir/AppRun.cap-original" "$@" +`; + +export async function preserveAppImageWorkingDirectory(appDir) { + const launcher = path.join(appDir, "AppRun"); + const original = path.join(appDir, "AppRun.cap-original"); + if ((await readdir(appDir)).includes(path.basename(original))) { + if ((await readFile(launcher, "utf8")) !== appRunWrapper) { + throw new Error("AppImage already contains a different AppRun wrapper"); + } + await access(original, constants.X_OK); + return; + } + await access(launcher, constants.X_OK); + await rename(launcher, original); + await writeFile(launcher, appRunWrapper, { mode: 0o755, flag: "wx" }); +} + +export function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: "inherit", ...options }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} failed with ${result.signal ?? result.status}`); + } + return result; +} + +async function hashPrefix(filename, size, digestMd5Offset) { + const digestMd5End = digestMd5Offset + 16; + const hash = createHash("sha256"); + let position = 0; + for await (const chunk of createReadStream(filename, { end: size - 1 })) { + if ( + digestMd5Offset >= position + chunk.length || + digestMd5End <= position + ) { + hash.update(chunk); + } else { + const start = Math.max(digestMd5Offset - position, 0); + const end = Math.min(digestMd5End - position, chunk.length); + if (start > 0) hash.update(chunk.subarray(0, start)); + hash.update(Buffer.alloc(end - start)); + if (end < chunk.length) hash.update(chunk.subarray(end)); + } + position += chunk.length; + } + return hash.digest("hex"); +} + +function parseElfHex(value) { + if (!/^(?:0x)?[0-9a-f]+$/i.test(value)) return Number.NaN; + const parsed = Number.parseInt(value, 16); + return Number.isSafeInteger(parsed) ? parsed : Number.NaN; +} + +async function readDigestMd5Section(runtime, env, run) { + const result = await run( + "readelf", + ["--wide", "--section-headers", runtime], + { + env: { ...env, LC_ALL: "C" }, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }, + ); + const sections = + typeof result.stdout === "string" + ? result.stdout.split(/\r?\n/).flatMap((line) => { + const match = + /^\s*\[\s*\d+\]\s+(\S+)\s+\S+\s+\S+\s+(\S+)\s+(\S+)/.exec(line); + return match?.[1] === ".digest_md5" + ? [{ offset: parseElfHex(match[2]), length: parseElfHex(match[3]) }] + : []; + }) + : []; + if (sections.length !== 1) { + throw new Error("Could not locate a single .digest_md5 section"); + } + return sections[0]; +} + +async function copyRuntime(image, directory, env, run) { + const options = { + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }; + const signature = (await run(image, ["--appimage-signature"], options)) + .stdout; + if (typeof signature !== "string" || signature.trim()) { + throw new Error("Expected an AppImage without an embedded GPG signature"); + } + const offset = (await run(image, ["--appimage-offset"], options)).stdout; + const size = typeof offset === "string" ? Number(offset.trim()) : Number.NaN; + if ( + typeof offset !== "string" || + !/^\d+$/.test(offset.trim()) || + !Number.isSafeInteger(size) || + size <= 0 || + size >= (await stat(image)).size + ) { + throw new Error("Invalid AppImage runtime offset"); + } + const filename = path.join(directory, "runtime"); + await pipeline( + createReadStream(image, { end: size - 1 }), + createWriteStream(filename, { mode: 0o755, flags: "wx" }), + ); + const runtime = await stat(filename); + if (!runtime.isFile() || runtime.size !== size) { + throw new Error("AppImage runtime copy is incomplete"); + } + await access(filename, constants.X_OK); + const digestMd5 = await readDigestMd5Section(filename, env, run); + if ( + !Number.isSafeInteger(digestMd5.offset) || + digestMd5.offset <= 0 || + !Number.isSafeInteger(digestMd5.length) || + digestMd5.length < 16 || + digestMd5.offset + digestMd5.length > size + ) { + throw new Error("Invalid .digest_md5 section in AppImage runtime"); + } + return { + filename, + size, + digestMd5, + hash: await hashPrefix(filename, size, digestMd5.offset), + }; +} + +export async function findConflictingLibraries(appDir, directory = appDir) { + const libraries = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const filename = path.join(directory, entry.name); + if (entry.isDirectory()) { + libraries.push(...(await findConflictingLibraries(appDir, filename))); + } else if ( + /^libwayland-client\.so(?:\..+)?$/.test(entry.name) || + (directory === path.join(appDir, "usr/lib") && + /^libpipewire-0\.3\.so(?:\..+)?$/.test(entry.name)) + ) { + libraries.push(filename); + } + } + return libraries; +} + +export async function finalizeLinuxAppImage( + filename, + { + unsigned = false, + env = process.env, + run = runCommand, + replace = rename, + outputPlugin = path.join( + env.XDG_CACHE_HOME || path.join(homedir(), ".cache"), + "tauri/linuxdeploy-plugin-appimage.AppImage", + ), + } = {}, +) { + const image = path.resolve(filename); + if (!image.endsWith(".AppImage")) { + throw new Error("Expected an .AppImage artifact"); + } + if (!unsigned && !env.TAURI_SIGNING_PRIVATE_KEY) { + throw new Error( + "TAURI_SIGNING_PRIVATE_KEY is required to sign the final AppImage", + ); + } + if (env.LDAI_SIGN !== undefined || env.SIGN !== undefined) { + throw new Error( + "Embedded GPG signing is unsupported; use the Tauri signer", + ); + } + await access(outputPlugin, constants.X_OK); + await access(image, constants.X_OK); + const work = await mkdtemp(path.join(path.dirname(image), ".cap-appimage-")); + let retainWork = false; + try { + const runtime = await copyRuntime(image, work, env, run); + await run(image, ["--appimage-extract"], { + cwd: work, + env, + stdio: ["ignore", "ignore", "inherit"], + }); + const appDir = path.join(work, "squashfs-root"); + const excluded = await findConflictingLibraries(appDir); + // Host Mesa and ALSA plugins require their matching Wayland and PipeWire ABIs. + for (const library of excluded) await rm(library); + await preserveAppImageWorkingDirectory(appDir); + const output = path.join(work, path.basename(image)); + await run( + outputPlugin, + ["--appimage-extract-and-run", "--appdir", appDir], + { + env: { + ...env, + APPIMAGE_EXTRACT_AND_RUN: "1", + OUTPUT: output, + LDAI_OUTPUT: output, + LDAI_RUNTIME_FILE: runtime.filename, + }, + }, + ); + if ( + (await stat(output)).size <= runtime.size || + (await hashPrefix(output, runtime.size, runtime.digestMd5.offset)) !== + runtime.hash + ) { + throw new Error("Final AppImage did not preserve its runtime"); + } + await chmod(output, 0o755); + if (!unsigned) { + await run("pnpm", ["tauri", "signer", "sign", output], { + cwd: desktopDirectory, + env: { + ...env, + TAURI_PRIVATE_KEY: env.TAURI_SIGNING_PRIVATE_KEY, + TAURI_PRIVATE_KEY_PASSWORD: + env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD ?? "", + }, + }); + if (!(await stat(`${output}.sig`)).size) { + throw new Error("Final AppImage updater signature is empty"); + } + } + const originalImage = path.join(work, "original-image"); + const originalImageStat = await stat(image); + await copyFile(image, originalImage, constants.COPYFILE_FICLONE); + await chmod(originalImage, originalImageStat.mode & 0o7777); + let imageReplaced = false; + try { + await replace(output, image); + imageReplaced = true; + if (!unsigned) await replace(`${output}.sig`, `${image}.sig`); + else await rm(`${image}.sig`, { force: true }); + } catch (error) { + if (!imageReplaced) throw error; + try { + await rename(originalImage, image); + } catch (rollbackError) { + retainWork = true; + throw new Error( + `${error instanceof Error ? error.message : error}; ${rollbackError instanceof Error ? rollbackError.message : rollbackError}. Recovery backup retained at ${work}`, + { cause: rollbackError }, + ); + } + throw error; + } + return excluded.map((library) => path.relative(appDir, library)); + } finally { + if (!retainWork) await rm(work, { recursive: true, force: true }); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const args = process.argv.slice(2); + const unsigned = args[0] === "--unsigned"; + if (unsigned) args.shift(); + if (process.platform !== "linux" || args.length !== 1) { + throw new Error( + "Run on Linux: node scripts/finalize-linux-appimage.mjs [--unsigned] ", + ); + } + const excluded = await finalizeLinuxAppImage(args[0], { unsigned }); + console.log( + `Finalized ${args[0]}; excluded ${excluded.join(", ") || "none"}`, + ); +} diff --git a/scripts/finalize-linux-appimage.test.mjs b/scripts/finalize-linux-appimage.test.mjs new file mode 100644 index 00000000000..e95509af4e9 --- /dev/null +++ b/scripts/finalize-linux-appimage.test.mjs @@ -0,0 +1,500 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rename, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { + finalizeLinuxAppImage, + findConflictingLibraries, + preserveAppImageWorkingDirectory, +} from "./finalize-linux-appimage.mjs"; + +const runtimeSize = 64; +const digestMd5Offset = 16; +const originalRuntime = Buffer.alloc(runtimeSize, 0x52); +originalRuntime.write("cap runtime fixture"); +Buffer.from("0123456789abcdef").copy(originalRuntime, digestMd5Offset); +const originalImage = Buffer.concat([ + originalRuntime, + Buffer.from("original payload"), +]); +const digestSectionHeaders = `[ 1] .digest_md5 PROGBITS 0000000000000000 0000000000000010 0000000000000010 0000000000000000 A 0 0 1`; + +function finalImage(runtime = originalRuntime) { + return Buffer.concat([runtime, Buffer.from("final payload")]); +} + +async function assertOriginalArtifact(image, signature) { + assert.deepEqual(await readFile(image), originalImage); + assert.equal(await readFile(`${image}.sig`, "utf8"), signature); +} + +async function fixture(t) { + const root = await mkdtemp(path.join(tmpdir(), "cap-appimage-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + const image = path.join(root, "Cap.AppImage"); + const plugin = path.join(root, "output-plugin"); + await writeFile(image, originalImage, { mode: 0o755 }); + await writeFile(`${image}.sig`, "original signature"); + await writeFile(plugin, "fixture", { mode: 0o755 }); + return { root, image, plugin, originalRuntime }; +} + +for (const failureAt of [1, 2]) { + test(`replacement failure ${failureAt} preserves the original artifact`, async (t) => { + const { root, image, plugin } = await fixture(t); + const env = { + TAURI_SIGNING_PRIVATE_KEY: "test key", + }; + let replacements = 0; + const originalInode = (await stat(image)).ino; + const run = async (command, args, options) => { + if (command === image) { + if (args[0] === "--appimage-signature") return { stdout: "" }; + if (args[0] === "--appimage-offset") { + return { stdout: `${runtimeSize}` }; + } + const appDir = path.join(options.cwd, "squashfs-root"); + await mkdir(appDir); + await writeFile(path.join(appDir, "AppRun"), "launcher", { + mode: 0o755, + }); + } else if (command === plugin) { + await writeFile(options.env.OUTPUT, finalImage()); + } else if (command === "readelf") { + assert.deepEqual(args.slice(0, 2), ["--wide", "--section-headers"]); + assert.equal(options.env.LC_ALL, "C"); + return { stdout: digestSectionHeaders }; + } else { + assert.equal(command, "pnpm"); + await writeFile(`${args[3]}.sig`, "new signature"); + } + }; + await assert.rejects( + finalizeLinuxAppImage(image, { + outputPlugin: plugin, + env, + run, + replace: async (from, to) => { + replacements += 1; + if (replacements === failureAt) { + throw new Error(`replacement ${failureAt} failed`); + } + await rename(from, to); + if (to === image) { + assert.notDeepEqual(await readFile(image), originalImage); + } + }, + }), + new RegExp(`replacement ${failureAt} failed`), + ); + await assertOriginalArtifact(image, "original signature"); + assert.equal((await stat(image)).mode & 0o111, 0o111); + if (failureAt === 1) assert.equal((await stat(image)).ino, originalInode); + assert.ok( + !(await readdir(root)).some((file) => file.startsWith(".cap-appimage-")), + ); + }); +} + +test("conflicting libraries are limited to Wayland clients and the root PipeWire copy without following directory symlinks", async (t) => { + const { root } = await fixture(t); + const appDir = path.join(root, "AppDir"); + const libraryDirectory = path.join(appDir, "usr/lib"); + await mkdir(libraryDirectory, { recursive: true }); + await writeFile( + path.join(libraryDirectory, "libwayland-client.so.0.23.0"), + "client", + ); + await symlink( + "libwayland-client.so.0.23.0", + path.join(libraryDirectory, "libwayland-client.so.0"), + ); + await writeFile(path.join(libraryDirectory, "libwayland-egl.so.1"), "egl"); + await writeFile( + path.join(libraryDirectory, "libpipewire-0.3.so.0"), + "pipewire", + ); + await mkdir(path.join(libraryDirectory, "cap")); + await writeFile( + path.join(libraryDirectory, "cap/libpipewire-0.3.so.0"), + "private copy", + ); + await writeFile(path.join(root, "libwayland-client.so.99"), "outside"); + await symlink(root, path.join(appDir, "outside")); + const libraries = await findConflictingLibraries(appDir); + assert.deepEqual(libraries.map((file) => path.basename(file)).sort(), [ + "libpipewire-0.3.so.0", + "libwayland-client.so.0", + "libwayland-client.so.0.23.0", + ]); +}); + +for (const failure of [false, true]) { + test(`signing ${failure ? "failure preserves the original artifact" : "uses the final bytes before replacing the artifact"}`, async (t) => { + const { root, image, plugin } = await fixture(t); + const calls = []; + const env = { + TAURI_SIGNING_PRIVATE_KEY: "test key", + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "test password", + }; + const run = async (command, args, options) => { + calls.push(command); + assert.deepEqual(await readFile(image), originalImage); + assert.equal( + await readFile(`${image}.sig`, "utf8"), + "original signature", + ); + if (command === image) { + if (args[0] === "--appimage-signature") return { stdout: "\n" }; + if (args[0] === "--appimage-offset") { + return { stdout: `${runtimeSize}\n` }; + } + assert.deepEqual(args, ["--appimage-extract"]); + const libraries = path.join(options.cwd, "squashfs-root/usr/lib"); + await mkdir(libraries, { recursive: true }); + await writeFile( + path.join(libraries, "libwayland-client.so.0"), + "client", + ); + await writeFile( + path.join(libraries, "libwayland-server.so.0"), + "server", + ); + await writeFile( + path.join(libraries, "libpipewire-0.3.so.0"), + "pipewire", + ); + await writeFile( + path.join(options.cwd, "squashfs-root/AppRun"), + "original launcher", + { mode: 0o755 }, + ); + } else if (command === "readelf") { + return { stdout: digestSectionHeaders }; + } else if (command === plugin) { + assert.equal( + Buffer.compare( + await readFile(options.env.LDAI_RUNTIME_FILE), + originalRuntime, + ), + 0, + ); + assert.notEqual(options.env.LDAI_RUNTIME_FILE, image); + assert.equal(options.env.OUTPUT, options.env.LDAI_OUTPUT); + assert.deepEqual(await readdir(path.join(args[2], "usr/lib")), [ + "libwayland-server.so.0", + ]); + assert.equal( + await readFile(path.join(args[2], "AppRun.cap-original"), "utf8"), + "original launcher", + ); + assert.match( + await readFile(path.join(args[2], "AppRun"), "utf8"), + /export OWD/, + ); + const changedRuntime = Buffer.from(originalRuntime); + changedRuntime.fill(0xa5, digestMd5Offset, digestMd5Offset + 16); + await writeFile(options.env.OUTPUT, finalImage(changedRuntime)); + } else { + assert.equal(command, "pnpm"); + assert.deepEqual(args.slice(0, 3), ["tauri", "signer", "sign"]); + assert.equal( + options.env.TAURI_PRIVATE_KEY, + env.TAURI_SIGNING_PRIVATE_KEY, + ); + assert.equal( + options.env.TAURI_PRIVATE_KEY_PASSWORD, + env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD, + ); + assert.ok(!args.includes(env.TAURI_SIGNING_PRIVATE_KEY)); + if (failure) throw new Error("signer failed"); + const hash = createHash("sha256") + .update(await readFile(args[3])) + .digest("hex"); + await writeFile(`${args[3]}.sig`, hash); + } + }; + const operation = finalizeLinuxAppImage(image, { + outputPlugin: plugin, + env, + run, + }); + if (failure) { + await assert.rejects(operation, /signer failed/); + await assertOriginalArtifact(image, "original signature"); + assert.equal( + await readFile(`${image}.sig`, "utf8"), + "original signature", + ); + } else { + assert.deepEqual((await operation).sort(), [ + "usr/lib/libpipewire-0.3.so.0", + "usr/lib/libwayland-client.so.0", + ]); + const expectedRuntime = Buffer.from(originalRuntime); + expectedRuntime.fill(0xa5, digestMd5Offset, digestMd5Offset + 16); + assert.deepEqual(await readFile(image), finalImage(expectedRuntime)); + assert.equal( + await readFile(`${image}.sig`, "utf8"), + createHash("sha256").update(finalImage(expectedRuntime)).digest("hex"), + ); + } + assert.deepEqual(calls, [image, image, "readelf", image, plugin, "pnpm"]); + assert.ok( + !(await readdir(root)).some((file) => file.startsWith(".cap-appimage-")), + ); + }); +} + +test("missing signing key or output tool fails before changing the input", async (t) => { + const { image, plugin } = await fixture(t); + await assert.rejects( + finalizeLinuxAppImage(image, { outputPlugin: plugin, env: {} }), + /TAURI_SIGNING_PRIVATE_KEY/, + ); + await chmod(plugin, 0o644); + await assert.rejects( + finalizeLinuxAppImage(image, { outputPlugin: plugin, unsigned: true }), + /EACCES/, + ); + await assertOriginalArtifact(image, "original signature"); +}); + +for (const invalidOffset of [ + "", + "0", + "-1", + "80", + "99999999999999999999", + "9x", +]) { + test(`invalid runtime offset ${JSON.stringify(invalidOffset)} preserves the input`, async (t) => { + const { root, image, plugin } = await fixture(t); + await assert.rejects( + finalizeLinuxAppImage(image, { + outputPlugin: plugin, + unsigned: true, + env: {}, + run: async (command, args) => { + assert.equal(command, image); + return { + stdout: args[0] === "--appimage-signature" ? "" : invalidOffset, + }; + }, + }), + /Invalid AppImage runtime offset/, + ); + await assertOriginalArtifact(image, "original signature"); + assert.ok( + !(await readdir(root)).some((file) => file.startsWith(".cap-appimage-")), + ); + }); +} + +for (const [name, sectionHeaders] of [ + [ + "missing", + "[ 1] .text PROGBITS 0000000000000000 0000000000000010 0000000000000010", + ], + [ + "malformed", + "[ 1] .digest_md5 PROGBITS 0000000000000000 not-hex 0000000000000010", + ], + [ + "short", + "[ 1] .digest_md5 PROGBITS 0000000000000000 0000000000000010 000000000000000f", + ], + [ + "out of range", + "[ 1] .digest_md5 PROGBITS 0000000000000000 0000000000000038 0000000000000020", + ], +]) { + test(`invalid digest section ${name} preserves the input`, async (t) => { + const { root, image, plugin } = await fixture(t); + await assert.rejects( + finalizeLinuxAppImage(image, { + outputPlugin: plugin, + unsigned: true, + env: {}, + run: async (command, args) => { + if (command === image) { + return { + stdout: + args[0] === "--appimage-signature" ? "" : `${runtimeSize}`, + }; + } + assert.equal(command, "readelf"); + return { stdout: sectionHeaders }; + }, + }), + /digest_md5/, + ); + await assertOriginalArtifact(image, "original signature"); + assert.ok( + !(await readdir(root)).some((file) => file.startsWith(".cap-appimage-")), + ); + }); +} + +test("runtime changes from the output plugin are rejected before signing", async (t) => { + const { root, image, plugin } = await fixture(t); + await assert.rejects( + finalizeLinuxAppImage(image, { + outputPlugin: plugin, + env: { TAURI_SIGNING_PRIVATE_KEY: "test key" }, + run: async (command, args, options) => { + if (command === image) { + if (args[0] === "--appimage-signature") return { stdout: "" }; + if (args[0] === "--appimage-offset") { + return { stdout: `${runtimeSize}` }; + } + const appDir = path.join(options.cwd, "squashfs-root"); + await mkdir(appDir); + await writeFile(path.join(appDir, "AppRun"), "launcher", { + mode: 0o755, + }); + } else if (command === "readelf") { + return { stdout: digestSectionHeaders }; + } else { + assert.equal(command, plugin); + const changedRuntime = Buffer.from(originalRuntime); + changedRuntime[0] ^= 0xff; + await writeFile(options.env.OUTPUT, finalImage(changedRuntime)); + } + }, + }), + /did not preserve its runtime/, + ); + await assertOriginalArtifact(image, "original signature"); + assert.ok( + !(await readdir(root)).some((file) => file.startsWith(".cap-appimage-")), + ); +}); + +test("embedded GPG signatures and signing requests fail without changing the input", async (t) => { + const { image, plugin } = await fixture(t); + for (const env of [{ LDAI_SIGN: "1" }, { SIGN: "0" }]) { + await assert.rejects( + finalizeLinuxAppImage(image, { + outputPlugin: plugin, + unsigned: true, + env, + }), + /Embedded GPG signing is unsupported/, + ); + } + await assert.rejects( + finalizeLinuxAppImage(image, { + outputPlugin: plugin, + unsigned: true, + env: {}, + run: async () => ({ stdout: "-----BEGIN PGP SIGNATURE-----" }), + }), + /without an embedded GPG signature/, + ); + await assertOriginalArtifact(image, "original signature"); +}); + +test("AppRun preserves caller paths and arguments in mounted and extracted launches", async (t) => { + const { root } = await fixture(t); + const appDir = path.join(root, "Cap's AppDir"); + await mkdir(path.join(appDir, "usr"), { recursive: true }); + const launcher = path.join(appDir, "AppRun"); + const original = `#!/bin/sh +cd "\${0%/*}/usr" || exit +printf '%s\\0' "$OWD" "$@" +exit 23 +`; + await writeFile(launcher, original, { mode: 0o755 }); + await preserveAppImageWorkingDirectory(appDir); + await preserveAppImageWorkingDirectory(appDir); + assert.equal( + await readFile(path.join(appDir, "AppRun.cap-original"), "utf8"), + original, + ); + const direct = spawnSync("/bin/sh", ["AppRun", "--version"], { + cwd: appDir, + }); + assert.equal(direct.status, 23, direct.stderr.toString()); + assert.deepEqual(direct.stdout.toString().split("\0"), [ + await realpath(appDir), + "--version", + "", + ]); + for (const name of ["caller's directory", "trailing-newline\n"]) { + const caller = path.join(root, name); + await mkdir(caller); + for (const runtimeOwd of [undefined, "/stale/caller"]) { + const env = { ...process.env }; + if (runtimeOwd === undefined) delete env.OWD; + else env.OWD = runtimeOwd; + const args = ["--cap-cli", "two words", "$(literal)", "last\n"]; + const result = spawnSync(launcher, args, { cwd: caller, env }); + assert.equal(result.status, 23, result.stderr.toString()); + assert.deepEqual(result.stdout.toString().split("\0"), [ + await realpath(caller), + ...args, + "", + ]); + } + } +}); + +test("AppRun wrapping refuses an unrelated existing launcher backup", async (t) => { + const { root } = await fixture(t); + await writeFile(path.join(root, "AppRun"), "existing launcher"); + await writeFile(path.join(root, "AppRun.cap-original"), "unrelated file"); + await assert.rejects( + preserveAppImageWorkingDirectory(root), + /different AppRun wrapper/, + ); + assert.equal( + await readFile(path.join(root, "AppRun"), "utf8"), + "existing launcher", + ); + assert.equal( + await readFile(path.join(root, "AppRun.cap-original"), "utf8"), + "unrelated file", + ); +}); + +test("AppRun still launches the GUI after the caller directory is removed", async (t) => { + const { root } = await fixture(t); + const appDir = path.join(root, "AppDir"); + const caller = path.join(root, "removed-caller"); + await mkdir(appDir); + await mkdir(caller); + const launcher = path.join(appDir, "AppRun"); + await writeFile( + launcher, + `#!/bin/sh +printf 'GUI launched:%s' "\${OWD-unset}" +exit 23 +`, + { mode: 0o755 }, + ); + await preserveAppImageWorkingDirectory(appDir); + const result = spawnSync( + "/bin/sh", + ["-c", `cd "$1" && rmdir "$1" && exec "$2"`, "sh", caller, launcher], + { env: { ...process.env, OWD: "/stale/caller" } }, + ); + assert.equal(result.status, 23, result.stderr.toString()); + assert.match(result.stdout.toString(), /^GUI launched:/); + assert.ok(!result.stdout.toString().includes("/stale/caller")); +}); diff --git a/scripts/linux-bundle-config.mjs b/scripts/linux-bundle-config.mjs new file mode 100644 index 00000000000..1564f36284d --- /dev/null +++ b/scripts/linux-bundle-config.mjs @@ -0,0 +1,63 @@ +export function supportedLinuxBundles(version) { + return version.includes("-") + ? ["deb", "appimage"] + : ["deb", "rpm", "appimage"]; +} + +export function createLinuxBundleConfig( + libraryNames, + commonFiles = {}, + debDependencies = [], +) { + const files = { ...commonFiles }; + for (const name of [...new Set(libraryNames)].toSorted()) { + files[`/usr/lib/cap/${name}`] = + `../../../target/native-deps/cap-deb-libs/${name}`; + } + + return { + bundle: { + linux: { + deb: { + depends: [...new Set([...debDependencies, "libasound2-plugins"])], + files: { + ...files, + "/usr/lib/cap/package-format": "../../../packaging/linux/deb", + }, + }, + rpm: { + compression: { type: "zstd", level: 9 }, + depends: [ + "webkit2gtk4.1", + "gtk3", + "libayatana-appindicator-gtk3", + "libva", + "pulseaudio-utils", + "pipewire-libs", + "alsa-lib", + "alsa-plugins-pulseaudio", + "libxkbcommon", + "libxkbcommon-x11", + "openssl-libs", + ], + files: { + ...files, + "/usr/lib/cap/package-format": "../../../packaging/linux/rpm", + }, + }, + appimage: { + bundleMediaFramework: true, + files: { + ...files, + "/usr/bin/pactl": "/usr/bin/pactl", + "/usr/lib/alsa-lib/libasound_module_pcm_pulse.so": + "../../../target/native-deps/cap-appimage-libs/libasound_module_pcm_pulse.so", + "/usr/lib/cap/alsa-pulse.conf": + "../../../packaging/linux/alsa-pulse.conf", + "/usr/lib/cap/package-format": "../../../packaging/linux/appimage", + }, + }, + }, + }, + }; +} diff --git a/scripts/linux-bundle-config.test.mjs b/scripts/linux-bundle-config.test.mjs new file mode 100644 index 00000000000..53f5c133dfc --- /dev/null +++ b/scripts/linux-bundle-config.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + createLinuxBundleConfig, + supportedLinuxBundles, +} from "./linux-bundle-config.mjs"; + +test("nightly builds retain DEB and AppImage without passing invalid hyphenated versions to RPM", () => { + assert.deepEqual(supportedLinuxBundles("0.6.0"), ["deb", "rpm", "appimage"]); + assert.deepEqual(supportedLinuxBundles("0.6.0-nightly.202608261700"), [ + "deb", + "appimage", + ]); + assert.deepEqual(supportedLinuxBundles("0.6.0-rc.1"), ["deb", "appimage"]); +}); + +test("all Linux formats retain the exact native library sonames and shared icons", () => { + const icon = + "/usr/share/icons/hicolor/scalable/status/so.cap.desktop-tray-studio-symbolic.svg"; + const config = createLinuxBundleConfig( + ["libonnxruntime.so", "libavcodec.so.61", "libonnxruntime.so.1"], + { [icon]: "icons/linux/so.cap.desktop-tray-studio-symbolic.svg" }, + ); + + for (const format of ["deb", "rpm", "appimage"]) { + assert.equal( + config.bundle.linux[format].files["/usr/lib/cap/libavcodec.so.61"], + "../../../target/native-deps/cap-deb-libs/libavcodec.so.61", + ); + assert.equal( + config.bundle.linux[format].files["/usr/lib/cap/libonnxruntime.so.1"], + "../../../target/native-deps/cap-deb-libs/libonnxruntime.so.1", + ); + assert.equal( + config.bundle.linux[format].files[icon], + "icons/linux/so.cap.desktop-tray-studio-symbolic.svg", + ); + } +}); + +test("AppImage includes the system-audio control tool and webview media framework", () => { + const { appimage, deb, rpm } = createLinuxBundleConfig([]).bundle.linux; + assert.equal(appimage.files["/usr/bin/pactl"], "/usr/bin/pactl"); + assert.equal(appimage.bundleMediaFramework, true); + assert.equal( + appimage.files["/usr/lib/alsa-lib/libasound_module_pcm_pulse.so"], + "../../../target/native-deps/cap-appimage-libs/libasound_module_pcm_pulse.so", + ); + assert.equal( + appimage.files["/usr/lib/cap/alsa-pulse.conf"], + "../../../packaging/linux/alsa-pulse.conf", + ); + for (const format of [deb, rpm]) { + assert.equal(format.files["/usr/lib/cap/alsa-pulse.conf"], undefined); + } +}); + +test("library mappings are deterministic and preserve the input mappings", () => { + const files = { "/usr/share/cap/example": "example" }; + const first = createLinuxBundleConfig(["libz.so.1", "liba.so.2"], files); + const second = createLinuxBundleConfig( + ["liba.so.2", "libz.so.1", "liba.so.2"], + files, + ); + assert.equal(JSON.stringify(first), JSON.stringify(second)); + assert.deepEqual(files, { "/usr/share/cap/example": "example" }); +}); + +test("Debian audio dependencies retain existing requirements without duplicates", () => { + const dependencies = ["libgtk-3-0", "libasound2-plugins"]; + const config = createLinuxBundleConfig([], {}, dependencies); + assert.deepEqual(config.bundle.linux.deb.depends, dependencies); + assert.notEqual(config.bundle.linux.deb.depends, dependencies); + assert.deepEqual( + createLinuxBundleConfig([], {}, ["libgtk-3-0"]).bundle.linux.deb.depends, + ["libgtk-3-0", "libasound2-plugins"], + ); +}); diff --git a/scripts/prepare-gpui-dependency.sh b/scripts/prepare-gpui-dependency.sh index 445b5fa36f0..906b1a2ea5c 100755 --- a/scripts/prepare-gpui-dependency.sh +++ b/scripts/prepare-gpui-dependency.sh @@ -3,7 +3,11 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" zed_dir="$repo_root/../zed-cap" -patch_file="$repo_root/apps/desktop-gpui/patches/zed-gpui.patch" +patch_files=( + "$repo_root/apps/desktop-gpui/patches/zed-gpui.patch" + "$repo_root/apps/desktop-gpui/patches/zed-windows.patch" + "$repo_root/apps/desktop-gpui/patches/zed-linux.patch" +) base_revision="5d1f83d9f27a19bec1fb241dc33b42238af9cf8d" remote="https://github.com/wingleeio/zed.git" @@ -12,10 +16,12 @@ verify_checkout() { echo "error: $zed_dir does not contain GPUI base $base_revision" >&2 exit 1 fi - if ! git -C "$zed_dir" apply --reverse --check --unidiff-zero "$patch_file"; then - echo "error: $zed_dir does not contain Cap's pinned GPUI patch" >&2 - exit 1 - fi + for patch_file in "${patch_files[@]}"; do + if ! git -C "$zed_dir" apply --reverse --check --unidiff-zero "$patch_file"; then + echo "error: $zed_dir does not contain Cap's pinned GPUI patch: $patch_file" >&2 + exit 1 + fi + done } if [[ -e "$zed_dir/.git" ]]; then @@ -38,7 +44,9 @@ git init --quiet "$temporary_dir" git -C "$temporary_dir" remote add origin "$remote" git -C "$temporary_dir" fetch --quiet --depth 1 origin "$base_revision" git -C "$temporary_dir" checkout --quiet --detach FETCH_HEAD -git -C "$temporary_dir" apply --unidiff-zero "$patch_file" +for patch_file in "${patch_files[@]}"; do + git -C "$temporary_dir" apply --unidiff-zero "$patch_file" +done mv "$temporary_dir" "$zed_dir" trap - EXIT verify_checkout diff --git a/scripts/setup.js b/scripts/setup.js index 378944e9462..1997c747825 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -7,6 +7,7 @@ import * as path from "node:path"; import { env } from "node:process"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { createLinuxBundleConfig } from "./linux-bundle-config.mjs"; const exec = promisify(execCb); const execFile = promisify(execFileCb); @@ -710,6 +711,32 @@ async function fileExists(path) { } async function writeLinuxTauriConfig(sonameLibs) { + const pluginName = "libasound_module_pcm_pulse.so"; + const pluginDirectories = [ + `/usr/lib/${arch}-linux-gnu/alsa-lib`, + "/usr/lib64/alsa-lib", + "/usr/lib/alsa-lib", + ]; + let pulsePlugin; + for (const directory of pluginDirectories) { + const candidate = path.join(directory, pluginName); + if (await fileExists(candidate)) { + pulsePlugin = candidate; + break; + } + } + if (!pulsePlugin) { + throw new Error( + "The ALSA PulseAudio plugin is required for Linux bundles. Install libasound2-plugins (Debian/Ubuntu), alsa-plugins-pulseaudio (Fedora), or alsa-plugins (Arch).", + ); + } + const appimageLibDir = path.join( + __root, + "target/native-deps/cap-appimage-libs", + ); + await fs.mkdir(appimageLibDir, { recursive: true }); + await fs.copyFile(pulsePlugin, path.join(appimageLibDir, pluginName)); + const configPath = path.join( __root, "apps", @@ -717,19 +744,24 @@ async function writeLinuxTauriConfig(sonameLibs) { "src-tauri", "tauri.linux.conf.json", ); - const files = {}; - - for (const name of sonameLibs.toSorted()) { - files[`/usr/lib/cap/${name}`] = - `../../../target/native-deps/cap-deb-libs/${name}`; - } + const baseConfig = JSON.parse( + await fs.readFile( + path.join(path.dirname(configPath), "tauri.conf.json"), + "utf8", + ), + ); + const config = createLinuxBundleConfig( + sonameLibs, + baseConfig.bundle.linux.deb.files, + baseConfig.bundle.linux.deb.depends, + ); await writeFileIfChanged( configPath, - `${JSON.stringify({ bundle: { linux: { deb: { files } } } }, null, "\t")}\n`, + `${JSON.stringify(config, null, "\t")}\n`, ); console.log( - `Generated Linux Tauri deb config with ${sonameLibs.length} shared libraries`, + `Generated Linux Tauri package configs with ${sonameLibs.length} shared libraries`, ); }