diff --git a/.github/actions/install-desktop-deps/action.yml b/.github/actions/install-desktop-deps/action.yml index 6da52f04fd0..50f85262e2c 100644 --- a/.github/actions/install-desktop-deps/action.yml +++ b/.github/actions/install-desktop-deps/action.yml @@ -45,5 +45,8 @@ runs: libxfixes-dev \ libwayland-dev \ libxkbcommon-dev \ + libxkbcommon-x11-dev \ libva-dev \ + xvfb \ + xauth \ patchelf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c42007a69ad..0f67f266a21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: name: Detect Changes runs-on: ubuntu-latest outputs: + desktop: ${{ steps.filter.outputs.desktop }} rust: ${{ steps.filter.outputs.rust }} tauri-plugins: ${{ steps.filter.outputs.tauri-plugins }} branch: ${{ steps.branch.outputs.branch }} @@ -31,11 +32,30 @@ jobs: with: filters: | desktop: + - '.github/actions/install-desktop-deps/**' + - '.github/workflows/ci.yml' + - 'apps/cli/**' - 'apps/desktop/**' + - 'apps/desktop-gpui/**' + - 'crates/**' + - 'packages/database/**' + - 'packages/ui-solid/**' + - 'packages/utils/**' + - 'packages/web-api-contract/**' + - 'scripts/build-desktop-binaries*' + - 'scripts/build-gpui-binary.sh' + - 'scripts/prepare-gpui-dependency.sh' + - 'scripts/run-gpui-build*' + - 'scripts/sync-desktop-versions.mjs' + - 'scripts/verify-gpui-release-inputs.mjs' + - 'Cargo.toml' + - 'Cargo.lock' + - 'pnpm-lock.yaml' rust: - '.cargo/**' - '.github/**' - 'apps/cli/**' + - 'apps/desktop-gpui/**' - 'crates/**' - 'apps/desktop/src-tauri/**' - 'Cargo.toml' @@ -73,9 +93,13 @@ jobs: - uses: ./.github/actions/setup-js - name: Check Expo dependencies + env: + EXPO_OFFLINE: "1" run: pnpm --dir apps/mobile exec expo install --check - name: Run Expo Doctor + env: + EXPO_OFFLINE: "1" working-directory: apps/mobile run: pnpm dlx expo-doctor@1.20.1 @@ -195,6 +219,8 @@ jobs: runner: macos-latest - target: x86_64-pc-windows-msvc runner: windows-2022 + - target: x86_64-unknown-linux-gnu + runner: ubuntu-24.04 runs-on: ${{ matrix.settings.runner }} env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} @@ -203,6 +229,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Install Linux desktop dependencies + if: ${{ runner.os == 'Linux' }} + uses: ./.github/actions/install-desktop-deps + - name: Rust setup uses: dtolnay/rust-toolchain@1.88.0 with: @@ -240,10 +270,43 @@ jobs: shell: bash run: ./scripts/build-desktop-binaries.sh ${{ matrix.settings.target }} + - name: Test Linux recording and encoder regressions + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + cargo test --locked -p cap-recording --lib + cargo test --locked -p cap-enc-ffmpeg + env: + LD_LIBRARY_PATH: ${{ format('{0}/target/native-deps/lib:{0}/target/debug:{0}/target/{1}/debug', github.workspace, matrix.settings.target) }} + + - name: Build GPUI desktop + shell: bash + run: ./scripts/build-gpui-binary.sh debug ${{ matrix.settings.target }} + + - name: Test GPUI desktop + shell: bash + run: | + if [[ "$RUNNER_OS" == "Windows" ]]; then + export PATH="$(cygpath "$GITHUB_WORKSPACE/target/debug"):$PATH" + elif [[ "$RUNNER_OS" == "macOS" ]]; then + frameworks="$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/debug/Frameworks" + mkdir -p "$frameworks" + ln -sfn "$GITHUB_WORKSPACE/target/Frameworks/Spacedrive.framework" "$frameworks/Spacedrive.framework" + elif [[ "$RUNNER_OS" == "Linux" ]]; then + export LD_LIBRARY_PATH="$GITHUB_WORKSPACE/target/native-deps/lib:$GITHUB_WORKSPACE/target/debug:$GITHUB_WORKSPACE/target/${{ matrix.settings.target }}/debug${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + fi + if [[ "$RUNNER_OS" == "Linux" ]]; then + xvfb-run -a --server-args="-screen 0 1920x1080x24 -noreset" \ + cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked --target ${{ matrix.settings.target }} --bin cap-gpui + else + cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked --target ${{ matrix.settings.target }} --bin cap-gpui + fi + - name: Build app working-directory: apps/desktop run: pnpm tauri build --debug --target ${{ matrix.settings.target }} --no-bundle env: + LD_LIBRARY_PATH: ${{ runner.os == 'Linux' && format('{0}/target/native-deps/lib:{0}/target/debug:{0}/target/{1}/debug', github.workspace, matrix.settings.target) || '' }} RUST_TARGET_TRIPLE: ${{ matrix.settings.target }} tauri-plugins: @@ -279,6 +342,8 @@ jobs: - target: x86_64-pc-windows-msvc runner: windows-2022 runs-on: ${{ matrix.settings.runner }} + env: + RUST_TARGET_TRIPLE: ${{ matrix.settings.target }} steps: - name: Checkout uses: actions/checkout@v4 @@ -300,12 +365,16 @@ jobs: - run: node scripts/setup.js + - name: Build desktop binaries + shell: bash + run: ./scripts/build-desktop-binaries.sh ${{ matrix.settings.target }} + - name: Build debug - run: cargo build --all + run: cargo build --all --target ${{ matrix.settings.target }} - name: Build release - run: cargo check --all --release + run: cargo check --all --release --target ${{ matrix.settings.target }} - name: Run Clippy if: ${{ matrix.settings.target == 'aarch64-apple-darwin' || matrix.settings.target == 'x86_64-pc-windows-msvc' }} - run: cargo clippy --workspace --all-features --locked -- -D warnings + run: cargo clippy --workspace --all-features --locked --target ${{ matrix.settings.target }} -- -D warnings diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index e22099e885f..1445ee2e568 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -359,6 +359,43 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} RUST_TARGET_TRIPLE: ${{ matrix.settings.target }} + - name: Verify macOS app contents and signing + if: ${{ runner.os == 'macOS' }} + shell: bash + run: | + set -euo pipefail + APP="target/${{ matrix.settings.target }}/release/bundle/macos/Cap.app" + for binary in Cap cap-muxer cap-exporter cap-cli cap-gpui; do + executable="$APP/Contents/MacOS/$binary" + if [[ ! -x "$executable" ]]; then + echo "::error::Bundled executable missing or not executable: $executable" + exit 1 + fi + codesign --verify --strict "$executable" + done + GPUI_SIGNATURE="$(codesign -dv --verbose=4 "$APP/Contents/MacOS/cap-gpui" 2>&1)" + if ! grep -Eq 'flags=.*runtime' <<< "$GPUI_SIGNATURE"; then + echo "::error::Cap GPUI is not signed with the macOS hardened runtime." + exit 1 + fi + if ! grep -Eq '^Authority=Developer ID Application:' <<< "$GPUI_SIGNATURE"; then + echo "::error::Cap GPUI is not signed with a Developer ID Application certificate." + exit 1 + fi + codesign --verify --deep --strict "$APP" + spctl --assess --type execute "$APP" + xcrun stapler validate "$APP" + if [[ ! -s "$APP.tar.gz" || ! -s "$APP.tar.gz.sig" ]]; then + echo "::error::macOS updater artifact or production signature is missing." + exit 1 + fi + DMG="$(find "target/${{ matrix.settings.target }}/release/bundle/dmg" -maxdepth 1 -type f -name '*.dmg' -print -quit)" + if [[ -z "$DMG" || ! -s "$DMG" ]]; then + echo "::error::macOS disk image is missing or empty." + exit 1 + fi + codesign --verify "$DMG" + # Guards against the Linux packaging regressions: # 1. Frontend missing: vinxi must produce apps/desktop/.output/public, # which Tauri EMBEDS into the binary (it is NOT shipped as a loose @@ -402,6 +439,22 @@ jobs: WORK="$(mktemp -d)" dpkg-deb -x "$DEB" "$WORK" + EXECUTABLES=() + for binary in Cap cap-muxer cap-exporter cap-cli cap-gpui; do + executable="$WORK/usr/bin/$binary" + if [[ ! -x "$executable" ]]; then + echo "::error::Bundled executable missing or not executable: /usr/bin/$binary" + exit 1 + fi + EXECUTABLES+=("$executable") + done + + GPUI_RPATH="$(objdump -p "$WORK/usr/bin/cap-gpui" | awk '/RPATH|RUNPATH/{print $2}')" + if [[ "$GPUI_RPATH" != *'$ORIGIN/../lib/cap'* ]]; then + echo "::error::The bundled GPUI executable cannot resolve shared libraries from /usr/lib/cap" + exit 1 + fi + # Best-effort confirmation that the SPA actually got embedded into the # binary (Tauri stores asset path keys as plaintext). Informational only. if strings "$WORK/usr/bin/Cap" 2>/dev/null | grep -q "index.html"; then @@ -412,7 +465,7 @@ jobs: # (2) every FFmpeg soname the binary NEEDs is bundled BIN="$WORK/usr/bin/Cap" - NEEDED="$(objdump -p "$BIN" | awk '/NEEDED/{print $2}' | grep -E '^lib(av|sw|postproc)' || true)" + 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 MISSING=0 @@ -434,12 +487,19 @@ jobs: # in 0.5.2). Assert the curated set of such sonames the binary links. DEPENDS="$(dpkg-deb -f "$DEB" Depends || true)" echo "Declared Depends: ${DEPENDS:-(none)}" - NEEDED_ALL="$(objdump -p "$BIN" | awk '/NEEDED/{print $2}')" + if ! echo "$DEPENDS" | grep -Eq '(^|,[[:space:]]*)pulseaudio-utils([[:space:](,]|$)'; then + echo "::error::Linux system audio requires pulseaudio-utils (pactl) in .deb Depends." + exit 1 + fi + NEEDED_ALL="$(for executable in "${EXECUTABLES[@]}"; do objdump -p "$executable"; done | awk '/NEEDED/{print $2}' | sort -u)" # soname -> a Depends token that satisfies it. The alsa token is a # substring so it matches both libasound2 and libasound2t64. declare -A SONAME_DEP=( [libpipewire-0.3.so.0]="libpipewire-0.3-0" [libasound.so.2]="libasound2" + [libxkbcommon.so.0]="libxkbcommon0" + [libxkbcommon-x11.so.0]="libxkbcommon-x11-0" + [libssl.so.3]="libssl3" ) DEP_MISSING=0 for so in "${!SONAME_DEP[@]}"; do @@ -459,6 +519,33 @@ jobs: fi echo "Runtime dependency declarations OK" + - name: Verify Windows installer contents + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + $installers = Get-ChildItem -Path "target/${{ matrix.settings.target }}/release/bundle/nsis" -Filter *.exe + if (-not $installers) { + throw "No Windows NSIS installer was produced." + } + $sevenZip = Get-Command 7z -ErrorAction Stop + $requiredFiles = @( + "Cap.exe", "cap-muxer.exe", "cap-exporter.exe", "cap-cli.exe", "cap-gpui.exe", + "avcodec-61.dll", "avdevice-61.dll", "avfilter-10.dll", "avformat-61.dll", + "avutil-59.dll", "postproc-58.dll", "swresample-5.dll", "swscale-8.dll", + "dxcompiler.dll", "dxil.dll", "onnxruntime.dll", "onnxruntime_providers_shared.dll" + ) + foreach ($installer in $installers) { + $contents = & $sevenZip.Source l -slt $installer.FullName + if ($LASTEXITCODE -ne 0) { + throw "Could not inspect Windows installer '$($installer.Name)'." + } + foreach ($file in $requiredFiles) { + if ($contents -notcontains "Path = $file") { + throw "Windows installer '$($installer.Name)' is missing bundled '$file'." + } + } + } + - name: Upload unsigned Windows installer if: ${{ runner.os == 'Windows' }} id: upload_unsigned_windows_installer @@ -518,6 +605,21 @@ jobs: Write-Host "Files in bundle directory after signing:" Get-ChildItem -Path $bundleDir -Filter *.exe | ForEach-Object { Write-Host " - $($_.Name)" } + - name: Verify Windows installer Authenticode signature + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + $installers = Get-ChildItem -Path "target/${{ matrix.settings.target }}/release/bundle/nsis" -Filter *.exe + if (-not $installers) { + throw "No Windows installer exists after SignPath signing." + } + foreach ($installer in $installers) { + $signature = Get-AuthenticodeSignature -FilePath $installer.FullName + if ($signature.Status -ne "Valid") { + throw "Windows installer '$($installer.Name)' has invalid Authenticode status: $($signature.Status)." + } + } + - name: Re-sign Windows installer for Tauri updater if: ${{ runner.os == 'Windows' }} shell: bash @@ -528,6 +630,10 @@ jobs: echo "Re-signing $(basename "$exe") for Tauri updater..." rm -f "${exe}.sig" pnpm tauri signer sign -k "$TAURI_SIGNING_PRIVATE_KEY" -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$exe" + if [[ ! -s "${exe}.sig" ]]; then + echo "::error::Windows production updater signature missing for $exe" + exit 1 + fi done echo "Signature files after re-signing:" ls -la "$BUNDLE_DIR"/*.sig diff --git a/.github/workflows/sync-tests.yml b/.github/workflows/sync-tests.yml index 2e7cd3cb247..3e7343db139 100644 --- a/.github/workflows/sync-tests.yml +++ b/.github/workflows/sync-tests.yml @@ -23,6 +23,8 @@ on: - "crates/enc-mediafoundation/**" - "crates/timestamp/**" - "crates/rendering/**" + - "crates/editor/**" + - "crates/audio/**" - "crates/media-info/**" - ".github/workflows/sync-tests.yml" @@ -119,6 +121,11 @@ jobs: # looking identical to a pass in the CI log. cargo test --locked -p cap-rendering -- --nocapture + - name: Editor audio playback and export regressions + shell: bash + run: | + cargo test --locked -p cap-editor --lib audio::tests:: + # Real encoders + DASH muxer + remux/validation over full instant-mode # scenarios: pause/resume excision, stall-recovery bursts with # same-microsecond timestamps, segment assembly and A/V alignment. diff --git a/Cargo.lock b/Cargo.lock index 0aa2004200b..ebda8bf7721 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1485,6 +1485,7 @@ dependencies = [ "lz4_flex", "md5", "nix 0.29.0", + "notify-rust", "objc", "objc2 0.6.2", "objc2-app-kit 0.3.1", diff --git a/apps/chrome-extension/e2e/overlay-ui.spec.ts b/apps/chrome-extension/e2e/overlay-ui.spec.ts index 2d3aed81db7..307609c637a 100644 --- a/apps/chrome-extension/e2e/overlay-ui.spec.ts +++ b/apps/chrome-extension/e2e/overlay-ui.spec.ts @@ -759,6 +759,90 @@ test("the countdown appears while recording setup is still pending", async () => } }); +test("camera preview recovers when ICE gathering never reports completion", async () => { + test.setTimeout(60_000); + const mockServer = await createMockCapServer(); + const extension = await launchExtensionContext(); + + try { + const worker = await getServiceWorker(extension.context); + await configureExtension(worker, mockServer.origin); + + const messengerPage = await extension.context.newPage(); + await messengerPage.goto( + `chrome-extension://${new URL(worker.url()).host}/popup.html`, + ); + + const targetPage = await extension.context.newPage(); + await targetPage.addInitScript(() => { + if (!window.location.pathname.endsWith("/camera-preview.html")) return; + const NativePeerConnection = window.RTCPeerConnection; + window.RTCPeerConnection = class extends NativePeerConnection { + get iceGatheringState(): RTCIceGatheringState { + return "gathering"; + } + }; + }); + await targetPage.goto(`${mockServer.origin}/capture.html`); + await targetPage.bringToFront(); + + await sendServiceWorkerMessage(messengerPage, { + target: "service-worker", + type: "bootstrap", + }); + await sendServiceWorkerMessage(messengerPage, { + target: "service-worker", + type: "open-recorder-panel", + }); + + await expect + .poll(() => frameWithUrl(targetPage, "camera-preview.html") !== null, { + timeout: 10_000, + }) + .toBe(true); + + const previewFrame = frameWithUrl(targetPage, "camera-preview.html"); + if (!previewFrame) throw new Error("camera preview frame missing"); + expect( + await previewFrame.evaluate(() => { + const peer = new RTCPeerConnection(); + const state = peer.iceGatheringState; + peer.close(); + return state; + }), + ).toBe("gathering"); + + await expect + .poll( + () => + previewFrame.evaluate(() => { + const video = document.querySelector("video"); + return Boolean( + video && video.videoWidth > 0 && video.readyState >= 2, + ); + }), + { timeout: 8_000 }, + ) + .toBe(true); + + const devtools = await extension.context.newCDPSession(targetPage); + await expect + .poll( + async () => + (await getClosedShadowNodeId( + devtools, + "class", + "cap-extension-camera-loading", + )) === null, + { timeout: 5_000 }, + ) + .toBe(true); + } finally { + await extension.cleanup(); + await mockServer.close(); + } +}); + test("recording controls stay stable and the camera resizes directly", async () => { test.setTimeout(120_000); const mockServer = await createMockCapServer(); diff --git a/apps/chrome-extension/package.json b/apps/chrome-extension/package.json index 714c712b5e9..d5c46de7c71 100644 --- a/apps/chrome-extension/package.json +++ b/apps/chrome-extension/package.json @@ -1,6 +1,6 @@ { "name": "@cap/chrome-extension", - "version": "1.0.3", + "version": "1.0.4", "private": true, "type": "module", "scripts": { diff --git a/apps/chrome-extension/public/manifest.json b/apps/chrome-extension/public/manifest.json index 10592ea0688..091dd5dc723 100644 --- a/apps/chrome-extension/public/manifest.json +++ b/apps/chrome-extension/public/manifest.json @@ -3,7 +3,7 @@ "name": "Cap - Screen Recorder & Screen Capture", "short_name": "Cap", "description": "Free, open source screen recorder. Capture your screen, tab, camera & mic in Chrome and share a video link the moment you stop.", - "version": "1.0.3", + "version": "1.0.4", "homepage_url": "https://cap.so", "minimum_chrome_version": "116", "icons": { diff --git a/apps/chrome-extension/src/offscreen/recorder.ts b/apps/chrome-extension/src/offscreen/recorder.ts index 6f7177c229f..4889d624613 100644 --- a/apps/chrome-extension/src/offscreen/recorder.ts +++ b/apps/chrome-extension/src/offscreen/recorder.ts @@ -150,6 +150,10 @@ let retryInProgress = false; let lastProgressBroadcastAt = 0; let cameraPreviewStream: MediaStream | null = null; let cameraPreviewDeviceId: string | null = null; +let cameraPreviewStreamRequest: { + deviceId: string | null; + promise: Promise; +} | null = null; const cameraPreviewSessions = new Map(); const activeRecordingSounds = new Set(); @@ -297,10 +301,32 @@ const getCameraPreviewStream = async (settings: WebcamSettings) => { return cameraPreviewStream; } + if (cameraPreviewStreamRequest?.deviceId === settings.deviceId) { + return cameraPreviewStreamRequest.promise; + } + + if (cameraPreviewStreamRequest) { + await cameraPreviewStreamRequest.promise.catch(() => undefined); + } + disconnectCameraPreviews(); - cameraPreviewStream = await getCameraMediaStream(settings, false); - cameraPreviewDeviceId = settings.deviceId; - return cameraPreviewStream; + const promise = getCameraMediaStream(settings, false).then((stream) => { + cameraPreviewStream = stream; + cameraPreviewDeviceId = settings.deviceId; + return stream; + }); + cameraPreviewStreamRequest = { + deviceId: settings.deviceId, + promise, + }; + + try { + return await promise; + } finally { + if (cameraPreviewStreamRequest?.promise === promise) { + cameraPreviewStreamRequest = null; + } + } }; const getStreamSize = (stream: MediaStream) => { diff --git a/apps/chrome-extension/src/shared/webrtc.test.ts b/apps/chrome-extension/src/shared/webrtc.test.ts new file mode 100644 index 00000000000..8bbcc249ce5 --- /dev/null +++ b/apps/chrome-extension/src/shared/webrtc.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { waitForIceGatheringComplete } from "./webrtc"; + +const createPeer = (iceGatheringState: RTCIceGatheringState) => { + const peer = new EventTarget(); + Object.defineProperty(peer, "iceGatheringState", { + configurable: true, + value: iceGatheringState, + writable: true, + }); + return peer as RTCPeerConnection; +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("waitForIceGatheringComplete", () => { + it("resolves immediately after ICE gathering has completed", async () => { + const peer = createPeer("complete"); + const addEventListener = vi.spyOn(peer, "addEventListener"); + + await expect(waitForIceGatheringComplete(peer)).resolves.toBeUndefined(); + expect(addEventListener).not.toHaveBeenCalled(); + }); + + it("resolves when ICE gathering reports completion", async () => { + vi.useFakeTimers(); + const peer = createPeer("gathering"); + const pending = waitForIceGatheringComplete(peer); + Object.defineProperty(peer, "iceGatheringState", { value: "complete" }); + peer.dispatchEvent(new Event("icegatheringstatechange")); + + await expect(pending).resolves.toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("continues with gathered candidates when ICE never completes", async () => { + vi.useFakeTimers(); + const peer = createPeer("gathering"); + const removeEventListener = vi.spyOn(peer, "removeEventListener"); + const pending = waitForIceGatheringComplete(peer, 50); + + await vi.advanceTimersByTimeAsync(50); + + await expect(pending).resolves.toBeUndefined(); + expect(removeEventListener).toHaveBeenCalledWith( + "icegatheringstatechange", + expect.any(Function), + ); + }); +}); diff --git a/apps/chrome-extension/src/shared/webrtc.ts b/apps/chrome-extension/src/shared/webrtc.ts index 631e4f2ef9b..77c7c3aaaf4 100644 --- a/apps/chrome-extension/src/shared/webrtc.ts +++ b/apps/chrome-extension/src/shared/webrtc.ts @@ -11,15 +11,20 @@ export const toSessionDescriptionInit = ( }; }; -export const waitForIceGatheringComplete = (peer: RTCPeerConnection) => +const ICE_GATHERING_TIMEOUT_MS = 2000; + +export const waitForIceGatheringComplete = ( + peer: RTCPeerConnection, + timeoutMs = ICE_GATHERING_TIMEOUT_MS, +) => new Promise((resolve) => { if (peer.iceGatheringState === "complete") { resolve(); return; } - const handleIceGatheringStateChange = () => { - if (peer.iceGatheringState !== "complete") return; + const finish = () => { + globalThis.clearTimeout(timeout); peer.removeEventListener( "icegatheringstatechange", handleIceGatheringStateChange, @@ -27,6 +32,12 @@ export const waitForIceGatheringComplete = (peer: RTCPeerConnection) => resolve(); }; + const handleIceGatheringStateChange = () => { + if (peer.iceGatheringState !== "complete") return; + finish(); + }; + + const timeout = globalThis.setTimeout(finish, timeoutMs); peer.addEventListener( "icegatheringstatechange", handleIceGatheringStateChange, diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index 16648093c5c..b4d2d76c762 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -54,6 +54,7 @@ windows = { workspace = true, features = [ "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading", + "Win32_UI_HiDpi", ] } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index f1aa889853f..1342331d89d 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -447,6 +447,13 @@ struct CompletionsArgs { } fn main() { + #[cfg(windows)] + { + use windows::Win32::UI::HiDpi::{PROCESS_PER_MONITOR_DPI_AWARE, SetProcessDpiAwareness}; + + let _ = unsafe { SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE) }; + } + let cli = Cli::parse(); let level_filter = cli.log_level.level_filter(); diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index c1efba9f398..7c0f280531b 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -521,8 +521,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" dependencies = [ - "async-fs", - "async-net", "enumflags2", "futures-channel", "futures-util", @@ -1480,6 +1478,7 @@ dependencies = [ "cap-camera", "cap-camera-effects", "cap-editor", + "cap-enc-ffmpeg", "cap-export", "cap-project", "cap-recording", @@ -1501,6 +1500,7 @@ dependencies = [ "gpui_platform", "gpui_tokio", "image 0.25.10", + "jpeg-decoder", "kameo", "libc", "md-5", @@ -1512,6 +1512,7 @@ dependencies = [ "reqwest 0.12.28", "rfd", "scap-targets", + "semver", "serde", "serde_json", "sha2", diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index d414eb36f9b..8d00a8f3988 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -50,6 +50,7 @@ cpal = { git = "https://github.com/CapSoftware/cpal", rev = "6013cb5f8bd3" } # The actual recording engine -- the same studio/instant actors the Tauri app # drives. Drags in the ffmpeg encode stack; that is the point. cap-recording = { path = "../../crates/recording" } +cap-enc-ffmpeg = { path = "../../crates/enc-ffmpeg" } # Already in the tree through cap-recording; direct so the camera draw bench # can construct `NativeCameraFrame.timestamp` for its synthetic frames. cap-timestamp = { path = "../../crates/timestamp" } @@ -93,6 +94,7 @@ ffmpeg = { package = "ffmpeg-next", git = "https://github.com/CapSoftware/rust-f # the app ships as webp; features are additive, so enabling it here does not # move gpui's own pin. image = { version = "0.25.1", default-features = false, features = ["jpeg", "png", "webp"] } +jpeg-decoder = "0.3" smallvec = "1" # Grapheme boundaries for `ui::TextInput`: Backspace deletes a user-perceived # character, not a `char`, so a flag emoji or a combining accent goes in one @@ -114,8 +116,9 @@ chrono = "0.4" # Read the Tauri app's settings store so both apps share one recordings library. serde_json = "1" serde = { version = "1", features = ["derive"] } +semver = "1" base64 = "0.22" -rfd = "0.15" +rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] } # The screenshot export compositor (`src/screenshot_export.rs`): the canvas-2d # work `renderScreenshotExportCanvas` does in the webview -- shape rasterizing, # text, and the share fingerprint. All three are already in this lockfile @@ -166,6 +169,9 @@ libc = "0.2" raw-window-handle = "0.6" windows-sys = { version = "0.59", features = [ "Win32_Foundation", + "Win32_Security", + "Win32_System_SystemInformation", + "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", ] } diff --git a/apps/desktop-gpui/patches/zed-gpui.patch b/apps/desktop-gpui/patches/zed-gpui.patch new file mode 100644 index 00000000000..eefbff8935f --- /dev/null +++ b/apps/desktop-gpui/patches/zed-gpui.patch @@ -0,0 +1,361 @@ +diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs +index afb2171476..6cc35624a4 100644 +--- a/crates/gpui/src/scene.rs ++++ b/crates/gpui/src/scene.rs +@@ -830,0 +831,4 @@ pub struct PaintSurface { ++ pub corner_radii: Corners, ++ /// Normalized sub-rect of the source texture to sample, for object-fit ++ /// crops. The full texture is `(0,0)..(1,1)`. ++ pub source_uv: Bounds, +diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs +index 0480dc9574..484179d778 100644 +--- a/crates/gpui/src/window.rs ++++ b/crates/gpui/src/window.rs +@@ -1519,0 +1520 @@ impl Window { ++ let will_draw = invalidator.is_dirty() || request_frame_options.force_render; +@@ -1526 +1527,4 @@ impl Window { +- Some(Duration::from_micros(33333)) ++ // The skip below returns before the dirty check, so without ++ // this an unrelated pending next-frame callback caps fresh ++ // content at 30fps -- video playback in an unfocused window. ++ (!will_draw).then_some(Duration::from_micros(33333)) +@@ -4544,0 +4549,18 @@ impl Window { ++ self.paint_surface_fitted(bounds, bounds, Corners::default(), image_buffer); ++ } ++ ++ /// [`Self::paint_surface`] for object-fit layouts: paint the `visible` ++ /// rect of a surface whose fitted content box is `fitted` (equal or ++ /// larger, e.g. `ObjectFit::Cover`), cropping via the surface's source ++ /// UVs so `corner_radii` round the element's actual corners — the same ++ /// contract as [`Self::paint_image_fitted`]. ++ /// ++ /// This method should only be called as part of the paint phase of element drawing. ++ #[cfg(target_os = "macos")] ++ pub fn paint_surface_fitted( ++ &mut self, ++ visible: Bounds, ++ fitted: Bounds, ++ corner_radii: Corners, ++ image_buffer: CVPixelBuffer, ++ ) { +@@ -4549 +4571,20 @@ impl Window { +- let bounds = self.snap_bounds(bounds); ++ let source_uv = if visible == fitted { ++ Bounds { ++ origin: point(0., 0.), ++ size: size(1., 1.), ++ } ++ } else { ++ let fitted_width = f32::from(fitted.size.width).max(1.0); ++ let fitted_height = f32::from(fitted.size.height).max(1.0); ++ Bounds { ++ origin: point( ++ (f32::from(visible.origin.x) - f32::from(fitted.origin.x)) / fitted_width, ++ (f32::from(visible.origin.y) - f32::from(fitted.origin.y)) / fitted_height, ++ ), ++ size: size( ++ f32::from(visible.size.width) / fitted_width, ++ f32::from(visible.size.height) / fitted_height, ++ ), ++ } ++ }; ++ let bounds = self.snap_bounds(visible); +@@ -4550,0 +4592 @@ impl Window { ++ let corner_radii = corner_radii.scale(self.scale_factor()); +@@ -4554,0 +4597,2 @@ impl Window { ++ corner_radii, ++ source_uv, +diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs +index c93a383c38..11506dbb62 100644 +--- a/crates/gpui_macos/src/metal_renderer.rs ++++ b/crates/gpui_macos/src/metal_renderer.rs +@@ -10,3 +10,3 @@ use gpui::{ +- AtlasTextureId, BackdropBlur, Background, Bounds, ContentMask, DevicePixels, DrawOrder, +- MonochromeSprite, PaintSurface, Path, Point, PolychromeSprite, PrimitiveBatch, Quad, +- ScaledPixels, Scene, Shadow, Size, Surface, Underline, point, size, ++ AtlasTextureId, BackdropBlur, Background, Bounds, ContentMask, Corners, DevicePixels, ++ DrawOrder, MonochromeSprite, PaintSurface, Path, Point, PolychromeSprite, PrimitiveBatch, Quad, ++ ScaledPixels, Scene, Shadow, Size, Underline, point, size, +@@ -19,2 +19,3 @@ use core_video::{ +- metal_texture::CVMetalTextureGetTexture, metal_texture_cache::CVMetalTextureCache, +- pixel_buffer::kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, ++ metal_texture::CVMetalTextureGetTexture, ++ metal_texture_cache::CVMetalTextureCache, ++ pixel_buffer::{kCVPixelFormatType_32BGRA, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange}, +@@ -178,0 +180 @@ pub(crate) struct MetalRenderer { ++ bgra_surfaces_pipeline_state: metal::RenderPipelineState, +@@ -393,0 +396,8 @@ impl MetalRenderer { ++ let bgra_surfaces_pipeline_state = build_pipeline_state( ++ &device, ++ &library, ++ "bgra_surfaces", ++ "surface_vertex", ++ "surface_bgra_fragment", ++ MTLPixelFormat::BGRA8Unorm, ++ ); +@@ -422,0 +433 @@ impl MetalRenderer { ++ bgra_surfaces_pipeline_state, +@@ -489,3 +500,7 @@ impl MetalRenderer { +- if self.path_intermediate_texture.as_ref().is_some_and(|texture| { +- texture.width() == size.width.0 as u64 && texture.height() == size.height.0 as u64 +- }) { ++ if self ++ .path_intermediate_texture ++ .as_ref() ++ .is_some_and(|texture| { ++ texture.width() == size.width.0 as u64 && texture.height() == size.height.0 as u64 ++ }) ++ { +@@ -1196,3 +1211 @@ impl MetalRenderer { +- texture +- .as_ref() +- .map_or(0, |t| t.width() * t.height() * 4) ++ texture.as_ref().map_or(0, |t| t.width() * t.height() * 4) +@@ -1385,2 +1398 @@ impl MetalRenderer { +- let alloc: *mut objc::runtime::Object = +- msg_send![class!(MPSImageGaussianBlur), alloc]; ++ let alloc: *mut objc::runtime::Object = msg_send![class!(MPSImageGaussianBlur), alloc]; +@@ -1444,2 +1456,4 @@ impl MetalRenderer { +- command_encoder +- .set_fragment_texture(BackdropBlurInputIndex::SourceTexture as u64, Some(source_texture)); ++ command_encoder.set_fragment_texture( ++ BackdropBlurInputIndex::SourceTexture as u64, ++ Some(source_texture), ++ ); +@@ -1898 +1911,0 @@ impl MetalRenderer { +- command_encoder.set_render_pipeline_state(&self.surfaces_pipeline_state); +@@ -1916,27 +1929,63 @@ impl MetalRenderer { +- assert_eq!( +- surface.image_buffer.get_pixel_format(), +- kCVPixelFormatType_420YpCbCr8BiPlanarFullRange +- ); +- +- let y_texture = self +- .core_video_texture_cache +- .create_texture_from_image( +- surface.image_buffer.as_concrete_TypeRef(), +- None, +- MTLPixelFormat::R8Unorm, +- surface.image_buffer.get_width_of_plane(0), +- surface.image_buffer.get_height_of_plane(0), +- 0, +- ) +- .unwrap(); +- let cb_cr_texture = self +- .core_video_texture_cache +- .create_texture_from_image( +- surface.image_buffer.as_concrete_TypeRef(), +- None, +- MTLPixelFormat::RG8Unorm, +- surface.image_buffer.get_width_of_plane(1), +- surface.image_buffer.get_height_of_plane(1), +- 1, +- ) +- .unwrap(); ++ // CVMetalTexture wrappers must outlive the draw call below; the ++ // command encoder retains the underlying MTLTextures, but the ++ // texture cache may recycle them once the wrapper is released. ++ let pixel_format = surface.image_buffer.get_pixel_format(); ++ let _plane_textures = if pixel_format == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange ++ { ++ command_encoder.set_render_pipeline_state(&self.surfaces_pipeline_state); ++ let y_texture = self ++ .core_video_texture_cache ++ .create_texture_from_image( ++ surface.image_buffer.as_concrete_TypeRef(), ++ None, ++ MTLPixelFormat::R8Unorm, ++ surface.image_buffer.get_width_of_plane(0), ++ surface.image_buffer.get_height_of_plane(0), ++ 0, ++ ) ++ .unwrap(); ++ let cb_cr_texture = self ++ .core_video_texture_cache ++ .create_texture_from_image( ++ surface.image_buffer.as_concrete_TypeRef(), ++ None, ++ MTLPixelFormat::RG8Unorm, ++ surface.image_buffer.get_width_of_plane(1), ++ surface.image_buffer.get_height_of_plane(1), ++ 1, ++ ) ++ .unwrap(); ++ command_encoder.set_fragment_texture(SurfaceInputIndex::YTexture as u64, unsafe { ++ let texture = CVMetalTextureGetTexture(y_texture.as_concrete_TypeRef()); ++ Some(metal::TextureRef::from_ptr(texture as *mut _)) ++ }); ++ command_encoder.set_fragment_texture( ++ SurfaceInputIndex::CbCrTexture as u64, ++ unsafe { ++ let texture = CVMetalTextureGetTexture(cb_cr_texture.as_concrete_TypeRef()); ++ Some(metal::TextureRef::from_ptr(texture as *mut _)) ++ }, ++ ); ++ (y_texture, Some(cb_cr_texture)) ++ } else if pixel_format == kCVPixelFormatType_32BGRA { ++ command_encoder.set_render_pipeline_state(&self.bgra_surfaces_pipeline_state); ++ let color_texture = self ++ .core_video_texture_cache ++ .create_texture_from_image( ++ surface.image_buffer.as_concrete_TypeRef(), ++ None, ++ MTLPixelFormat::BGRA8Unorm, ++ surface.image_buffer.get_width(), ++ surface.image_buffer.get_height(), ++ 0, ++ ) ++ .unwrap(); ++ command_encoder.set_fragment_texture(SurfaceInputIndex::YTexture as u64, unsafe { ++ let texture = CVMetalTextureGetTexture(color_texture.as_concrete_TypeRef()); ++ Some(metal::TextureRef::from_ptr(texture as *mut _)) ++ }); ++ (color_texture, None) ++ } else { ++ log::error!("unsupported surface pixel format: {pixel_format:#x}"); ++ continue; ++ }; +@@ -1945 +1994 @@ impl MetalRenderer { +- let next_offset = *instance_offset + mem::size_of::(); ++ let next_offset = *instance_offset + mem::size_of::(); +@@ -1954,0 +2004,6 @@ impl MetalRenderer { ++ // The fragment reads corner radii from the same instance record. ++ command_encoder.set_fragment_buffer( ++ SurfaceInputIndex::Surfaces as u64, ++ Some(&instance_buffer.metal_buffer), ++ *instance_offset as u64, ++ ); +@@ -1960,9 +2014,0 @@ impl MetalRenderer { +- // let y_texture = y_texture.get_texture().unwrap(). +- command_encoder.set_fragment_texture(SurfaceInputIndex::YTexture as u64, unsafe { +- let texture = CVMetalTextureGetTexture(y_texture.as_concrete_TypeRef()); +- Some(metal::TextureRef::from_ptr(texture as *mut _)) +- }); +- command_encoder.set_fragment_texture(SurfaceInputIndex::CbCrTexture as u64, unsafe { +- let texture = CVMetalTextureGetTexture(cb_cr_texture.as_concrete_TypeRef()); +- Some(metal::TextureRef::from_ptr(texture as *mut _)) +- }); +@@ -1978,0 +2025,2 @@ impl MetalRenderer { ++ corner_radii: surface.corner_radii, ++ source_uv: surface.source_uv, +@@ -2246 +2294 @@ pub struct PathSprite { +-#[derive(Clone, Debug, Eq, PartialEq)] ++#[derive(Clone, Debug, PartialEq)] +@@ -2250,0 +2299,2 @@ pub struct SurfaceBounds { ++ pub corner_radii: Corners, ++ pub source_uv: Bounds, +@@ -2454 +2504,4 @@ mod backdrop_blur_tests { +- assert!(diff <= 3, "vignette or shift at window edge: max diff {diff}"); ++ assert!( ++ diff <= 3, ++ "vignette or shift at window edge: max diff {diff}" ++ ); +diff --git a/crates/gpui_macos/src/shaders.metal b/crates/gpui_macos/src/shaders.metal +index 97bad184dc..71a05aca4f 100644 +--- a/crates/gpui_macos/src/shaders.metal ++++ b/crates/gpui_macos/src/shaders.metal +@@ -883,3 +883,4 @@ vertex SurfaceVertexOutput surface_vertex( +- // We are going to copy the whole texture, so the texture position corresponds +- // to the current vertex of the unit triangle. +- float2 texture_position = unit_vertex; ++ float2 texture_position = ++ float2(surface.source_uv.origin.x, surface.source_uv.origin.y) + ++ unit_vertex * float2(surface.source_uv.size.width, ++ surface.source_uv.size.height); +@@ -891,0 +893,11 @@ vertex SurfaceVertexOutput surface_vertex( ++float surface_corner_alpha(float2 position, SurfaceBounds surface) { ++ float max_radius = max( ++ max(surface.corner_radii.top_left, surface.corner_radii.top_right), ++ max(surface.corner_radii.bottom_left, surface.corner_radii.bottom_right)); ++ if (max_radius <= 0.) { ++ return 1.; ++ } ++ float distance = quad_sdf(position, surface.bounds, surface.corner_radii); ++ return saturate(0.5 - distance); ++} ++ +@@ -892,0 +905,2 @@ fragment float4 surface_fragment(SurfaceFragmentInput input [[stage_in]], ++ constant SurfaceBounds *surfaces ++ [[buffer(SurfaceInputIndex_Surfaces)]], +@@ -907 +921,15 @@ fragment float4 surface_fragment(SurfaceFragmentInput input [[stage_in]], +- return ycbcrToRGBTransform * ycbcr; ++ float4 color = ycbcrToRGBTransform * ycbcr; ++ color.a *= surface_corner_alpha(input.position.xy, surfaces[0]); ++ return color; ++} ++ ++fragment float4 surface_bgra_fragment(SurfaceFragmentInput input [[stage_in]], ++ constant SurfaceBounds *surfaces ++ [[buffer(SurfaceInputIndex_Surfaces)]], ++ texture2d color_texture ++ [[texture(SurfaceInputIndex_YTexture)]]) { ++ constexpr sampler texture_sampler(mag_filter::linear, min_filter::linear); ++ float4 color = float4( ++ color_texture.sample(texture_sampler, input.texture_position).rgb, 1.0); ++ color.a *= surface_corner_alpha(input.position.xy, surfaces[0]); ++ return color; +diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs +index 8a3aa5ea06..e0a9b8e2ef 100644 +--- a/crates/gpui_macos/src/window.rs ++++ b/crates/gpui_macos/src/window.rs +@@ -3135 +3135,2 @@ unsafe fn remove_layer_background(layer: id) { +- let _: () = msg_send![filter, setValue: radius forKey: ns_string("inputRadius")]; ++ let _: () = ++ msg_send![filter, setValue: radius forKey: ns_string("inputRadius")]; +diff --git a/crates/gpui_windows/src/directx_atlas.rs b/crates/gpui_windows/src/directx_atlas.rs +--- a/crates/gpui_windows/src/directx_atlas.rs ++++ b/crates/gpui_windows/src/directx_atlas.rs +@@ -110,0 +111,9 @@ impl PlatformAtlas for DirectXAtlas { ++ let cached_texture_index = if id.kind == AtlasTextureKind::Polychrome { ++ textures.textures.iter().position(|entry| { ++ entry ++ .as_ref() ++ .is_some_and(|entry| entry.live_atlas_keys == 0) ++ }) ++ } else { ++ None ++ }; +@@ -119 +129,7 @@ impl PlatformAtlas for DirectXAtlas { +- if texture.is_unreferenced() { ++ if texture.is_unreferenced() && id.kind == AtlasTextureKind::Polychrome { ++ *texture_slot = Some(texture); ++ if let Some(index) = cached_texture_index { ++ textures.textures[index] = None; ++ textures.free_list.push(index); ++ } ++ } else if texture.is_unreferenced() { +@@ -420 +435,33 @@ mod tests { +-} ++ ++ #[test] ++ fn test_reuses_one_unreferenced_polychrome_texture() { ++ let Some(atlas) = create_atlas() else { ++ return; ++ }; ++ ++ let small = Size { ++ width: DevicePixels(64), ++ height: DevicePixels(64), ++ }; ++ let large = Size { ++ width: DevicePixels(1280), ++ height: DevicePixels(720), ++ }; ++ let small_key = make_image_key(10); ++ let first_key = make_image_key(11); ++ let second_key = make_image_key(12); ++ ++ insert_tile(&atlas, &small_key, small); ++ atlas.remove(&small_key); ++ let first = insert_tile(&atlas, &first_key, large); ++ atlas.remove(&first_key); ++ let second = insert_tile(&atlas, &second_key, large); ++ ++ assert_eq!(first.texture_id, second.texture_id); ++ let state = atlas.0.lock(); ++ assert_eq!( ++ state.polychrome_textures.textures.iter().flatten().count(), ++ 1 ++ ); ++ } ++} diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index d50a8bbabc9..a07107f0a96 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -106,6 +106,60 @@ pub struct CameraPark { impl Global for AppWindows {} +fn remove_popup_window_chrome(native: Option, cx: &mut App) { + let Some(native) = native else { + return; + }; + + cx.spawn(async move |_| platform::remove_popup_window_chrome(&native)) + .detach(); +} + +pub(crate) fn export_in_flight(cx: &App) -> bool { + if !cx.has_global::() { + return false; + } + + let windows = cx.global::(); + windows.editors.iter().any(|(_, handle)| { + handle + .read(cx) + .ok() + .and_then(|editor| editor.export.as_ref()) + .is_some_and(|export| export.phase.is_busy()) + }) || windows.screenshot_editors.iter().any(|(_, handle)| { + handle + .read(cx) + .is_ok_and(ScreenshotEditorWindow::export_in_flight) + }) +} + +pub(crate) fn flush_pending_editor_saves(cx: &mut App) { + if !cx.has_global::() { + return; + } + + let windows = cx.global::(); + let editors: Vec<_> = windows.editors.iter().map(|(_, handle)| *handle).collect(); + let screenshot_editors: Vec<_> = windows + .screenshot_editors + .iter() + .map(|(_, handle)| *handle) + .collect(); + + for handle in editors { + if let Ok(pending) = handle.update(cx, |editor, _, _| editor.pending_save()) { + pending.borrow_mut().flush(); + } + } + + for handle in screenshot_editors { + if let Ok(pending) = handle.update(cx, |editor, _, _| editor.pending_save()) { + pending.borrow_mut().flush(); + } + } +} + /// Install the registry and wire the session observer that tears the bar down /// when a recording ends (stop, delete, or a failed start). pub fn init(main: WindowHandle, session: Entity, cx: &mut App) { @@ -302,9 +356,27 @@ pub fn show_main_window(cx: &mut App) { open_onboarding(cx); return; } + if RecordingSession::global(cx).read(cx).phase == Phase::Idle { + crate::feeds::Feeds::global(cx).update(cx, |feeds, cx| feeds.resume_camera_preview(cx)); + } + let reset_target = RecordingSession::global(cx).read(cx).phase == Phase::Idle + && cx.global::().editor_hidden_for_picker.is_none(); + if reset_target { + let picker_open = { + let windows = cx.global::(); + windows.main_hidden_for_picker || !windows.overlays.is_empty() + }; + if picker_open { + close_target_overlays(cx); + } + cx.global_mut::().main_hidden_for_picker = false; + } let main = cx.global::().main; let native = main .update(cx, |view, window, cx| { + if reset_target { + view.clear_target(cx); + } // Every path back to the main window is a path a new capture may // have arrived on -- a finished recording most of all. The Tauri // app gets this from `invalidateRecentMedia` plus the query's @@ -395,6 +467,21 @@ pub fn hide_main_window(cx: &mut App) { .detach(); } +fn hide_main_and_park_camera_preview(cx: &mut App) { + hide_main_window(cx); + + if !camera_preview_can_be_parked(RecordingSession::global(cx).read(cx).phase) { + return; + } + + close_camera_window(cx); + crate::feeds::Feeds::global(cx).update(cx, |feeds, cx| feeds.park_camera_preview(cx)); +} + +fn camera_preview_can_be_parked(phase: Phase) -> bool { + matches!(phase, Phase::Idle) +} + /// ⌘W, the File/Window menus' Close Window, and the main window's own red /// traffic light. /// @@ -493,7 +580,7 @@ pub fn open_settings(page: Page, cx: &mut App) { } }) .detach(); - hide_main_window(cx); + hide_main_and_park_camera_preview(cx); return; } @@ -574,8 +661,6 @@ pub fn open_settings(page: Page, cx: &mut App) { cx.spawn(async move |cx| { if let Some(native) = &native { - // `applyMacOSWindowMaterial("settings")`: same install as the main - // window, radius 26 instead of 16. let kind = platform::install_window_material( native, settings_window::SETTINGS_MATERIAL_RADIUS, @@ -609,7 +694,7 @@ pub fn open_settings(page: Page, cx: &mut App) { }) .detach(); - hide_main_window(cx); + hide_main_and_park_camera_preview(cx); } /// Close the settings window from our side (Cmd-W). The close button goes @@ -1112,6 +1197,21 @@ fn resolve_window(id: &scap_targets::WindowId) -> Option { HoveredWindow::from_window(&scap_targets::Window::from_id(id)?) } +fn pinned_window_resolution_matches( + requested: Option<&scap_targets::WindowId>, + resolved: Option<&scap_targets::WindowId>, +) -> bool { + requested.is_none_or(|requested| resolved.is_some_and(|resolved| requested == resolved)) +} + +pub(crate) fn reject_unavailable_window(cx: &mut App) { + dismiss_target_overlays(cx); + RecordingSession::global(cx).update(cx, |session, cx| { + session.error = Some("The selected window is no longer available. Select it again.".into()); + cx.notify(); + }); +} + /// Open (or re-target) the fullscreen overlays. /// /// A mode change tears the old windows down first: the overlay carries @@ -1119,7 +1219,10 @@ fn resolve_window(id: &scap_targets::WindowId) -> Option { /// cheaper to reason about and what the Tauri flow does (the webviews are /// recreated with a new `targetMode` query parameter). pub fn open_target_overlays(request: OverlayRequest, cx: &mut App) { - open_overlays_core(request, cx); + if !open_overlays_core(request, cx) { + reject_unavailable_window(cx); + return; + } // `pickerActive && !hasHidden && !recording` -> `getCurrentWindow().hide()` // (`new-main/index.tsx:2024-2028`): the picker owns the screen; the main @@ -1148,7 +1251,10 @@ pub fn open_editor_target_overlays(editor_path: PathBuf, request: OverlayRequest return; } - open_overlays_core(request, cx); + if !open_overlays_core(request, cx) { + reject_unavailable_window(cx); + return; + } let key = editor_key(&editor_path); if cx.global::().editor_hidden_for_picker.as_ref() != Some(&key) { @@ -1157,14 +1263,24 @@ pub fn open_editor_target_overlays(editor_path: PathBuf, request: OverlayRequest } } -fn open_overlays_core(request: OverlayRequest, cx: &mut App) { +fn open_overlays_core(request: OverlayRequest, cx: &mut App) -> bool { + let pinned = request.pinned_window.as_ref().and_then(resolve_window); + if request.mode == TargetType::Window + && !pinned_window_resolution_matches( + request.pinned_window.as_ref(), + pinned.as_ref().map(|window| &window.id), + ) + { + tracing::warn!(window = ?request.pinned_window, "selected window could not be resolved"); + return false; + } + let select = TargetSelect::global(cx); let mode_changed = select.read(cx).mode != Some(request.mode); if mode_changed { close_overlay_windows(cx); } - let pinned = request.pinned_window.as_ref().and_then(resolve_window); let display = request .display .clone() @@ -1233,6 +1349,7 @@ fn open_overlays_core(request: OverlayRequest, cx: &mut App) { // and the overlays non-activating, a plain key handler has nothing to be // delivered to. platform::register_escape_hotkey(); + true } /// Close the overlays and clear the main window's armed target -- Escape, the @@ -1984,7 +2101,7 @@ pub fn open_camera_window(cx: &mut App) { match handle { Ok(handle) => { cx.global_mut::().camera = Some(handle); - handle + let native = handle .update(cx, |_, window, _| { platform::apply_panel_behavior( window, @@ -1999,8 +2116,11 @@ pub fn open_camera_window(cx: &mut App) { }, ); platform::show_window_without_focus(window); + platform::native_window(window) }) - .ok(); + .ok() + .flatten(); + remove_popup_window_chrome(native, cx); } Err(error) => tracing::error!("camera window failed to open: {error:#}"), } @@ -2213,7 +2333,11 @@ fn revert_camera_park(cx: &mut App) { /// Hand a camera frame to the preview window. Returns false when no window is /// open (the pump drops the frame and keeps draining). -pub fn deliver_camera_frame(frame: cap_recording::NativeCameraFrame, cx: &mut App) -> bool { +pub fn deliver_camera_frame( + #[cfg(target_os = "macos")] frame: cap_recording::NativeCameraFrame, + #[cfg(not(target_os = "macos"))] frame: crate::camera_window::CameraPreviewFrame, + cx: &mut App, +) -> bool { let Some(handle) = cx.global::().camera else { return false; }; @@ -2267,7 +2391,7 @@ pub fn open_editor(project_path: PathBuf, cx: &mut App) { } }) .detach(); - hide_main_window(cx); + hide_main_and_park_camera_preview(cx); return; } @@ -2343,7 +2467,7 @@ pub fn open_editor(project_path: PathBuf, cx: &mut App) { }) .ok(); - hide_main_window(cx); + hide_main_and_park_camera_preview(cx); load_editor_project(key, handle, cx); } @@ -2380,6 +2504,7 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &m "editor project validated" ); log_timeline_model(&summary.timeline); + let recordings = summary.recordings.clone(); if handle .update(cx, |view, window, cx| view.set_summary(summary, window, cx)) .is_err() @@ -2390,7 +2515,7 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &m // The frame seam. Bounded and try_send-only: the renderer is already // latest-wins (`editor.rs:242-312`), so a full queue means the UI is // behind and the newest frame is the one that matters. - let (frame_tx, frame_rx) = flume::bounded(2); + let (frame_tx, frame_rx) = flume::bounded(4); let stats = Arc::new(editor_window::PumpStats::default()); // The playhead seam. `on_state_change` is called from the // `cap-playback` OS thread and from tokio workers, so it may only @@ -2422,29 +2547,23 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &m let frame_format = cap_editor::EditorFrameFormat::BgraSurface; #[cfg(not(target_os = "macos"))] let frame_format = cap_editor::EditorFrameFormat::Rgba; - if std::env::var("CAP_GPUI_MUTE_AUDIO").is_ok_and(|v| v == "1") { - let silent = std::sync::Arc::new(cap_editor::AudioOutput::new_headless( - Box::new(|_samples, _at| {}), - )); - cap_editor::EditorInstance::new_with_audio_output_and_frame_format( - instance_path, - state_cb, - frame_cb, - None, - frame_format, - silent, - ) - .await + let audio_output = if std::env::var("CAP_GPUI_MUTE_AUDIO").is_ok_and(|v| v == "1") { + std::sync::Arc::new(cap_editor::AudioOutput::new_headless(Box::new( + |_samples, _at| {}, + ))) } else { - cap_editor::EditorInstance::new_with_frame_format( - instance_path, - state_cb, - frame_cb, - None, - frame_format, - ) - .await - } + std::sync::Arc::new(cap_editor::AudioOutput::new()) + }; + cap_editor::EditorInstance::new_with_preloaded_recordings( + instance_path, + state_cb, + frame_cb, + None, + frame_format, + audio_output, + recordings, + ) + .await }) }); @@ -2644,10 +2763,42 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &m drive_auto_sidebar(handle, cx).await; drive_auto_playback(path, handle, cx).await; + drive_auto_export(handle, cx).await; }) .detach(); } +async fn drive_auto_export(handle: WindowHandle, cx: &mut gpui::AsyncApp) { + let Some(path) = std::env::var_os("CAP_GPUI_AUTO_EXPORT").map(PathBuf::from) else { + return; + }; + + cx.background_executor() + .timer(std::time::Duration::from_millis(300)) + .await; + let _ = handle.update(cx, |view, window, cx| { + view.open_export(window, cx); + if let Some(export) = view.export.as_mut() { + export.destination = crate::editor_export::ExportDestination::File; + export.format = if path.extension().is_some_and(|extension| extension == "gif") { + crate::editor_export::ExportFormatKind::Gif + } else { + crate::editor_export::ExportFormatKind::Mp4 + }; + if export.format == crate::editor_export::ExportFormatKind::Gif { + if export.resolution == crate::editor_export::ExportResolution::P4k { + export.resolution = crate::editor_export::ExportResolution::P1080; + } + if export.fps > 30 { + export.fps = 30; + } + } + } + tracing::info!(path = %path.display(), "auto editor export requested"); + view.start_export(window, cx); + }); +} + /// One line per timeline load naming every row and its segment count. The /// track set is derived from the project's own content, so this is how a /// fixture is checked to have actually deserialised rather than falling back @@ -3318,7 +3469,7 @@ fn open_controls( // Panel treatment AFTER open_window returns: inside the builder // closure the platform window is not finished and gpui's own // PopUp setup would override the level. - let number = handle + let (number, native) = handle .update(cx, |_, window, _| { platform::apply_panel_behavior( window, @@ -3333,10 +3484,14 @@ fn open_controls( // `order_front_regardless` in the Tauri flow: front without // taking key status from the app being recorded. platform::show_window_without_focus(window); - platform::window_number(window) + ( + platform::window_number(window), + platform::native_window(window), + ) }) - .ok() - .flatten()?; + .ok()?; + remove_popup_window_chrome(native, cx); + let number = number?; number.to_string().parse().ok() } Err(error) => { @@ -3604,6 +3759,37 @@ mod tests { assert!(!reveal_main_after_editor_close(0, false, false)); } + #[test] + fn active_or_pending_recordings_never_park_the_camera_preview() { + assert!(camera_preview_can_be_parked(Phase::Idle)); + assert!(!camera_preview_can_be_parked(Phase::Starting)); + assert!(!camera_preview_can_be_parked(Phase::Recording { + paused: false, + })); + assert!(!camera_preview_can_be_parked(Phase::Recording { + paused: true, + })); + assert!(!camera_preview_can_be_parked(Phase::Stopping)); + } + + #[test] + fn pinned_window_never_falls_back_to_another_target() { + let selected = "41".parse::().unwrap(); + let other = "42".parse::().unwrap(); + + assert!(pinned_window_resolution_matches(None, None)); + assert!(pinned_window_resolution_matches(None, Some(&other))); + assert!(pinned_window_resolution_matches( + Some(&selected), + Some(&selected) + )); + assert!(!pinned_window_resolution_matches(Some(&selected), None)); + assert!(!pinned_window_resolution_matches( + Some(&selected), + Some(&other) + )); + } + fn title_rule(title: &str) -> WindowExclusion { WindowExclusion { window_title: Some(title.to_string()), diff --git a/apps/desktop-gpui/src/auth.rs b/apps/desktop-gpui/src/auth.rs index 57f63ab67e6..9bff872697f 100644 --- a/apps/desktop-gpui/src/auth.rs +++ b/apps/desktop-gpui/src/auth.rs @@ -248,10 +248,10 @@ pub async fn update_auth_plan() -> Result<(), AuthApiError> { Ok(response) => tracing::warn!("Organizations fetch returned {}", response.status()), Err(error) => { tracing::warn!("Failed to fetch organizations: {error}"); - if !auth + if auth .get("organizations") .and_then(Value::as_array) - .is_some_and(|entries| !entries.is_empty()) + .is_none_or(Vec::is_empty) { auth.insert( "organizations_updated_at".into(), diff --git a/apps/desktop-gpui/src/camera_blur_portable.rs b/apps/desktop-gpui/src/camera_blur_portable.rs new file mode 100644 index 00000000000..85fa7b45051 --- /dev/null +++ b/apps/desktop-gpui/src/camera_blur_portable.rs @@ -0,0 +1,410 @@ +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use anyhow::{Context as _, anyhow}; + +pub const MAX_DIMS: (u32, u32) = (640, 360); +const INFERENCE_INTERVAL: Duration = Duration::from_millis(150); +const LOW_MEMORY_THRESHOLD_BYTES: u64 = 8 * 1024 * 1024 * 1024; + +pub fn blur_allowed() -> bool { + static ALLOWED: OnceLock = OnceLock::new(); + *ALLOWED + .get_or_init(|| total_memory_bytes().is_none_or(|bytes| bytes > LOW_MEMORY_THRESHOLD_BYTES)) +} + +#[cfg(unix)] +fn total_memory_bytes() -> Option { + let pages = u64::try_from(unsafe { libc::sysconf(libc::_SC_PHYS_PAGES) }).ok()?; + let page_size = u64::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) }).ok()?; + pages.checked_mul(page_size) +} + +#[cfg(windows)] +fn total_memory_bytes() -> Option { + use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX}; + + let mut status: MEMORYSTATUSEX = unsafe { std::mem::zeroed() }; + status.dwLength = std::mem::size_of::() as u32; + (unsafe { GlobalMemoryStatusEx(&mut status) } != 0).then_some(status.ullTotalPhys) +} + +pub fn fitted_dimensions(width: u32, height: u32, max: (u32, u32)) -> Option<(u32, u32)> { + if width == 0 || height == 0 { + return None; + } + + let scale = (max.0 as f64 / width as f64) + .min(max.1 as f64 / height as f64) + .min(1.0); + Some(( + ((width as f64 * scale).round() as u32).max(1), + ((height as f64 * scale).round() as u32).max(1), + )) +} + +pub struct PortableCameraBlur { + device: wgpu::Device, + queue: wgpu::Queue, + processor: cap_camera_effects::BlurProcessor, + source: Option, + readback: Option, + dimensions: Option<(u32, u32)>, + padded_bytes_per_row: u32, + rgba: Vec, +} + +impl PortableCameraBlur { + pub fn new() -> anyhow::Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .context("camera blur runtime")?; + let (device, queue) = runtime.block_on(async { + let instance = cap_rendering::create_wgpu_instance_sync(); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + force_fallback_adapter: cap_rendering::force_software_wgpu_adapter(), + compatible_surface: None, + }) + .await + .map_err(|error| anyhow!("camera blur adapter: {error}"))?; + adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("Camera Preview Blur"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_webgl2_defaults() + .using_resolution(adapter.limits()), + memory_hints: Default::default(), + trace: wgpu::Trace::Off, + }) + .await + .map_err(|error| anyhow!("camera blur device: {error}")) + })?; + let mut processor = + cap_camera_effects::BlurProcessor::new(&device, wgpu::TextureFormat::Rgba8Unorm) + .context("camera blur processor")?; + processor.set_inference_interval(INFERENCE_INTERVAL); + + Ok(Self { + device, + queue, + processor, + source: None, + readback: None, + dimensions: None, + padded_bytes_per_row: 0, + rgba: Vec::new(), + }) + } + + pub fn process( + &mut self, + image: &gpui::RenderImage, + dimensions: (usize, usize), + mode: cap_camera_effects::BlurMode, + ) -> anyhow::Result> { + let width = u32::try_from(dimensions.0).context("camera frame width")?; + let height = u32::try_from(dimensions.1).context("camera frame height")?; + let row_bytes = width.checked_mul(4).context("camera frame row size")?; + let expected_bytes = usize::try_from(row_bytes) + .ok() + .and_then(|row| row.checked_mul(dimensions.1)) + .context("camera frame size")?; + let bgra = image + .as_bytes(0) + .context("camera frame pixels unavailable")?; + if bgra.len() != expected_bytes { + anyhow::bail!("camera frame pixel data does not match its dimensions"); + } + + self.ensure_resources(width, height)?; + self.rgba.clear(); + self.rgba.reserve(expected_bytes); + for pixel in bgra.chunks_exact(4) { + self.rgba + .extend_from_slice(&[pixel[2], pixel[1], pixel[0], pixel[3]]); + } + + let source = self.source.as_ref().context("camera blur source missing")?; + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: source, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &self.rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(row_bytes), + rows_per_image: Some(height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Camera Preview Blur"), + }); + self.processor + .process_into_encoder(&self.device, &self.queue, source, &mut encoder, mode); + let output = self + .processor + .process_returning_output() + .context("camera blur output missing")?; + let readback = self + .readback + .as_ref() + .context("camera blur readback missing")?; + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: output, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(self.padded_bytes_per_row), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + self.queue.submit(std::iter::once(encoder.finish())); + + let (sender, receiver) = flume::bounded(1); + readback + .slice(..) + .map_async(wgpu::MapMode::Read, move |result| { + let _ = sender.send(result); + }); + self.device + .poll(wgpu::PollType::Wait) + .map_err(|error| anyhow!("camera blur GPU polling: {error}"))?; + receiver + .recv() + .context("camera blur GPU readback channel")? + .map_err(|error| anyhow!("camera blur GPU readback: {error}"))?; + + let mut pixels = Vec::with_capacity(expected_bytes); + { + let mapped = readback.slice(..).get_mapped_range(); + let padded = self.padded_bytes_per_row as usize; + let row = row_bytes as usize; + for source in mapped.chunks_exact(padded).take(height as usize) { + for pixel in source[..row].chunks_exact(4) { + pixels.extend_from_slice(&[pixel[2], pixel[1], pixel[0], pixel[3]]); + } + } + } + readback.unmap(); + + let image = image::RgbaImage::from_raw(width, height, pixels) + .context("camera blur output image")?; + Ok(Arc::new(gpui::RenderImage::new(smallvec::smallvec![ + image::Frame::new(image) + ]))) + } + + fn ensure_resources(&mut self, width: u32, height: u32) -> anyhow::Result<()> { + if self.dimensions == Some((width, height)) { + return Ok(()); + } + + let row_bytes = width.checked_mul(4).context("camera blur row size")?; + let alignment = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let padded_bytes_per_row = row_bytes + .checked_add(alignment - 1) + .context("camera blur row alignment")? + / alignment + * alignment; + let readback_size = u64::from(padded_bytes_per_row) + .checked_mul(u64::from(height)) + .context("camera blur readback size")?; + self.source = Some(self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("Camera Preview Blur Source"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + })); + self.readback = Some(self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Camera Preview Blur Readback"), + size: readback_size, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + })); + self.dimensions = Some((width, height)); + self.padded_bytes_per_row = padded_bytes_per_row; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blur_dimensions_are_bounded_without_upscaling() { + assert_eq!(fitted_dimensions(1920, 1080, MAX_DIMS), Some((640, 360))); + assert_eq!(fitted_dimensions(1280, 720, MAX_DIMS), Some((640, 360))); + assert_eq!(fitted_dimensions(640, 480, MAX_DIMS), Some((480, 360))); + assert_eq!(fitted_dimensions(320, 180, MAX_DIMS), Some((320, 180))); + assert_eq!(fitted_dimensions(0, 180, MAX_DIMS), None); + assert_eq!(fitted_dimensions(320, 0, MAX_DIMS), None); + } + + #[test] + fn low_memory_devices_skip_gpu_blur() { + assert_eq!( + blur_allowed(), + total_memory_bytes().is_none_or(|bytes| bytes > LOW_MEMORY_THRESHOLD_BYTES) + ); + } + + #[test] + fn portable_blur_processes_bgra_frames_without_changing_dimensions() { + #[cfg(not(target_os = "macos"))] + if std::env::var_os("CAP_GPUI_TEST_PORTABLE_BLUR").is_none() { + return; + } + + let mut pixels = Vec::with_capacity(64 * 32 * 4); + for y in 0..32u8 { + for x in 0..64u8 { + pixels.extend_from_slice(&[x * 3, y * 5, x.saturating_add(y), 255]); + } + } + let image = image::RgbaImage::from_raw(64, 32, pixels).unwrap(); + let input = gpui::RenderImage::new(smallvec::smallvec![image::Frame::new(image)]); + let mut processor = PortableCameraBlur::new().expect("portable camera blur initialized"); + let output = processor + .process(&input, (64, 32), cap_camera_effects::BlurMode::Heavy) + .expect("portable camera blur processed a frame"); + + assert_eq!(output.size(0).width.0, 64); + assert_eq!(output.size(0).height.0, 32); + assert_eq!(output.as_bytes(0).unwrap().len(), 64 * 32 * 4); + assert!( + output + .as_bytes(0) + .unwrap() + .chunks_exact(4) + .all(|pixel| pixel[3] == 255) + ); + + let staging = processor.rgba.as_ptr(); + processor + .process(&input, (64, 32), cap_camera_effects::BlurMode::Light) + .expect("portable camera blur reuses GPU resources"); + assert_eq!(processor.rgba.as_ptr(), staging); + assert_eq!(processor.dimensions, Some((64, 32))); + + let image = image::RgbaImage::from_pixel(640, 360, image::Rgba([32, 96, 160, 255])); + let preview = gpui::RenderImage::new(smallvec::smallvec![image::Frame::new(image)]); + let started = std::time::Instant::now(); + for _ in 0..12 { + let output = processor + .process(&preview, (640, 360), cap_camera_effects::BlurMode::Heavy) + .expect("portable camera blur processed a full-size preview frame"); + assert_eq!(output.as_bytes(0).unwrap().len(), 640 * 360 * 4); + } + + eprintln!( + "portable heavy-blur preview: {:.1} frames/s, staging={} bytes, readback={} bytes", + 12.0 / started.elapsed().as_secs_f64(), + processor.rgba.capacity(), + processor.readback.as_ref().unwrap().size() + ); + assert_eq!(processor.dimensions, Some((640, 360))); + assert_eq!(processor.readback.as_ref().unwrap().size(), 640 * 360 * 4); + } + + #[cfg(target_os = "macos")] + #[test] + fn portable_blur_processes_real_webcam_frames_when_requested() { + if std::env::var_os("CAP_GPUI_TEST_REAL_CAMERA").is_none() { + return; + } + + use cap_recording::feeds::camera::{ + AddSender, CameraFeed, DeviceOrModelID, RemoveInput, SetInput, + }; + use kameo::Actor as _; + + let camera = cap_camera::list_cameras().next().expect("connected webcam"); + let camera_name = camera.display_name().to_string(); + let camera_id = DeviceOrModelID::from_info(&camera); + let mut processor = PortableCameraBlur::new().expect("portable camera blur initialized"); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_time() + .build() + .expect("camera runtime"); + + runtime.block_on(async { + let actor = CameraFeed::spawn(CameraFeed::default()); + let (sender, receiver) = flume::bounded(4); + actor.ask(AddSender(sender)).await.expect("camera sender"); + let ready = actor + .ask(SetInput { + id: camera_id, + settings: None, + }) + .await + .expect("camera selected"); + ready.await.expect("camera initialized"); + + let mut scaler = None; + let mut frames = 0; + let started = std::time::Instant::now(); + while frames < 12 { + let frame = tokio::time::timeout(Duration::from_secs(8), receiver.recv_async()) + .await + .expect("camera frame arrived") + .expect("camera frame available"); + let (preview, dimensions) = crate::feeds::camera_preview_image( + &frame.inner, + &mut scaler, + true, + Some(MAX_DIMS), + ) + .expect("camera preview converted"); + assert!(dimensions.0 <= MAX_DIMS.0 as usize); + assert!(dimensions.1 <= MAX_DIMS.1 as usize); + let blurred = processor + .process(&preview, dimensions, cap_camera_effects::BlurMode::Heavy) + .expect("real camera frame blurred"); + assert_eq!(blurred.as_bytes(0).unwrap().len(), dimensions.0 * dimensions.1 * 4); + frames += 1; + } + + eprintln!( + "portable heavy-blur real webcam: camera={camera_name}, frames={frames}, rate={:.1}/s, dimensions={:?}", + frames as f64 / started.elapsed().as_secs_f64(), + processor.dimensions + ); + actor.ask(RemoveInput).await.expect("camera released"); + }); + } +} diff --git a/apps/desktop-gpui/src/camera_window.rs b/apps/desktop-gpui/src/camera_window.rs index f2d9619dc50..6ab01a30fcf 100644 --- a/apps/desktop-gpui/src/camera_window.rs +++ b/apps/desktop-gpui/src/camera_window.rs @@ -48,6 +48,8 @@ use std::sync::{ }; use std::time::{Duration, Instant}; +#[cfg(not(target_os = "macos"))] +use gpui::StyledImage as _; use gpui::{ AppContext as _, Context, Entity, FontWeight, InteractiveElement as _, IntoElement, MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Render, @@ -376,6 +378,8 @@ struct CameraPreviewView { size: f32, #[cfg(target_os = "macos")] latest_frame: Option, + #[cfg(not(target_os = "macos"))] + latest_frame: Option>, frame_dims: Option<(usize, usize)>, /// Bumped by the canvas paint callback; the parent's cadence log reads it /// to prove notify-driven repaints actually present. @@ -411,6 +415,8 @@ impl CameraPreviewView { size, #[cfg(target_os = "macos")] latest_frame: None, + #[cfg(not(target_os = "macos"))] + latest_frame: None, frame_dims: None, paints, camera_error, @@ -430,6 +436,18 @@ impl CameraPreviewView { cx.notify(); } + #[cfg(not(target_os = "macos"))] + fn set_frame( + &mut self, + frame: Arc, + dims: (usize, usize), + cx: &mut Context, + ) { + self.latest_frame = Some(frame); + self.frame_dims = Some(dims); + cx.notify(); + } + fn set_chrome(&mut self, radius: f32, size: f32, cx: &mut Context) { if self.radius != radius || self.size != size { self.radius = radius; @@ -485,10 +503,17 @@ impl Render for CameraPreviewView { ); } - #[cfg(target_os = "macos")] - let showing_frame = self.latest_frame.is_some(); #[cfg(not(target_os = "macos"))] - let showing_frame = false; + if let Some(image) = self.latest_frame.clone() { + self.paints.fetch_add(1, Ordering::Relaxed); + container = container.child( + gpui::img(image) + .size_full() + .object_fit(gpui::ObjectFit::Cover), + ); + } + + let showing_frame = self.latest_frame.is_some(); if !showing_frame { container = container.child( @@ -551,6 +576,12 @@ impl Render for CameraPreviewView { } } +#[cfg(not(target_os = "macos"))] +pub struct CameraPreviewFrame { + pub image: Arc, + pub dims: (usize, usize), +} + /// The chrome half: renders the parent's toolbar, re-rendered only when the /// parent notifies (same shape as the editor's `EditorSectionView`). struct CameraToolbarView { @@ -631,6 +662,10 @@ impl CameraWindow { platform::apply_window_theme(window, platform::ForcedAppearance::Dark); let theme = Theme::dark(); let state = store::load().camera_window.unwrap_or_default(); + #[cfg(not(target_os = "macos"))] + Feeds::global(cx).update(cx, |feeds, _| { + feeds.set_camera_preview_state(state.mirrored, state.background_blur) + }); let paints = Arc::new(AtomicU32::new(0)); let preview = cx.new({ let paints = paints.clone(); @@ -679,7 +714,8 @@ impl CameraWindow { /// explicit ask (unit-2 finding). pub fn frame_arrived( &mut self, - frame: cap_recording::NativeCameraFrame, + #[cfg(target_os = "macos")] frame: cap_recording::NativeCameraFrame, + #[cfg(not(target_os = "macos"))] frame: CameraPreviewFrame, window: &mut Window, cx: &mut Context, ) { @@ -751,7 +787,19 @@ impl CameraWindow { } #[cfg(not(target_os = "macos"))] { - let _ = (frame, window, cx); + 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| { + preview.set_frame(frame.image, frame.dims, cx) + }); + if dims_changed { + self.apply_window_size(window, cx); + cx.notify(); + } + if first_frame && !window.is_window_active() { + window.refresh(); + } } self.frames_in_window += 1; @@ -890,6 +938,10 @@ impl CameraWindow { let blur_before = self.state.background_blur; mutate(&mut self.state); self.state.size = clamp_size(self.state.size); + #[cfg(not(target_os = "macos"))] + Feeds::global(cx).update(cx, |feeds, _| { + feeds.set_camera_preview_state(self.state.mirrored, self.state.background_blur) + }); #[cfg(target_os = "macos")] { if self.state.background_blur != blur_before { @@ -1386,12 +1438,20 @@ impl Render for CameraWindow { ) .when(resizing, |this| { this.on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, cx| { - this.handle_resize_move(event, window, cx); + if event.dragging() { + this.handle_resize_move(event, window, cx); + } else { + this.end_resize(cx); + } })) .on_mouse_up( MouseButton::Left, cx.listener(|this, _: &MouseUpEvent, _, cx| this.end_resize(cx)), ) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _: &MouseUpEvent, _, cx| this.end_resize(cx)), + ) }) .child( self.toolbar.clone().cached( diff --git a/apps/desktop-gpui/src/controls_window.rs b/apps/desktop-gpui/src/controls_window.rs index d2588b4c479..50f6af5ef2c 100644 --- a/apps/desktop-gpui/src/controls_window.rs +++ b/apps/desktop-gpui/src/controls_window.rs @@ -31,6 +31,12 @@ pub struct ControlsWindow { _tick: gpui::Task<()>, } +#[derive(Clone, Copy)] +enum DestructiveAction { + Restart, + Delete, +} + impl ControlsWindow { pub fn new( session: Entity, @@ -213,6 +219,41 @@ impl ControlsWindow { }) } + fn confirm_action( + &mut self, + action: DestructiveAction, + window: &mut Window, + cx: &mut Context, + ) { + let (title, message, accept) = match action { + DestructiveAction::Restart => ( + "Confirm Restart", + "Are you sure you want to restart the recording? The current recording will be discarded.", + "Restart", + ), + DestructiveAction::Delete => ( + "Confirm Delete", + "Are you sure you want to delete the recording?", + "Delete", + ), + }; + + cx.spawn_in(window, async move |this, cx| { + if !crate::platform::confirm_dialog(title, message, accept, "Cancel", true) { + return; + } + + this.update_in(cx, |this, _, cx| { + this.session.update(cx, |session, cx| match action { + DestructiveAction::Restart => session.restart(cx), + DestructiveAction::Delete => session.delete(cx), + }); + }) + .ok(); + }) + .detach(); + } + fn render_bar(&mut self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; let session = self.session.read(cx); @@ -273,17 +314,20 @@ impl ControlsWindow { .child( self.action_button("restart", "icons/restart.svg", busy) .when(!busy, |this| { - this.on_click(cx.listener(|this, _, _, cx| { - this.session - .update(cx, |session, cx| session.restart(cx)); + this.on_click(cx.listener(|this, _, window, cx| { + this.confirm_action( + DestructiveAction::Restart, + window, + cx, + ); })) }), ) .child(self.action_button("delete", "icons/trash.svg", busy).when( !busy, |this| { - this.on_click(cx.listener(|this, _, _, cx| { - this.session.update(cx, |session, cx| session.delete(cx)); + this.on_click(cx.listener(|this, _, window, cx| { + this.confirm_action(DestructiveAction::Delete, window, cx); })) }, )) @@ -297,6 +341,7 @@ impl ControlsWindow { .flex() .items_center() .justify_center() + .rounded_r(px(15.)) .border_l_1() .border_color(theme.gray_5) .p(px(4.)) diff --git a/apps/desktop-gpui/src/deeplink.rs b/apps/desktop-gpui/src/deeplink.rs index 6a54b5618cd..a25a98299b6 100644 --- a/apps/desktop-gpui/src/deeplink.rs +++ b/apps/desktop-gpui/src/deeplink.rs @@ -208,6 +208,7 @@ fn channel() -> &'static ( /// slot only exists during an active sign-in and filters for its own /// `token`/`api_key` parameters -- the pre-existing behavior of the handler, /// unchanged. Action parsing happens on top. +#[cfg(any(target_os = "macos", target_os = "windows"))] pub fn submit_deep_link(raw: &str) { crate::auth::submit_deep_link(raw); submit_action_url(raw); @@ -364,6 +365,7 @@ impl DeepLinkAction { cx.defer(move |cx| { let (camera_feed, mic_feed) = { let feeds = Feeds::global(cx); + feeds.update(cx, |feeds, cx| feeds.resume_camera_preview(cx)); let feeds = feeds.read(cx); (feeds.camera_actor(), feeds.mic_actor()) }; diff --git a/apps/desktop-gpui/src/editor_audio.rs b/apps/desktop-gpui/src/editor_audio.rs index 3a108eff0ee..86005040683 100644 --- a/apps/desktop-gpui/src/editor_audio.rs +++ b/apps/desktop-gpui/src/editor_audio.rs @@ -30,13 +30,29 @@ pub enum AudioPicker { } pub fn bundled_track_path(id: &str) -> Option { - let file = format!("{id}.mp3"); let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let candidates = [ + bundled_track_path_from(id, &crate::store::bundled_resource_dirs(), &manifest) +} + +fn bundled_track_path_from( + id: &str, + resource_dirs: &[PathBuf], + manifest: &Path, +) -> Option { + if !AUDIO_LIBRARY.iter().any(|(known, _)| *known == id) { + return None; + } + + let file = format!("{id}.mp3"); + let mut candidates = resource_dirs + .iter() + .map(|directory| directory.join("assets/music").join(&file)) + .collect::>(); + candidates.extend([ manifest.join("../desktop/src/assets/music").join(&file), manifest.join("assets/music").join(&file), - ]; - candidates.into_iter().find(|path| path.exists()) + ]); + candidates.into_iter().find(|path| path.is_file()) } pub fn copy_library_track( @@ -184,3 +200,42 @@ fn render_library_row( ) .into_any_element() } + +#[cfg(test)] +mod tests { + use super::{AUDIO_LIBRARY, bundled_track_path, bundled_track_path_from}; + + #[test] + fn built_in_music_resolves_from_an_installed_bundle() { + let root = + std::env::temp_dir().join(format!("cap-gpui-installed-music-{}", std::process::id())); + let resources = root.join("Cap.app/Contents/Resources"); + let music = resources.join("assets/music"); + std::fs::create_dir_all(&music).unwrap(); + let track = music.join("lofi-beats-mirostar.mp3"); + std::fs::write(&track, b"test track").unwrap(); + + assert_eq!( + bundled_track_path_from("lofi-beats-mirostar", &[resources], &root.join("missing")), + Some(track) + ); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn built_in_music_remains_available_from_the_development_checkout() { + for (id, _) in AUDIO_LIBRARY { + assert!( + bundled_track_path(id).is_some(), + "missing bundled track {id}" + ); + } + } + + #[test] + fn built_in_music_rejects_unknown_and_traversal_identifiers() { + assert_eq!(bundled_track_path("unknown-track"), None); + assert_eq!(bundled_track_path("../lofi-beats-mirostar"), None); + } +} diff --git a/apps/desktop-gpui/src/editor_canvas.rs b/apps/desktop-gpui/src/editor_canvas.rs index 8de3668584b..35274a20958 100644 --- a/apps/desktop-gpui/src/editor_canvas.rs +++ b/apps/desktop-gpui/src/editor_canvas.rs @@ -601,7 +601,7 @@ impl EditorWindow { if exclude == CanvasSelection::Text(index) { continue; } - if !(t >= segment.start && t < segment.end) || !segment.enabled { + if !(t >= segment.start && t < segment.end && segment.enabled) { continue; } let center = segment.center; @@ -1173,7 +1173,7 @@ impl EditorWindow { } } for (index, segment) in timeline.text_segments.iter().enumerate() { - if !(time >= segment.start && time < segment.end) || !segment.enabled { + if !(time >= segment.start && time < segment.end && segment.enabled) { continue; } let element = CanvasSelection::Text(index); diff --git a/apps/desktop-gpui/src/editor_edits.rs b/apps/desktop-gpui/src/editor_edits.rs index ea6944896b0..7d3b89770cc 100644 --- a/apps/desktop-gpui/src/editor_edits.rs +++ b/apps/desktop-gpui/src/editor_edits.rs @@ -1894,6 +1894,8 @@ mod tests { use super::*; use crate::editor_timeline::{SegmentDetail, TimelineModel}; + const OVERLAPPING_SEGMENT_END: f64 = 314.0 / 100.0; + fn config(json: serde_json::Value) -> ProjectConfiguration { serde_json::from_value(json).expect("fixture parses") } @@ -2333,7 +2335,7 @@ mod tests { let overlapping = config(serde_json::json!({ "timeline": { "segments": [ - { "recordingSegment": 0, "timescale": 1.0, "start": 2.54, "end": 3.14 }, + { "recordingSegment": 0, "timescale": 1.0, "start": 2.54, "end": OVERLAPPING_SEGMENT_END }, { "recordingSegment": 0, "timescale": 1.0, "start": 2.14, "end": 3.29 }, { "recordingSegment": 0, "timescale": 1.0, "start": 3.29, "end": 6.68 }, { "recordingSegment": 0, "timescale": 1.0, "start": 5.83, "end": 6.98 } @@ -2398,7 +2400,7 @@ mod tests { let overlapping = config(serde_json::json!({ "timeline": { "segments": [ - { "recordingSegment": 0, "timescale": 1.0, "start": 2.54, "end": 3.14 }, + { "recordingSegment": 0, "timescale": 1.0, "start": 2.54, "end": OVERLAPPING_SEGMENT_END }, { "recordingSegment": 0, "timescale": 1.0, "start": 2.14, "end": 3.29 } ], "zoomSegments": [] @@ -2411,7 +2413,7 @@ mod tests { assert_eq!( clip_trim_end(timeline, 0, 2.6, spp, &displays, recording), - Some(3.14) + Some(OVERLAPPING_SEGMENT_END) ); assert_eq!( clip_trim_start(timeline, 0, 3.0, spp, &displays, recording), diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index 69febb468a6..bb684f7a438 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -140,7 +140,7 @@ pub enum ExportPhase { } impl ExportPhase { - fn is_busy(self) -> bool { + pub(crate) fn is_busy(self) -> bool { matches!( self, Self::Starting | Self::Rendering | Self::Copying | Self::Uploading @@ -473,7 +473,7 @@ impl EditorWindow { })); } - fn start_export(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn start_export(&mut self, window: &mut Window, cx: &mut Context) { let pretty_name = self .summary() .map(|summary| summary.pretty_name.clone()) @@ -528,7 +528,9 @@ impl EditorWindow { "mp4" }; let default = format!("{pretty_name}.{ext}"); - let chosen = platform::save_file_panel(&default, &[ext]); + let chosen = std::env::var_os("CAP_GPUI_AUTO_EXPORT") + .map(PathBuf::from) + .or_else(|| platform::save_file_panel(&default, &[ext])); if chosen.is_none() { let _ = this.update(cx, |this, cx| { if let Some(ui) = this.export.as_mut() { @@ -593,6 +595,7 @@ impl EditorWindow { match export.await { Ok(Ok(path)) => { + tracing::info!(path = %path.display(), "editor export completed"); if destination == ExportDestination::Clipboard { let _ = this.update(cx, |this, cx| { if let Some(ui) = this.export.as_mut() { @@ -633,6 +636,7 @@ impl EditorWindow { } } Ok(Err(error)) => { + tracing::error!(error, "editor export failed"); let cancelled = error == "Export cancelled" || cancel.load(Ordering::Relaxed); let _ = this.update(cx, |this, cx| { if let Some(ui) = this.export.as_mut() { diff --git a/apps/desktop-gpui/src/editor_sidebar.rs b/apps/desktop-gpui/src/editor_sidebar.rs index b7c431c1fca..366ec90dcc6 100644 --- a/apps/desktop-gpui/src/editor_sidebar.rs +++ b/apps/desktop-gpui/src/editor_sidebar.rs @@ -462,24 +462,34 @@ pub fn wallpapers_for_theme(theme: &str) -> Vec<&'static str> { /// installed Cap.app (whose paths are byte-identical to what the shipping app /// would write) and falling back to the repository the dev build runs from. pub fn wallpaper_dir() -> Option { - if let Ok(path) = std::env::var("CAP_GPUI_WALLPAPERS_DIR") { - let path = PathBuf::from(path); - if path.is_dir() { - return Some(path); - } - } + let override_dir = std::env::var_os("CAP_GPUI_WALLPAPERS_DIR").map(PathBuf::from); + wallpaper_dir_from( + &crate::store::bundled_resource_dirs(), + override_dir.as_deref(), + Path::new(env!("CARGO_MANIFEST_DIR")), + ) +} - let mut candidates = Vec::with_capacity(3); - if let Ok(executable) = std::env::current_exe() - && let Some(contents) = executable.parent().and_then(Path::parent) +fn wallpaper_dir_from( + resource_dirs: &[PathBuf], + override_dir: Option<&Path>, + manifest: &Path, +) -> Option { + if let Some(path) = override_dir + && path.is_dir() { - candidates.push(contents.join("Resources/assets/backgrounds")); + return Some(path.to_path_buf()); } + + let mut candidates = resource_dirs + .iter() + .map(|directory| directory.join("assets/backgrounds")) + .collect::>(); candidates.push(PathBuf::from( "/Applications/Cap.app/Contents/Resources/assets/backgrounds", )); candidates.push( - PathBuf::from(env!("CARGO_MANIFEST_DIR")) + manifest .join("../desktop/src-tauri/assets/backgrounds") .clean(), ); @@ -510,6 +520,9 @@ impl Clean for PathBuf { } pub fn wallpaper_path(id: &str) -> Option { + if !WALLPAPER_NAMES.contains(&id) { + return None; + } let path = wallpaper_dir()?.join(format!("{id}.jpg")); path.is_file().then_some(path) } @@ -1699,8 +1712,18 @@ impl EditorWindow { fn decode_scaled_rgba(path: &Path, max: u32) -> Option { let bytes = std::fs::read(path).ok()?; let format = image::guess_format(&bytes).ok()?; - let decoded = image::load_from_memory_with_format(&bytes, format).ok()?; - let (width, height) = (decoded.width().max(1), decoded.height().max(1)); + let (decoded, width, height) = if format == image::ImageFormat::Jpeg { + decode_jpeg_thumbnail(&bytes, max).or_else(|| { + let decoded = image::load_from_memory_with_format(&bytes, format).ok()?; + let dimensions = (decoded.width(), decoded.height()); + Some((decoded, dimensions.0, dimensions.1)) + })? + } else { + let decoded = image::load_from_memory_with_format(&bytes, format).ok()?; + let dimensions = (decoded.width(), decoded.height()); + (decoded, dimensions.0, dimensions.1) + }; + let (width, height) = (width.max(1), height.max(1)); let scale = (max as f32 / width.max(height) as f32).min(1.); let target_width = ((width as f32 * scale).round() as u32).max(1); let target_height = ((height as f32 * scale).round() as u32).max(1); @@ -1724,6 +1747,25 @@ fn decode_scaled_rgba(path: &Path, max: u32) -> Option { }) } +fn decode_jpeg_thumbnail(bytes: &[u8], max: u32) -> Option<(image::DynamicImage, u32, u32)> { + let mut decoder = jpeg_decoder::Decoder::new(bytes); + decoder.read_info().ok()?; + let info = decoder.info()?; + let requested = max.clamp(1, u16::MAX as u32) as u16; + let (width, height) = decoder.scale(requested, requested).ok()?; + let pixels = decoder.decode().ok()?; + let decoded = match info.pixel_format { + jpeg_decoder::PixelFormat::RGB24 => image::DynamicImage::ImageRgb8( + image::RgbImage::from_raw(width as u32, height as u32, pixels)?, + ), + jpeg_decoder::PixelFormat::L8 => image::DynamicImage::ImageLuma8( + image::GrayImage::from_raw(width as u32, height as u32, pixels)?, + ), + _ => return None, + }; + Some((decoded, info.width as u32, info.height as u32)) +} + /// [`decode_scaled_rgba`] in gpui's BGRA order. fn decode_scaled(path: &Path, max: u32) -> Option> { decode_scaled_rgba(path, max).map(library::rgba_to_render_image) @@ -1931,7 +1973,7 @@ impl EditorWindow { } self.sidebar.wallpaper_task = Some(cx.spawn_in(window, async move |this, cx| { let (_decodes, results) = - library::spawn_decode_pool(cx.background_executor(), wanted, |id| { + library::spawn_decode_pool_limited(cx.background_executor(), wanted, 2, |id| { decode_wallpaper_thumbnail(id).map(|image| (id, image)) }); while let Ok(first) = results.recv_async().await { @@ -3904,6 +3946,69 @@ mod tests { assert_eq!(wallpapers_for_theme("orange").len(), 9); } + #[test] + fn built_in_wallpapers_remain_available_from_the_development_checkout() { + assert!(wallpaper_path("macOS/sequoia-dark").is_some()); + } + + #[test] + fn wallpaper_jpegs_decode_at_thumbnail_dimensions() { + let path = wallpaper_path("macOS/sequoia-dark").unwrap(); + let (width, height) = image::image_dimensions(&path).unwrap(); + let image = decode_scaled_rgba(&path, WALLPAPER_TILE_MAX).unwrap(); + + assert_eq!(image.width().max(image.height()), WALLPAPER_TILE_MAX); + assert!( + (image.width() as f64 / image.height() as f64 - width as f64 / height as f64).abs() + < 0.025 + ); + } + + #[test] + fn jpeg_thumbnail_decoder_downsamples_before_allocating_pixels() { + let original = image::RgbImage::from_pixel(1024, 512, image::Rgb([40, 120, 200])); + let mut encoded = Vec::new(); + image::codecs::jpeg::JpegEncoder::new(&mut encoded) + .encode_image(&original) + .unwrap(); + + let (decoded, width, height) = decode_jpeg_thumbnail(&encoded, 128).unwrap(); + + assert_eq!((width, height), (1024, 512)); + assert!(decoded.width() <= 256); + assert!(decoded.height() <= 128); + let pixel = decoded.to_rgb8().get_pixel(0, 0).0; + assert!(pixel[0].abs_diff(40) <= 3); + assert!(pixel[1].abs_diff(120) <= 3); + assert!(pixel[2].abs_diff(200) <= 3); + } + + #[test] + fn built_in_wallpapers_reject_unknown_and_traversal_identifiers() { + assert_eq!(wallpaper_path("unknown/wallpaper"), None); + assert_eq!(wallpaper_path("../macOS/sequoia-dark"), None); + assert_eq!(wallpaper_path("/macOS/sequoia-dark"), None); + } + + #[test] + fn built_in_wallpapers_resolve_from_an_installed_bundle() { + let root = std::env::temp_dir().join(format!( + "cap-gpui-installed-wallpapers-{}", + std::process::id() + )); + let resources = root.join("Cap.app/Contents/Resources"); + let backgrounds = resources.join("assets/backgrounds"); + let wallpaper = backgrounds.join("macOS/sequoia-dark.jpg"); + std::fs::create_dir_all(wallpaper.parent().unwrap()).unwrap(); + std::fs::write(&wallpaper, b"test wallpaper").unwrap(); + + let found = wallpaper_dir_from(&[resources], None, &root.join("missing")); + assert_eq!(found, Some(backgrounds)); + assert!(found.unwrap().join("macOS/sequoia-dark.jpg").is_file()); + + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn hex_parsing_matches_the_apps() { assert_eq!(hex_to_rgb("#4785FF"), Some([71, 133, 255, 255])); diff --git a/apps/desktop-gpui/src/editor_timeline.rs b/apps/desktop-gpui/src/editor_timeline.rs index 3eeb2898989..befb28a9572 100644 --- a/apps/desktop-gpui/src/editor_timeline.rs +++ b/apps/desktop-gpui/src/editor_timeline.rs @@ -1723,7 +1723,7 @@ fn render_track_content( let mut content = div().relative().size_full(); if !segments.iter().any(|segment| segment.lane == row.lane) - && !(row.kind == TrackKind::ThreeD && !model.camera3d_setup_ghosts.is_empty()) + && (row.kind != TrackKind::ThreeD || model.camera3d_setup_ghosts.is_empty()) && let Some(empty) = render_empty_track( theme, row.kind, diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 105731b3db5..b5bd74046a1 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -204,6 +204,15 @@ pub fn letterbox(container: (f32, f32), frame: (f32, f32)) -> (f32, f32) { } } +fn frame_layout_requires_editor_invalidation( + first_frame: bool, + playing: bool, + layout_changed: bool, + cleared_drag_rect: bool, +) -> bool { + first_frame || cleared_drag_rect || (!playing && layout_changed) +} + // --------------------------------------------------------------------------- // Loading a project // --------------------------------------------------------------------------- @@ -213,6 +222,7 @@ pub fn letterbox(container: (f32, f32), frame: (f32, f32)) -> (f32, f32) { /// whose disabled state is data-driven. #[derive(Debug, Clone)] pub struct ProjectSummary { + pub recordings: Arc, /// `meta().prettyName` -- the header's editable name. pub pretty_name: String, /// Every track the timeline draws, derived from the bundle's own @@ -262,8 +272,7 @@ pub struct ProjectSummary { /// unwinds out of the renderer's task. It is a plain synchronous function, /// so running it here under `catch_unwind` -- on a background thread, in /// Rust-only frames, never across an objc boundary -- converts the panic -/// into a message. `EditorInstance::new` then repeats the same construction -/// with input already known to be good. +/// into a message. The validated metadata is reused by `EditorInstance`. /// /// Blocking; callers run it on the background executor. pub fn preflight(path: &std::path::Path) -> Result { @@ -314,6 +323,7 @@ pub fn preflight(path: &std::path::Path) -> Result { "This recording's video tracks could not be opened. The bundle looks damaged.".to_string() })? .map_err(|error| format!("Failed to read this recording's media: {error}"))?; + let recordings = Arc::new(recordings); // `RecordingMeta::project_config()` loads `project-config.json` (falling // back to the default) and overlays `captions.json` -- the same read @@ -358,6 +368,7 @@ pub fn preflight(path: &std::path::Path) -> Result { let duration = timeline.total_duration; Ok(ProjectSummary { + recordings: recordings.clone(), pretty_name: meta.pretty_name.clone(), timeline, duration: duration.max(0.0), @@ -4776,7 +4787,12 @@ impl EditorWindow { if let Some(stats) = &self.stats { stats.presented.fetch_add(1, Ordering::Relaxed); } - if layout_changed || cleared_drag_rect { + if frame_layout_requires_editor_invalidation( + first_frame, + self.playing, + layout_changed, + cleared_drag_rect, + ) { cx.notify(); } // `refresh()` is a whole-window invalidation, so calling it per frame on @@ -8724,7 +8740,14 @@ pub fn make_frame_callback( ) -> cap_editor::EditorFrameCallback { Box::new(move |output, layout| { stats.rendered.fetch_add(1, Ordering::Relaxed); - if tx.try_send((output, layout)).is_err() { + #[cfg(target_os = "windows")] + let sent = tx + .send_timeout((output, layout), Duration::from_millis(100)) + .is_ok(); + #[cfg(not(target_os = "windows"))] + let sent = tx.try_send((output, layout)).is_ok(); + + if !sent { stats.dropped.fetch_add(1, Ordering::Relaxed); } }) @@ -8809,6 +8832,31 @@ mod tests { assert!((width / height - 992. / 492.).abs() < 0.001); } + #[test] + fn animated_frame_layout_does_not_invalidate_editor_during_playback() { + assert!(!frame_layout_requires_editor_invalidation( + false, true, true, false + )); + assert!(!frame_layout_requires_editor_invalidation( + false, true, false, false + )); + assert!(!frame_layout_requires_editor_invalidation( + false, false, false, false + )); + assert!(frame_layout_requires_editor_invalidation( + false, false, true, false + )); + assert!(frame_layout_requires_editor_invalidation( + true, true, true, false + )); + assert!(frame_layout_requires_editor_invalidation( + false, true, false, true + )); + assert!(frame_layout_requires_editor_invalidation( + false, true, true, true + )); + } + /// Every failure `EditorInstance::new` would return, plus the one it would /// panic on, has to come back as a message. #[test] diff --git a/apps/desktop-gpui/src/feeds.rs b/apps/desktop-gpui/src/feeds.rs index c4aa52b9098..0c9fcd3f2d0 100644 --- a/apps/desktop-gpui/src/feeds.rs +++ b/apps/desktop-gpui/src/feeds.rs @@ -14,6 +14,11 @@ use std::time::{Duration, Instant}; +#[cfg(any(not(target_os = "macos"), test))] +use std::sync::Arc; +#[cfg(not(target_os = "macos"))] +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; + use cap_recording::feeds::{ camera::{self, CameraFeed}, microphone::{self, MicrophoneFeed, MicrophoneSamples}, @@ -48,10 +53,19 @@ pub struct Feeds { /// Rolling 200ms max of the mic level, in dB FS. `-96` when silent/absent. pub mic_level_db: f64, pub camera_error: Option, + camera_preview_parked: bool, /// Bumped on every camera/mic selection change; async completions from a /// previous selection see a stale epoch and drop their result. camera_epoch: u64, mic_epoch: u64, + #[cfg(not(target_os = "macos"))] + camera_preview_mirrored: Arc, + #[cfg(not(target_os = "macos"))] + camera_preview_active: Arc, + #[cfg(not(target_os = "macos"))] + camera_preview_blur: Arc, + #[cfg(not(target_os = "macos"))] + camera_preview_reset: Option>, // Channel-holding tasks; dropping them ends the pumps. _frame_pump: Option>, _meter_pump: Option>, @@ -77,8 +91,17 @@ impl Feeds { microphone: None, mic_level_db: -96.0, camera_error: None, + camera_preview_parked: false, camera_epoch: 0, mic_epoch: 0, + #[cfg(not(target_os = "macos"))] + camera_preview_mirrored: Arc::new(AtomicBool::new(false)), + #[cfg(not(target_os = "macos"))] + camera_preview_active: Arc::new(AtomicBool::new(true)), + #[cfg(not(target_os = "macos"))] + camera_preview_blur: Arc::new(AtomicU8::new(0)), + #[cfg(not(target_os = "macos"))] + camera_preview_reset: None, _frame_pump: None, _meter_pump: None, _mic_errors: None, @@ -95,6 +118,9 @@ impl Feeds { /// selected (or the actor died -- `recording::start` falls back to a /// per-recording feed in that case). pub fn camera_actor(&self) -> Option> { + if self.camera_preview_parked { + return None; + } self.camera.as_ref()?; self.camera_actor.clone().filter(|actor| actor.is_alive()) } @@ -104,6 +130,20 @@ impl Feeds { self.mic_actor.clone().filter(|actor| actor.is_alive()) } + #[cfg(not(target_os = "macos"))] + pub fn set_camera_preview_state(&self, mirrored: bool, blur: crate::store::BlurMode) { + self.camera_preview_mirrored + .store(mirrored, Ordering::Relaxed); + self.camera_preview_blur.store( + match blur { + crate::store::BlurMode::Off => 0, + crate::store::BlurMode::Light => 1, + crate::store::BlurMode::Heavy => 2, + }, + Ordering::Relaxed, + ); + } + /// Select (or deselect) the camera. Opens/closes the preview window and /// points the app-scoped feed at the device. pub fn set_camera(&mut self, selection: Option, cx: &mut Context) { @@ -111,47 +151,15 @@ impl Feeds { return; } self.camera_epoch += 1; - let epoch = self.camera_epoch; self.camera = selection.clone(); self.camera_error = None; cx.notify(); match selection { - Some(selection) => { - let actor = self.ensure_camera_actor(cx); - let set = gpui_tokio::Tokio::spawn(cx, async move { - let ready = actor - .ask(camera::SetInput { - id: selection.id, - settings: None, - }) - .await - .map_err(|e| e.to_string())?; - ready.await.map_err(|e| e.to_string()) - }); - cx.spawn(async move |this, cx| { - let result = match set.await { - Ok(result) => result.map(|_| ()), - Err(join_error) => Err(join_error.to_string()), - }; - this.update(cx, |this, cx| { - if this.camera_epoch != epoch { - return; - } - if let Err(error) = result { - tracing::error!("camera input failed: {error}"); - this.camera_error = Some(error); - } - cx.notify(); - }) - .ok(); - }) - .detach(); - // Deferred: `open_window` paints the new window's first frame - // synchronously, and that render reads this very entity -- - // opening inside the update is a double-lease panic. - cx.defer(app_windows::open_camera_window); + Some(selection) if !self.camera_preview_parked => { + self.start_camera_preview(selection, cx); } + Some(_) => {} None => { if let Some(actor) = self.camera_actor.clone() { gpui_tokio::Tokio::spawn(cx, async move { @@ -164,6 +172,83 @@ impl Feeds { } } + pub fn park_camera_preview(&mut self, cx: &mut Context) { + if self.camera_preview_parked { + return; + } + + self.camera_preview_parked = true; + self.camera_epoch += 1; + + #[cfg(not(target_os = "macos"))] + { + self.camera_preview_active.store(false, Ordering::Release); + if let Some(reset) = &self.camera_preview_reset { + let _ = reset.try_send(()); + } + } + + if let Some(actor) = self.camera_actor.clone() { + gpui_tokio::Tokio::spawn(cx, async move { + if let Err(error) = actor.ask(camera::RemoveInput).await { + tracing::warn!("parking the camera preview: {error}"); + } + }) + .detach(); + } + + tracing::info!("camera preview parked"); + } + + pub fn resume_camera_preview(&mut self, cx: &mut Context) { + if !self.camera_preview_parked { + return; + } + + self.camera_preview_parked = false; + #[cfg(not(target_os = "macos"))] + self.camera_preview_active.store(true, Ordering::Release); + if let Some(selection) = self.camera.clone() { + self.camera_epoch += 1; + self.start_camera_preview(selection, cx); + tracing::info!("camera preview resumed"); + } + } + + fn start_camera_preview(&mut self, selection: SelectedCamera, cx: &mut Context) { + let epoch = self.camera_epoch; + let actor = self.ensure_camera_actor(cx); + let set = gpui_tokio::Tokio::spawn(cx, async move { + let ready = actor + .ask(camera::SetInput { + id: selection.id, + settings: None, + }) + .await + .map_err(|error| error.to_string())?; + ready.await.map_err(|error| error.to_string()) + }); + cx.spawn(async move |this, cx| { + let result = match set.await { + Ok(result) => result.map(|_| ()), + Err(error) => Err(error.to_string()), + }; + this.update(cx, |this, cx| { + if this.camera_epoch != epoch { + return; + } + if let Err(error) = result { + tracing::error!("camera input failed: {error}"); + this.camera_error = Some(error); + } + cx.notify(); + }) + .ok(); + }) + .detach(); + cx.defer(app_windows::open_camera_window); + } + /// Select (or deselect) the microphone. The feed keeps running between /// recordings so the pickers and bar have a live level. pub fn set_microphone(&mut self, label: Option, cx: &mut Context) { @@ -254,24 +339,142 @@ impl Feeds { // The preview channel: bounded(4) so a stalled UI drops frames instead // of ballooning; the pump drains on the main thread and hands each // frame straight to the camera window. - let (frame_tx, frame_rx) = flume::bounded::(4); - { - let actor = actor.clone(); - gpui_tokio::Tokio::spawn(cx, async move { - if let Err(error) = actor.ask(camera::AddNativeSender(frame_tx)).await { - tracing::error!("attaching camera preview sender: {error}"); + #[cfg(target_os = "macos")] + let pump = { + let (frame_tx, frame_rx) = flume::bounded::(4); + { + let actor = actor.clone(); + gpui_tokio::Tokio::spawn(cx, async move { + if let Err(error) = actor.ask(camera::AddNativeSender(frame_tx)).await { + tracing::error!("attaching camera preview sender: {error}"); + } + }) + .detach(); + } + + cx.spawn(async move |_this, cx| { + while let Ok(frame) = frame_rx.recv_async().await { + cx.update(|cx| app_windows::deliver_camera_frame(frame, cx)); } }) - .detach(); - } + }; - let pump = cx.spawn(async move |_this, cx| { - while let Ok(frame) = frame_rx.recv_async().await { - // When no window is open (yet), the frame is dropped and the - // channel keeps draining. - cx.update(|cx| app_windows::deliver_camera_frame(frame, cx)); + #[cfg(not(target_os = "macos"))] + let pump = { + let (frame_tx, frame_rx) = flume::bounded::(4); + let (preview_tx, preview_rx) = flume::bounded(2); + let (reset_tx, reset_rx) = flume::bounded(1); + self.camera_preview_reset = Some(reset_tx); + let mirrored = self.camera_preview_mirrored.clone(); + let active = self.camera_preview_active.clone(); + let blur = self.camera_preview_blur.clone(); + if let Err(error) = std::thread::Builder::new() + .name("camera-preview".into()) + .spawn(move || { + let mut scaler = None; + let mut processor = None; + let mut previous_blur = 0; + let mut blur_failed = false; + loop { + let received = flume::Selector::new() + .recv(&frame_rx, |result| result.map(Some)) + .recv(&reset_rx, |result| result.map(|()| None)) + .wait(); + let mut frame = match received { + Ok(Some(frame)) => frame, + Ok(None) => { + scaler = None; + processor = None; + previous_blur = 0; + blur_failed = false; + continue; + } + Err(_) => break, + }; + if !active.load(Ordering::Acquire) { + continue; + } + while let Ok(newer) = frame_rx.try_recv() { + frame = newer; + } + let requested_blur = blur.load(Ordering::Relaxed); + if requested_blur != previous_blur { + processor = None; + blur_failed = false; + previous_blur = requested_blur; + } + let blur_mode = match requested_blur { + 1 => Some(cap_camera_effects::BlurMode::Light), + 2 => Some(cap_camera_effects::BlurMode::Heavy), + _ => None, + } + .filter(|_| !blur_failed && crate::camera_blur_portable::blur_allowed()); + let max_dims = blur_mode.map(|_| crate::camera_blur_portable::MAX_DIMS); + let Some((mut image, dims)) = camera_preview_image( + &frame.inner, + &mut scaler, + mirrored.load(Ordering::Relaxed), + max_dims, + ) else { + continue; + }; + if let Some(mode) = blur_mode + && !blur_failed + { + if processor.is_none() { + match crate::camera_blur_portable::PortableCameraBlur::new() { + Ok(worker) => { + tracing::info!("camera blur preview initialized"); + processor = Some(worker); + } + Err(error) => { + tracing::warn!( + "camera blur preview unavailable: {error:#}" + ); + blur_failed = true; + } + } + } + if let Some(worker) = processor.as_mut() { + match worker.process(&image, dims, mode) { + Ok(blurred) => image = blurred, + Err(error) => { + tracing::warn!("camera blur preview stopped: {error:#}"); + processor = None; + blur_failed = true; + } + } + } + } + match preview_tx + .try_send(crate::camera_window::CameraPreviewFrame { image, dims }) + { + Ok(()) | Err(flume::TrySendError::Full(_)) => {} + Err(flume::TrySendError::Disconnected(_)) => break, + } + } + }) + { + tracing::error!("starting camera preview worker: {error}"); } - }); + + { + let actor = actor.clone(); + gpui_tokio::Tokio::spawn(cx, async move { + if let Err(error) = actor.ask(camera::AddSender(frame_tx)).await { + tracing::error!("attaching camera preview sender: {error}"); + } + }) + .detach(); + } + + cx.spawn(async move |_this, cx| { + while let Ok(frame) = preview_rx.recv_async().await { + cx.update(|cx| app_windows::deliver_camera_frame(frame, cx)); + } + }) + }; + self._frame_pump = Some(pump); self.camera_actor = Some(actor.clone()); actor @@ -353,6 +556,85 @@ impl Feeds { } } +#[cfg(any(not(target_os = "macos"), test))] +pub(crate) fn camera_preview_image( + frame: &ffmpeg::frame::Video, + scaler: &mut Option, + mirrored: bool, + max_dims: Option<(u32, u32)>, +) -> Option<(Arc, (usize, usize))> { + let source_width = frame.width(); + let source_height = frame.height(); + let (width, height) = match max_dims { + Some(max) => { + crate::camera_blur_portable::fitted_dimensions(source_width, source_height, max)? + } + None if source_width > 0 && source_height > 0 => (source_width, source_height), + None => return None, + }; + + let mut converted = ffmpeg::frame::Video::empty(); + let source = if frame.format() == ffmpeg::format::Pixel::BGRA + && width == source_width + && height == source_height + { + frame + } else { + let definition = scaler.as_ref().map(|context| context.input()); + let output = scaler.as_ref().map(|context| context.output()); + if definition.is_none_or(|input| { + input.format != frame.format() + || input.width != source_width + || input.height != source_height + }) || output.is_none_or(|output| output.width != width || output.height != height) + { + *scaler = Some( + ffmpeg::software::scaling::Context::get( + frame.format(), + source_width, + source_height, + ffmpeg::format::Pixel::BGRA, + width, + height, + ffmpeg::software::scaling::Flags::BILINEAR, + ) + .ok()?, + ); + } + scaler.as_mut()?.run(frame, &mut converted).ok()?; + &converted + }; + + let width = width as usize; + let height = height as usize; + let row_bytes = width.checked_mul(4)?; + let stride = source.stride(0); + if stride < row_bytes { + return None; + } + let input = source.data(0); + if input.len() < height.checked_mul(stride)? { + return None; + } + let mut pixels = vec![0; height.checked_mul(row_bytes)?]; + for (row, output) in pixels.chunks_exact_mut(row_bytes).enumerate() { + let input = &input[row * stride..row * stride + row_bytes]; + if mirrored { + for (destination, source) in output.chunks_exact_mut(4).zip(input.chunks_exact(4).rev()) + { + destination.copy_from_slice(source); + } + } else { + output.copy_from_slice(input); + } + } + let image = image::RgbaImage::from_raw(width as u32, height as u32, pixels)?; + let image = Arc::new(gpui::RenderImage::new(smallvec::smallvec![ + image::Frame::new(image) + ])); + Some((image, (width, height))) +} + /// `db_fs` from `src-tauri/src/audio_meter.rs`: peak of the batch as dB FS, /// clamped to [-96, 0]. fn db_fs(samples: &MicrophoneSamples) -> f64 { @@ -417,4 +699,62 @@ mod tests { assert_eq!(bar_level(0.0), 1.0); assert!((bar_level(-30.0) - 0.5).abs() < 1e-9); } + + #[test] + fn camera_preview_preserves_bgra_pixels_and_mirrors_each_row() { + let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, 2, 2); + let stride = frame.stride(0); + let pixels = frame.data_mut(0); + pixels[..8].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]); + pixels[stride..stride + 8].copy_from_slice(&[9, 10, 11, 12, 13, 14, 15, 16]); + + let mut scaler = None; + let (image, dims) = camera_preview_image(&frame, &mut scaler, false, None).unwrap(); + assert_eq!(dims, (2, 2)); + assert_eq!( + image.as_bytes(0).unwrap(), + &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] + ); + assert!(scaler.is_none()); + + let (mirrored, dims) = camera_preview_image(&frame, &mut scaler, true, None).unwrap(); + assert_eq!(dims, (2, 2)); + assert_eq!( + mirrored.as_bytes(0).unwrap(), + &[5, 6, 7, 8, 1, 2, 3, 4, 13, 14, 15, 16, 9, 10, 11, 12] + ); + } + + #[test] + fn camera_preview_converts_rgba_to_bgra() { + let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, 2, 1); + frame.data_mut(0)[..8].copy_from_slice(&[10, 20, 30, 255, 40, 50, 60, 128]); + + let mut scaler = None; + let (image, dims) = camera_preview_image(&frame, &mut scaler, false, None).unwrap(); + assert_eq!(dims, (2, 1)); + assert_eq!( + image.as_bytes(0).unwrap(), + &[30, 20, 10, 255, 60, 50, 40, 128] + ); + assert!(scaler.is_some()); + } + + #[test] + fn camera_preview_downscales_only_when_blur_requires_it() { + let frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, 64, 32); + let mut scaler = None; + + let (_, full_size) = camera_preview_image(&frame, &mut scaler, false, None).unwrap(); + assert_eq!(full_size, (64, 32)); + assert!(scaler.is_none()); + + let (_, capped) = camera_preview_image(&frame, &mut scaler, false, Some((32, 32))).unwrap(); + assert_eq!(capped, (32, 16)); + assert!(scaler.is_some()); + + let (_, recapped) = + camera_preview_image(&frame, &mut scaler, false, Some((16, 16))).unwrap(); + assert_eq!(recapped, (16, 8)); + } } diff --git a/apps/desktop-gpui/src/import.rs b/apps/desktop-gpui/src/import.rs index f993b7f6a96..6f85b5a9f06 100644 --- a/apps/desktop-gpui/src/import.rs +++ b/apps/desktop-gpui/src/import.rs @@ -13,6 +13,7 @@ use std::{ path::{Path, PathBuf}, + sync::atomic::{AtomicUsize, Ordering}, time::Duration, }; @@ -35,6 +36,7 @@ const MEDIA_IMPORT_EXTENSIONS: &[&str] = &[ "bmp", "tif", "tiff", ]; const MAX_IMAGE_DIMENSION: u32 = 16_384; +static ACTIVE_IMPORT_WORKERS: AtomicUsize = AtomicUsize::new(0); /// `transcode_video` fails with exactly this when the bundle vanishes mid-way /// (the user deleted it -- the Tauri cancellation seam, `import.rs:1253-1256`); @@ -187,6 +189,25 @@ pub fn imports_snapshot(cx: &App) -> Vec { .unwrap_or_default() } +pub fn imports_in_flight(cx: &App) -> bool { + ACTIVE_IMPORT_WORKERS.load(Ordering::Acquire) != 0 || !imports_snapshot(cx).is_empty() +} + +struct InFlightImport; + +impl InFlightImport { + fn begin() -> Self { + ACTIVE_IMPORT_WORKERS.fetch_add(1, Ordering::AcqRel); + Self + } +} + +impl Drop for InFlightImport { + fn drop(&mut self) { + ACTIVE_IMPORT_WORKERS.fetch_sub(1, Ordering::AcqRel); + } +} + /// One worker->UI update, applied with a clean gpui borrow. fn apply_progress(update: ImportProgress, cx: &mut App) { if !cx.has_global::() { @@ -327,9 +348,13 @@ pub fn import_image_from_path(source_path: PathBuf, cx: &mut App) { /// conversion -- `tokio::task::spawn_blocking`'s role in the Tauri version. fn spawn_import(cx: &mut App, work: impl FnOnce(flume::Sender) + Send + 'static) { let (tx, rx) = flume::unbounded::(); + let in_flight = InFlightImport::begin(); let worker = std::thread::Builder::new() .name("cap-media-import".to_string()) - .spawn(move || work(tx)); + .spawn(move || { + let _in_flight = in_flight; + work(tx); + }); if let Err(error) = worker { tracing::error!("failed to spawn the import worker thread: {error}"); return; @@ -1016,7 +1041,13 @@ fn convert_for_encode( height: u32, ) -> Result { if frame.format() == pixel_format && frame.width() == width && frame.height() == height { - return Ok(frame.clone()); + let mut reference = ffmpeg::frame::Video::empty(); + let status = unsafe { ffmpeg::ffi::av_frame_ref(reference.as_mut_ptr(), frame.as_ptr()) }; + return Ok(if status >= 0 { + reference + } else { + frame.clone() + }); } if scaler.is_none() { @@ -1649,6 +1680,24 @@ mod tests { dir } + #[test] + fn imports_are_in_flight_before_their_first_progress_update() { + let baseline = ACTIVE_IMPORT_WORKERS.load(Ordering::Acquire); + let in_flight = InFlightImport::begin(); + assert_eq!(ACTIVE_IMPORT_WORKERS.load(Ordering::Acquire), baseline + 1); + + let (release, released) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let _in_flight = in_flight; + released.recv().unwrap(); + }); + + assert_eq!(ACTIVE_IMPORT_WORKERS.load(Ordering::Acquire), baseline + 1); + release.send(()).unwrap(); + worker.join().unwrap(); + assert_eq!(ACTIVE_IMPORT_WORKERS.load(Ordering::Acquire), baseline); + } + #[test] fn extension_filters_are_case_insensitive_and_file_gated() { let dir = temp_dir("extensions"); @@ -1728,4 +1777,49 @@ mod tests { assert_eq!(ensure_even(1), 2); assert_eq!(ensure_even(0), 2); } + + #[test] + fn matching_import_frame_reuses_reference_counted_pixels() { + let mut original = ffmpeg::frame::Video::new(avformat::Pixel::YUV420P, 16, 12); + original.set_pts(Some(417)); + original.data_mut(0)[0] = 81; + let mut scaler = None; + + let converted = + convert_for_encode(&original, &mut scaler, avformat::Pixel::YUV420P, 16, 12) + .expect("reference-counted import frame"); + + assert!(scaler.is_none()); + assert_eq!(converted.data(0).as_ptr(), original.data(0).as_ptr()); + assert_eq!(converted.pts(), Some(417)); + let buffer = unsafe { (*original.as_ptr()).buf[0] }; + assert_eq!(unsafe { ffmpeg::ffi::av_buffer_get_ref_count(buffer) }, 2); + + drop(original); + + assert_eq!(converted.data(0)[0], 81); + let retained_buffer = unsafe { (*converted.as_ptr()).buf[0] }; + assert_eq!( + unsafe { ffmpeg::ffi::av_buffer_get_ref_count(retained_buffer) }, + 1 + ); + } + + #[test] + fn mismatched_import_frame_still_converts_and_preserves_timestamp() { + let mut original = ffmpeg::frame::Video::new(avformat::Pixel::RGBA, 16, 12); + original.set_pts(Some(819)); + let mut scaler = None; + + let converted = + convert_for_encode(&original, &mut scaler, avformat::Pixel::YUV420P, 16, 12) + .expect("converted import frame"); + + assert!(scaler.is_some()); + assert_eq!(converted.format(), avformat::Pixel::YUV420P); + assert_eq!(converted.width(), 16); + assert_eq!(converted.height(), 12); + assert_eq!(converted.pts(), Some(819)); + assert_ne!(converted.data(0).as_ptr(), original.data(0).as_ptr()); + } } diff --git a/apps/desktop-gpui/src/library.rs b/apps/desktop-gpui/src/library.rs index a4eaceda794..e98b5f218ff 100644 --- a/apps/desktop-gpui/src/library.rs +++ b/apps/desktop-gpui/src/library.rs @@ -33,6 +33,7 @@ use cap_project::{ InstantRecordingMeta, RecordingMeta, RecordingMetaInner, StudioRecordingMeta, StudioRecordingStatus, }; +use cap_recording::recovery::RecoveryManager; use gpui::RenderImage; use image::buffer::ConvertBuffer as _; @@ -286,6 +287,14 @@ pub struct RecordingItem { pub thumbnail: Option, } +#[derive(Debug, Clone, PartialEq)] +pub struct IncompleteRecordingItem { + pub project_path: PathBuf, + pub pretty_name: String, + pub segment_count: usize, + pub estimated_duration_secs: f64, +} + impl RecordingItem { /// `hasActiveRecording` (`recordings.tsx:66-73`) minus its upload half: /// there are no uploads in this app, so `MultipartUpload` / @@ -395,6 +404,112 @@ pub fn list_recordings() -> Vec { list_recordings_in(&known_recordings_dirs()) } +pub fn find_incomplete_recordings_in( + dirs: &[PathBuf], + active_recording: Option<&Path>, +) -> Vec { + list_recordings_in(dirs) + .into_iter() + .filter(|item| { + item.mode == RecordingMode::Studio + && matches!( + item.status, + RecordingStatus::InProgress | RecordingStatus::NeedsRemux + ) + && active_recording != Some(item.path.as_path()) + && is_recording_after_recovery_cutoff(&item.pretty_name) + }) + .filter_map(|item| { + let incomplete = RecoveryManager::inspect_recording(&item.path)?; + (!incomplete.recoverable_segments.is_empty()).then_some(IncompleteRecordingItem { + project_path: item.path, + pretty_name: item.pretty_name, + segment_count: incomplete.recoverable_segments.len(), + estimated_duration_secs: incomplete.estimated_duration.as_secs_f64(), + }) + }) + .collect() +} + +pub fn find_incomplete_recordings() -> Vec { + find_incomplete_recordings_in(&known_recordings_dirs(), None) +} + +fn is_recording_after_recovery_cutoff(pretty_name: &str) -> bool { + let Some(date) = pretty_name + .strip_prefix("Cap ") + .and_then(|name| name.split(" at ").next()) + .and_then(|value| chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").ok()) + else { + return false; + }; + + chrono::NaiveDate::from_ymd_opt(2025, 12, 31).is_some_and(|cutoff| date > cutoff) +} + +pub fn recover_incomplete_recording(project_path: &Path) -> Result { + recover_incomplete_recording_in(&known_recordings_dirs(), project_path) +} + +fn recover_incomplete_recording_in( + dirs: &[PathBuf], + project_path: &Path, +) -> Result { + if project_path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + || !dirs.iter().any(|dir| project_path.starts_with(dir)) + { + return Err("Path is not inside a recordings directory".to_string()); + } + + let canonical_path = project_path + .canonicalize() + .map_err(|error| format!("Failed to resolve recording path: {error}"))?; + if !dirs.iter().any(|dir| { + dir.canonicalize() + .is_ok_and(|canonical_dir| canonical_path.starts_with(canonical_dir)) + }) { + return Err("Path is not inside a recordings directory".to_string()); + } + + let meta = RecordingMeta::load_for_project(&canonical_path) + .map_err(|error| format!("Failed to load recording metadata: {error}"))?; + let Some(studio) = meta.studio_meta() else { + return Err("Only incomplete studio recordings can be recovered".to_string()); + }; + if !matches!( + studio.status(), + StudioRecordingStatus::InProgress | StudioRecordingStatus::NeedsRemux + ) { + return Err("Recording is not waiting for recovery".to_string()); + } + + let incomplete = RecoveryManager::inspect_recording(&canonical_path) + .ok_or_else(|| "No recoverable segments found".to_string())?; + let recovered = RecoveryManager::recover(&incomplete) + .map_err(|error| format!("Failed to recover recording: {error}"))?; + let display = match &recovered.meta { + StudioRecordingMeta::SingleSegment { segment } => { + segment.display.path.to_path(&recovered.project_path) + } + StudioRecordingMeta::MultipleSegments { inner } => inner + .segments + .first() + .map(|segment| segment.display.path.to_path(&recovered.project_path)) + .ok_or_else(|| "Recovered recording has no display segments".to_string())?, + }; + if let Err(error) = create_screenshot( + &display, + &bundle_thumbnail_path(&recovered.project_path), + None, + ) { + tracing::warn!(path = %recovered.project_path.display(), %error, "failed to create recovered recording thumbnail"); + } + + Ok(recovered.project_path) +} + /// `delete_recording_directory` (`lib.rs:4001-4046`), guards verbatim. /// /// Three of them, and each one covers a hole the others leave: @@ -697,6 +812,19 @@ pub fn spawn_decode_pool( jobs: Vec, decode: impl Fn(J) -> Option + Send + Sync + Clone + 'static, ) -> (Vec>, flume::Receiver) +where + J: Send + 'static, + R: Send + 'static, +{ + spawn_decode_pool_limited(executor, jobs, MAX_DECODE_WORKERS, decode) +} + +pub fn spawn_decode_pool_limited( + executor: &gpui::BackgroundExecutor, + jobs: Vec, + max_workers: usize, + decode: impl Fn(J) -> Option + Send + Sync + Clone + 'static, +) -> (Vec>, flume::Receiver) where J: Send + 'static, R: Send + 'static, @@ -710,7 +838,7 @@ where let (result_tx, result_rx) = flume::unbounded(); let workers = std::thread::available_parallelism() .map_or(4, std::num::NonZeroUsize::get) - .min(MAX_DECODE_WORKERS) + .min(max_workers.max(1)) .min(job_rx.len().max(1)); let tasks = (0..workers) .map(|_| { @@ -1367,6 +1495,181 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + fn write_incomplete_bundle( + recordings: &Path, + name: &str, + pretty_name: &str, + status: &str, + fragments: bool, + ) -> PathBuf { + let bundle = write_bundle( + recordings, + name, + &format!( + r#"{{"pretty_name":"{pretty_name}","sharing":null,"segments":[],"status":{{"status":"{status}"}}}}"# + ), + ); + if fragments { + let display = bundle.join("content/segments/segment-0/display"); + std::fs::create_dir_all(&display).unwrap(); + std::fs::write(display.join("init.mp4"), vec![0u8; 128]).unwrap(); + std::fs::write(display.join("segment_001.m4s"), vec![1u8; 256]).unwrap(); + std::fs::write( + display.join("manifest.json"), + serde_json::to_vec(&serde_json::json!({ + "version": 5, + "type": "m4s_segments", + "init_segment": "init.mp4", + "segments": [{ + "path": "segment_001.m4s", + "is_complete": true, + "file_size": 256 + }] + })) + .unwrap(), + ) + .unwrap(); + } + bundle + } + + #[test] + fn recovery_scan_preserves_active_terminal_legacy_and_unrecoverable_recordings() { + let _ = ffmpeg::init(); + let root = temp_dir("recovery-scan"); + let recordings = root.join("recordings"); + std::fs::create_dir_all(&recordings).unwrap(); + let eligible = write_incomplete_bundle( + &recordings, + "eligible", + "Cap 2026-01-02 at 10.00.00", + "InProgress", + true, + ); + let active = write_incomplete_bundle( + &recordings, + "active", + "Cap 2026-01-03 at 10.00.00", + "InProgress", + true, + ); + let remux = write_incomplete_bundle( + &recordings, + "remux", + "Cap 2026-01-04 at 10.00.00", + "NeedsRemux", + true, + ); + write_incomplete_bundle( + &recordings, + "legacy", + "Cap 2025-12-31 at 10.00.00", + "InProgress", + true, + ); + write_incomplete_bundle( + &recordings, + "terminal", + "Cap 2026-01-05 at 10.00.00", + "Complete", + true, + ); + let corrupt = write_incomplete_bundle( + &recordings, + "unrecoverable", + "Cap 2026-01-06 at 10.00.00", + "InProgress", + false, + ); + let corrupt_before = std::fs::read(corrupt.join("recording-meta.json")).unwrap(); + + let found = find_incomplete_recordings_in( + std::slice::from_ref(&recordings), + Some(active.as_path()), + ); + + assert_eq!(found.len(), 2); + assert!(found.iter().any(|item| item.project_path == eligible)); + assert!(found.iter().any(|item| item.project_path == remux)); + assert!(found.iter().all(|item| item.segment_count == 1)); + assert_eq!( + std::fs::read(corrupt.join("recording-meta.json")).unwrap(), + corrupt_before + ); + assert!(active.exists()); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn recovery_rejects_paths_outside_the_recording_library() { + let root = temp_dir("recovery-path"); + let recordings = root.join("recordings"); + let elsewhere = root.join("elsewhere"); + std::fs::create_dir_all(&recordings).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + + let result = recover_incomplete_recording_in(std::slice::from_ref(&recordings), &elsewhere); + + assert_eq!( + result, + Err("Path is not inside a recordings directory".to_string()) + ); + assert!(elsewhere.is_dir()); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn recovery_restores_interrupted_real_recording_when_fixture_is_provided() { + let Ok(source) = std::env::var("CAP_GPUI_RECOVERY_FIXTURE") else { + return; + }; + let _ = ffmpeg::init(); + let source = PathBuf::from(source); + let root = temp_dir("recovery-real"); + let recordings = root.join("recordings"); + let copy = recordings.join("interrupted.cap"); + std::fs::create_dir_all(©).unwrap(); + + let mut pending = vec![(source, copy.clone())]; + while let Some((from, to)) = pending.pop() { + for entry in std::fs::read_dir(from).unwrap() { + let entry = entry.unwrap(); + let destination = to.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + std::fs::create_dir_all(&destination).unwrap(); + pending.push((entry.path(), destination)); + } else { + std::fs::copy(entry.path(), destination).unwrap(); + } + } + } + + let dirs = std::slice::from_ref(&recordings); + let found = find_incomplete_recordings_in(dirs, None); + assert_eq!(found.len(), 1); + assert_eq!(found[0].segment_count, 1); + + let recovered = recover_incomplete_recording_in(dirs, ©).unwrap(); + let meta = RecordingMeta::load_for_project(&recovered).unwrap(); + + assert!(matches!( + meta.studio_meta().unwrap().status(), + StudioRecordingStatus::Complete + )); + assert!( + recovered + .join("content/segments/segment-0/display.mp4") + .is_file() + ); + assert!(recovered.join("project-config.json").is_file()); + assert!(bundle_thumbnail_path(&recovered).is_file()); + assert!(find_incomplete_recordings_in(dirs, None).is_empty()); + + std::fs::remove_dir_all(root).unwrap(); + } + /// The three `delete_recording_directory` guards, one test each. #[test] fn delete_rejects_traversal_paths_outside_and_symlink_escapes() { @@ -1400,6 +1703,8 @@ mod tests { let escape = recordings.join("escape.cap"); #[cfg(unix)] std::os::unix::fs::symlink(&victim, &escape).unwrap(); + #[cfg(windows)] + std::os::windows::fs::symlink_dir(&victim, &escape).unwrap(); assert_eq!( delete_recording_directory_in(&dirs, &escape), Err("Path is not inside a recordings directory".to_string()), diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index b4509e2bfd3..713f423227e 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -10,6 +10,8 @@ mod auth; mod camera_bench; #[cfg(target_os = "macos")] mod camera_blur; +#[cfg(any(not(target_os = "macos"), test))] +mod camera_blur_portable; mod camera_window; mod controls_window; mod deeplink; @@ -58,6 +60,7 @@ mod theme; mod transcription; mod tray; mod ui; +mod updates; mod upload; use gpui::{App, AppContext as _, Bounds, WindowBounds, WindowOptions, px, size}; @@ -72,6 +75,7 @@ const MAIN_WINDOW_HEIGHT: f32 = 395.; /// material `"panel"` on both visual systems in /// `apps/desktop/src/utils/macos-window-material.ts`, and the same 16 the /// shell paints with (`rounded-[16px]`). +#[cfg(target_os = "macos")] const MAIN_WINDOW_MATERIAL_RADIUS: f64 = 16.; fn parse_auto_record(spec: &str) -> Option<(main_window::Mode, u64)> { @@ -253,6 +257,7 @@ fn main() { .expect("failed to open the main window"); app_windows::init(window_handle, session, cx); + updates::schedule_startup_check(cx); // The app menu (and with it ⌘W/⌘M/⌘Q) and the status-bar item. Both // reach into the window registry, so they come after it -- and the menu @@ -350,6 +355,7 @@ fn main() { "main window opened" ); view.start_enumeration(window, cx); + view.start_recovery_check(window, cx); view.auto_expand(window, cx); view.auto_open_recent(window, cx); // The AppKit work below must not run inside this update: @@ -366,6 +372,7 @@ fn main() { // `applyMacOSWindowMaterial("panel")` does in the Tauri app. Nothing // paints it; the shell paints a translucent tint *over* it, so the // window has to be told which one landed. + #[cfg(target_os = "macos")] cx.spawn(async move |cx| { let Some(native) = native_main else { tracing::error!("no NSWindow behind the main window; material not installed"); @@ -384,6 +391,8 @@ fn main() { }); }) .detach(); + #[cfg(not(target_os = "macos"))] + let _ = native_main; // `CAP_GPUI_DEBUG_LIGHTS=1`: poll the main window's style mask and // standard-button set, logging on change -- pins down *when* AppKit diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index aa293c709ff..a1447b90c94 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -341,6 +341,11 @@ pub struct MainWindow { /// is open so a large library is not walked on every home paint. library: Option, library_task: Option>, + incomplete_recording: Option, + recovery_error: Option, + recovery_pending: bool, + recovery_scan_task: Option>, + recovery_action_task: Option>, /// `createLicenseQuery()`'s resolution, cached: reading the store file in /// `render_plan_badge` would be I/O per paint. Refreshed on every Recents /// rescan -- the same seam that already re-reads the library on reshow, @@ -456,6 +461,11 @@ impl MainWindow { recents_task: None, library: None, library_task: None, + incomplete_recording: None, + recovery_error: None, + recovery_pending: false, + recovery_scan_task: None, + recovery_action_task: None, plan: PlanBadge::current(), thumbnails: target_thumbnails::ThumbnailCache::default(), display_thumbnail_task: None, @@ -511,6 +521,24 @@ impl MainWindow { ) }); } + if std::env::var("CAP_GPUI_AUTO_MIC").is_ok_and(|value| value == "1") + && this.microphone.is_none() + { + let selected = + cap_recording::feeds::microphone::MicrophoneFeed::default_device() + .and_then(|(name, _, _)| { + this.devices + .microphones + .iter() + .find(|microphone| microphone.name == name) + .cloned() + }) + .or_else(|| this.devices.microphones.first().cloned()); + if let Some(microphone) = selected { + tracing::info!(microphone = %microphone.name, "auto-selecting microphone"); + this.set_microphone_selection(Some(microphone), cx); + } + } // `CAP_GPUI_AUTO_PANEL=display|window`: open that target // picker panel the way the chevron click does. Same reason as // `CAP_GPUI_AUTO_CAMERA` above -- the chrome screenshots need @@ -928,6 +956,104 @@ impl MainWindow { })); } + pub fn start_recovery_check(&mut self, window: &mut Window, cx: &mut Context) { + self.scan_incomplete_recordings(window, cx, std::time::Duration::from_secs(2)); + } + + fn scan_incomplete_recordings( + &mut self, + window: &mut Window, + cx: &mut Context, + delay: std::time::Duration, + ) { + self.recovery_scan_task = Some(cx.spawn_in(window, async move |this, cx| { + if !delay.is_zero() { + cx.background_executor().timer(delay).await; + } + + let idle = this + .update_in(cx, |this, _, cx| this.session.read(cx).phase == Phase::Idle) + .unwrap_or(false); + if !idle { + return; + } + + let incomplete = cx + .background_executor() + .spawn(async { library::find_incomplete_recordings() }) + .await; + + this.update_in(cx, |this, window, cx| { + if this.session.read(cx).phase != Phase::Idle { + return; + } + this.incomplete_recording = incomplete.into_iter().next(); + if this.incomplete_recording.is_none() { + this.recovery_error = None; + } + cx.notify(); + window.refresh(); + }) + .ok(); + })); + } + + fn process_incomplete_recording( + &mut self, + recover: bool, + window: &mut Window, + cx: &mut Context, + ) { + if self.recovery_pending || self.session.read(cx).phase != Phase::Idle { + return; + } + let Some(recording) = self.incomplete_recording.clone() else { + return; + }; + + self.recovery_pending = true; + self.recovery_error = None; + cx.notify(); + window.refresh(); + + self.recovery_action_task = Some(cx.spawn_in(window, async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { + if recover { + library::recover_incomplete_recording(&recording.project_path) + } else { + library::delete_recording_directory(&recording.project_path) + .map(|()| recording.project_path) + } + }) + .await; + + this.update_in(cx, |this, window, cx| { + this.recovery_pending = false; + match result { + Ok(project_path) => { + this.incomplete_recording = None; + this.recovery_error = None; + this.refresh_recents(window, cx); + this.refresh_open_library(window, cx); + this.scan_incomplete_recordings(window, cx, std::time::Duration::ZERO); + if recover { + cx.defer(move |cx| app_windows::open_editor(project_path, cx)); + } + } + Err(error) => { + tracing::warn!(%error, "incomplete recording action failed"); + this.recovery_error = Some(error); + } + } + cx.notify(); + window.refresh(); + }) + .ok(); + })); + } + /// Install a fresh scan result, releasing the previous thumbnails from the /// sprite atlas -- the same explicit drop the camera preview does with /// every frame it replaces. @@ -1239,6 +1365,8 @@ impl MainWindow { self.mode = mode; self.target = Some(overlay.unwrap_or(TargetType::Display)); + self.system_audio = + std::env::var("CAP_GPUI_AUTO_SYSTEM_AUDIO").is_ok_and(|value| value == "1"); cx.notify(); // `CAP_GPUI_AUTO_PAUSE=1`: wiggle pause/resume in the middle third, so @@ -1339,10 +1467,20 @@ impl MainWindow { // The overlay route: arm the mode (which opens the // overlays), let them come up, seed what a drag or a hover // would have produced, then press their Start button. - if this - .update_in(cx, |this, _window, cx| this.arm_overlay(kind, cx)) - .is_err() - { + let wanted = required_window.clone(); + let armed = this + .update_in(cx, |this, _window, cx| { + this.arm_overlay(kind, cx); + kind != TargetType::Window + || wanted.as_ref().is_none_or(|wanted| { + this.selected_window + .as_ref() + .is_some_and(|selected| window_matches(selected, wanted)) + }) + }) + .unwrap_or(false); + if !armed { + tracing::error!("auto-record selected window is no longer available"); return; } cx.background_executor() @@ -1514,6 +1652,7 @@ impl MainWindow { let (camera_feed, mic_feed) = { let feeds = Feeds::global(cx); + feeds.update(cx, |feeds, cx| feeds.resume_camera_preview(cx)); let feeds = feeds.read(cx); (feeds.camera_actor(), feeds.mic_actor()) }; @@ -1590,7 +1729,13 @@ impl MainWindow { label: camera.label.clone(), }); self.camera = camera; - Feeds::global(cx).update(cx, |feeds, cx| feeds.set_camera(selection, cx)); + Feeds::global(cx).update(cx, |feeds, cx| { + let selected = selection.is_some(); + feeds.set_camera(selection, cx); + if selected { + feeds.resume_camera_preview(cx); + } + }); cx.notify(); } @@ -1623,6 +1768,7 @@ impl Render for MainWindow { div() .size_full() + .relative() .flex() .flex_col() .overflow_hidden() @@ -1657,10 +1803,138 @@ impl Render for MainWindow { }, |this| this.child(self.render_recording_overlay(cx)), ) + .when_some( + self.incomplete_recording + .clone() + .filter(|_| self.session.read(cx).phase == Phase::Idle), + |this, recording| this.child(self.render_recovery_toast(recording, cx)), + ) } } impl MainWindow { + fn render_recovery_toast( + &self, + recording: library::IncompleteRecordingItem, + cx: &mut Context, + ) -> impl IntoElement { + let theme = self.theme; + let pending = self.recovery_pending; + let duration = recording.estimated_duration_secs.round() as u64; + let duration_label = if duration == 0 { + String::new() + } else if duration < 60 { + format!(" · ~{duration}s") + } else if duration.is_multiple_of(60) { + format!(" · ~{}m", duration / 60) + } else { + format!(" · ~{}m {}s", duration / 60, duration % 60) + }; + let segments = format!( + "{} segment{}{}", + recording.segment_count, + if recording.segment_count == 1 { + "" + } else { + "s" + }, + duration_label + ); + + div() + .absolute() + .bottom(px(12.)) + .left(px(12.)) + .right(px(12.)) + .rounded(px(8.)) + .border_1() + .border_color(theme.red_4) + .bg(theme.red_2) + .p(px(10.)) + .shadow_md() + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .text_size(px(10.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.red_11) + .child("Incomplete Recording"), + ) + .child( + div() + .truncate() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.gray_12) + .child(recording.pretty_name), + ) + .child( + div() + .text_size(px(10.)) + .text_color(theme.gray_11) + .child(segments), + ) + .when_some(self.recovery_error.clone(), |this, error| { + this.child( + div() + .mt(px(4.)) + .text_size(px(10.)) + .text_color(theme.red_11) + .child(error), + ) + }), + ) + .child( + div() + .flex() + .flex_row() + .flex_shrink_0() + .gap(px(6.)) + .child( + ui::Button::plain( + &theme, + "recover-incomplete-recording", + ui::ButtonVariant::Primary, + ui::ButtonSize::Xs, + ) + .label(if pending { "..." } else { "Recover" }) + .disabled(pending) + .on_click(cx.listener( + |this, _, window, cx| { + this.process_incomplete_recording(true, window, cx); + }, + )), + ) + .child( + ui::Button::plain( + &theme, + "discard-incomplete-recording", + ui::ButtonVariant::Gray, + ui::ButtonSize::Xs, + ) + .label("Discard") + .disabled(pending) + .on_click(cx.listener( + |this, _, window, cx| { + this.process_incomplete_recording(false, window, cx); + }, + )), + ), + ), + ) + } + fn render_header(&self, _window: &Window, cx: &mut Context) -> impl IntoElement { let theme = self.theme; @@ -2606,6 +2880,7 @@ impl MainWindow { let studio = item.mode == library::RecordingMode::Studio; let sharing = item.sharing.clone(); let mode = item.mode; + let opens_editor = item.opens_editor(); let subtitle = item.mode.label().to_string(); let actions = self.render_recording_card_actions(index, item, cx); @@ -2617,12 +2892,12 @@ impl MainWindow { Some(subtitle), item.clip_count, cx.listener(move |_, _, _window, cx| { - if studio { + if studio && opens_editor { let path = path.clone(); cx.defer(move |cx| app_windows::open_editor(path, cx)); - } else if let Some(url) = &sharing { + } else if !studio && let Some(url) = &sharing { cx.open_url(url); - } else { + } else if !studio { library::open_recording_folder(&path, mode); } }), @@ -2648,7 +2923,7 @@ impl MainWindow { .px(px(8.)) .pb(px(6.)) .gap(px(4.)); - if mode == library::RecordingMode::Studio { + if item.opens_editor() { let editor = path.clone(); row = row.child(self.library_action( ("lib-rec-edit", index), @@ -4454,7 +4729,7 @@ pub fn auto_overlay_kind() -> Option { } } -fn auto_window_title() -> Option { +pub(crate) fn auto_window_title() -> Option { std::env::var("CAP_GPUI_AUTO_WINDOW") .ok() .map(|value| value.trim().to_string()) @@ -4462,7 +4737,7 @@ fn auto_window_title() -> Option { } fn window_matches(window: &crate::devices::WindowOption, title: &str) -> bool { - window.label == title || window.label.contains(title) + window.label == title } /// `CAP_GPUI_AUTO_AREA=x,y,width,height`, the harness's stand-in for drawing a diff --git a/apps/desktop-gpui/src/onboarding_window.rs b/apps/desktop-gpui/src/onboarding_window.rs index 3b80e68af06..fee56329582 100644 --- a/apps/desktop-gpui/src/onboarding_window.rs +++ b/apps/desktop-gpui/src/onboarding_window.rs @@ -21,7 +21,8 @@ use gpui::{ Context, FocusHandle, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, - Point, Render, SharedString, Styled, Window, div, prelude::FluentBuilder, px, svg, + Point, Render, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::FluentBuilder, px, svg, }; use crate::{ @@ -48,18 +49,15 @@ const REQUEST_GRACE: std::time::Duration = std::time::Duration::from_secs(2); /// itself, so the all-green state is actually seen. const AUTO_FINISH_DELAY: std::time::Duration = std::time::Duration::from_millis(1200); -// The permissions column. Fixed, computed widths throughout: the rows repeat, -// so nothing text-bearing in them gets flex_1 (the layout perf rule). -const CARD_W: f32 = 560.; +const CARD_W: f32 = 440.; const ROW_PAD_X: f32 = 16.; -const ROW_ICON_TILE: f32 = 40.; -const ROW_GAP: f32 = 14.; -const ROW_ACTION_W: f32 = 122.; -const ROW_TEXT_W: f32 = CARD_W - 2. * ROW_PAD_X - ROW_ICON_TILE - 2. * ROW_GAP - ROW_ACTION_W; +const ROW_GAP: f32 = 16.; +const ROW_ACTION_W: f32 = 112.; +const ROW_TEXT_W: f32 = CARD_W - 2. * ROW_PAD_X - ROW_GAP - ROW_ACTION_W; const HINT_ICON: f32 = 16.; const HINT_BUTTON_W: f32 = 116.; const HINT_TEXT_W: f32 = CARD_W - 2. * ROW_PAD_X - HINT_ICON - 2. * ROW_GAP - HINT_BUTTON_W; -const FOOTER_CTA_W: f32 = 168.; +const FOOTER_CTA_W: f32 = 144.; const FOOTER_TEXT_W: f32 = CARD_W - FOOTER_CTA_W - 16.; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -113,7 +111,16 @@ impl OnboardingWindow { forced, focus: cx.focus_handle(), }; + cx.observe_window_activation(window, |this: &mut Self, window, cx| { + if window.is_window_active() && this.step == Step::Permissions { + this.refresh_permissions(window, cx); + } + }) + .detach(); this.arm_poll(window, cx); + if this.step == Step::Permissions && this.state.all_shown_granted() && !this.forced { + this.schedule_auto_finish(cx); + } this } @@ -176,6 +183,39 @@ impl OnboardingWindow { } } + fn refresh_permissions(&mut self, window: &mut Window, cx: &mut Context) { + cx.spawn_in(window, async move |this, cx| { + let raw = cx + .background_executor() + .spawn(async { crate::permissions_ui::sweep_raw() }) + .await; + + this.update_in(cx, |this, window, cx| { + if this.step != Step::Permissions { + return; + } + + if this.state.apply_raw(raw) { + this.grants_changed(cx); + cx.notify(); + } + + if this.state.all_shown_granted() { + this.pending = None; + this.verify = None; + this.poll = None; + if !this.forced { + this.schedule_auto_finish(cx); + } + } else { + this.arm_poll(window, cx); + } + }) + .ok(); + }) + .detach(); + } + /// Everything granted: close the surface on its own after a beat -- the /// "finish gracefully" half of the poll contract. fn schedule_auto_finish(&mut self, cx: &mut Context) { @@ -222,36 +262,76 @@ impl OnboardingWindow { if self.pending.is_some() { return; } - match self.state.action(permission) { - None => return, - Some(RowAction::OpenSettings) => { - self.state.note_settings_opened(permission); - permissions::open_permission_settings(permission); - } - Some(RowAction::Request) => { - permissions::request_permission(permission); - self.pending = Some(permission); - self.verify = Some(cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(REQUEST_GRACE).await; - let raw = cx - .background_executor() - .spawn(async { crate::permissions_ui::sweep_raw() }) - .await; - this.update_in(cx, |this, _window, cx| { - this.state.apply_raw(raw); + + self.pending = Some(permission); + self.verify = Some(cx.spawn_in(window, async move |this, cx| { + let raw = cx + .background_executor() + .spawn(async { crate::permissions_ui::sweep_raw() }) + .await; + + let should_verify = match this.update_in(cx, |this, window, cx| { + let action = this.state.refreshed_action(permission, raw); + match action { + None => { this.pending = None; - if permission.required() && !this.state.status(permission).permitted() { - this.state.note_request_failed(permission); - this.state.note_settings_opened(permission); - permissions::open_permission_settings(permission); + if this.state.all_shown_granted() { + this.poll = None; + if !this.forced { + this.schedule_auto_finish(cx); + } } cx.notify(); - }) - .ok(); - })); + false + } + Some(RowAction::OpenSettings) => { + this.pending = None; + this.state.note_settings_opened(permission); + permissions::open_permission_settings(permission); + this.arm_poll(window, cx); + cx.notify(); + false + } + Some(RowAction::Request) => { + permissions::request_permission(permission); + this.arm_poll(window, cx); + cx.notify(); + true + } + } + }) { + Ok(should_verify) => should_verify, + Err(_) => return, + }; + + if !should_verify { + return; } - } - self.arm_poll(window, cx); + + cx.background_executor().timer(REQUEST_GRACE).await; + let raw = cx + .background_executor() + .spawn(async { crate::permissions_ui::sweep_raw() }) + .await; + + this.update_in(cx, |this, _window, cx| { + this.state.apply_raw(raw); + this.pending = None; + if permission.required() && !this.state.status(permission).permitted() { + this.state.note_request_failed(permission); + this.state.note_settings_opened(permission); + permissions::open_permission_settings(permission); + } + if this.state.all_shown_granted() { + this.poll = None; + if !this.forced { + this.schedule_auto_finish(cx); + } + } + cx.notify(); + }) + .ok(); + })); cx.notify(); } @@ -381,7 +461,7 @@ impl OnboardingWindow { let (granted, total) = self.state.granted_counts(); let all_granted = self.state.all_shown_granted(); let can_continue = self.state.necessary_granted(); - let (success_bg, success_border, success_fg) = self.success_palette(); + let (_, _, success_fg) = self.success_palette(); let rows = self .state @@ -392,117 +472,69 @@ impl OnboardingWindow { }) .collect::>(); - div() + let content = div() .flex() .flex_col() - .flex_1() - .min_h_0() + .min_h_full() + .w_full() .items_center() - .px(px(48.)) - .pt(px(10.)) - .pb(px(28.)) - // Header + .justify_center() + .gap(px(24.)) + .py(px(24.)) .child( div() .flex() .flex_col() + .flex_none() .items_center() - .gap(px(10.)) + .gap(px(12.)) .child( div() .flex() .items_center() .justify_center() .size(px(48.)) - .rounded(px(14.)) + .rounded(px(16.)) .border_1() - .map(|badge| { - if all_granted { - badge.bg(success_bg).border_color(success_border) - } else { - badge - .bg(Hsla::from(theme.gray_2)) - .border_color(Hsla::from(theme.gray_4)) - } - }) - .child(svg().path("icons/shield.svg").size(px(22.)).text_color( - if all_granted { - success_fg - } else { - Hsla::from(theme.gray_11) - }, - )), + .border_color(Hsla::from(theme.gray_4)) + .bg(Hsla::from(theme.gray_2)) + .child( + svg() + .path("icons/shield.svg") + .size(px(20.)) + .text_color(Hsla::from(theme.gray_11)), + ), ) .child( div() .text_size(px(24.)) .font_weight(FontWeight::BOLD) .text_color(Hsla::from(theme.gray_12)) - .child(if self.revisit { - "Permissions needed" - } else { - "Permissions" - }), + .child("Permissions Required"), ) .child( div() - .max_w(px(440.)) - .text_size(px(13.)) - .text_color(Hsla::from(theme.gray_11)) + .max_w(px(CARD_W)) + .text_size(px(14.)) + .line_height(px(20.)) + .text_color(Hsla::from(theme.gray_10)) .text_center() .child(if self.revisit { - "Cap no longer has the access it needs to record. Re-grant the permissions below to continue." + "Cap needs these permissions again to continue recording." } else { - "Cap needs a few macOS permissions to capture your screen, audio and camera." - }), - ) - .child( - div() - .flex() - .flex_row() - .items_center() - .gap(px(6.)) - .px(px(10.)) - .py(px(4.)) - .rounded_full() - .border_1() - .text_size(px(11.)) - .font_weight(FontWeight::MEDIUM) - .map(|pill| { - if all_granted { - pill.bg(success_bg) - .border_color(success_border) - .text_color(success_fg) - .child( - svg() - .path("icons/check.svg") - .size(px(11.)) - .text_color(success_fg), - ) - .child("All permissions granted") - } else { - pill.bg(Hsla::from(theme.gray_2)) - .border_color(Hsla::from(theme.gray_4)) - .text_color(Hsla::from(theme.gray_11)) - .child(SharedString::from(format!( - "{granted} of {total} granted" - ))) - } + "Cap needs a few permissions to record your screen and capture audio." }), ), ) - // Rows .child( div() .flex() .flex_col() .flex_none() .w(px(CARD_W)) - .gap(px(10.)) - .mt(px(22.)) + .gap(px(8.)) .children(rows), ) - // Relaunch hint (Tauri's "Restart Required" dialog, inline) .when(self.state.relaunch_hint(), |this| { this.child( div() @@ -512,7 +544,6 @@ impl OnboardingWindow { .items_center() .gap(px(ROW_GAP)) .w(px(CARD_W)) - .mt(px(12.)) .px(px(ROW_PAD_X)) .py(px(10.)) .rounded(px(12.)) @@ -530,28 +561,52 @@ impl OnboardingWindow { div() .w(px(HINT_TEXT_W)) .flex_none() - .truncate() - .text_size(px(12.)) + .text_size(px(11.)) + .line_height(px(15.)) .text_color(Hsla::from(theme.amber_11)) .child("Granted it in System Settings? Relaunch Cap to apply."), ) .child( - div().w(px(HINT_BUTTON_W)).flex_none().flex().justify_end().child( - ui::Button::plain( - &theme, - "perm-relaunch", - ui::ButtonVariant::White, - ui::ButtonSize::Sm, - ) - .icon("icons/rotate-ccw.svg") - .label("Relaunch Cap") - .on_click(cx.listener(|_, _, _, _| permissions::relaunch())), - ), + div() + .w(px(HINT_BUTTON_W)) + .flex_none() + .flex() + .justify_end() + .child( + ui::Button::plain( + &theme, + "perm-relaunch", + ui::ButtonVariant::White, + ui::ButtonSize::Sm, + ) + .radius(px(8.)) + .icon("icons/rotate-ccw.svg") + .label("Relaunch Cap") + .on_click(cx.listener(|_, _, _, _| permissions::relaunch())), + ), ), ) - }) - .child(div().flex_1()) - // Footer + }); + + div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .items_center() + .px(px(48.)) + .pb(px(24.)) + .child( + div() + .id("onboarding-permissions-content") + .flex() + .flex_col() + .flex_1() + .min_h_0() + .w_full() + .overflow_y_scroll() + .child(content), + ) .child( div() .flex() @@ -560,6 +615,7 @@ impl OnboardingWindow { .items_center() .justify_between() .w(px(CARD_W)) + .pt(px(16.)) .child( div() .w(px(FOOTER_TEXT_W)) @@ -571,13 +627,13 @@ impl OnboardingWindow { } else { Hsla::from(theme.gray_11) }) - .child(if all_granted { - "You're all set." + .child(SharedString::from(if all_granted { + "You're all set.".to_string() } else if !can_continue { - "Screen Recording and Accessibility are required to continue." + format!("{granted} of {total} permissions granted") } else { - "Microphone and Camera are optional. Grant them anytime." - }), + "Microphone and Camera are optional.".to_string() + })), ) .child( ui::Button::plain( @@ -586,6 +642,7 @@ impl OnboardingWindow { ui::ButtonVariant::Primary, ui::ButtonSize::Lg, ) + .radius(px(8.)) .label(if self.revisit { "Continue to Cap" } else { @@ -610,27 +667,17 @@ impl OnboardingWindow { let theme = self.theme; let (success_bg, success_border, success_fg) = self.success_palette(); let granted = status == OSPermissionStatus::Granted; - let denied = status == OSPermissionStatus::Denied; let busy = self.pending.is_some(); - // Icon tile, tinted by state. - let (tile_bg, tile_fg) = if granted { - (success_bg, success_fg) - } else if denied { - (Hsla::from(theme.red_3), Hsla::from(theme.red_11)) - } else { - (Hsla::from(theme.gray_3), Hsla::from(theme.gray_12)) - }; - let action: gpui::AnyElement = if granted { div() .flex() .flex_row() .items_center() .gap(px(6.)) - .px(px(10.)) - .h(px(30.)) - .rounded_full() + .px(px(12.)) + .h(px(28.)) + .rounded(px(8.)) .bg(success_bg) .border_1() .border_color(success_border) @@ -646,16 +693,17 @@ impl OnboardingWindow { .child("Granted") .into_any_element() } else { - let (label, variant) = match self.state.action(permission) { - Some(RowAction::OpenSettings) => ("Open Settings", ui::ButtonVariant::Gray), - _ => ("Grant", ui::ButtonVariant::Primary), + let label = match self.state.action(permission) { + Some(RowAction::OpenSettings) => "Open Settings", + _ => "Grant", }; ui::Button::plain( &theme, SharedString::from(format!("perm-action-{}", permission.label())), - variant, + ui::ButtonVariant::Gray, ui::ButtonSize::Sm, ) + .radius(px(8.)) .label(label) .disabled(busy) .on_click(cx.listener(move |this, _, window, cx| this.act(permission, window, cx))) @@ -670,34 +718,18 @@ impl OnboardingWindow { .gap(px(ROW_GAP)) .w(px(CARD_W)) .px(px(ROW_PAD_X)) - .py(px(14.)) - .rounded(px(14.)) + .py(px(12.)) + .rounded(px(12.)) .border_1() .border_color(Hsla::from(theme.gray_4)) .bg(Hsla::from(theme.gray_2)) - .child( - div() - .flex() - .flex_none() - .items_center() - .justify_center() - .size(px(ROW_ICON_TILE)) - .rounded(px(10.)) - .bg(tile_bg) - .child( - svg() - .path(permission.icon()) - .size(px(18.)) - .text_color(tile_fg), - ), - ) .child( div() .flex() .flex_col() .flex_none() .w(px(ROW_TEXT_W)) - .gap(px(3.)) + .gap(px(2.)) .child( div() .flex() @@ -711,36 +743,24 @@ impl OnboardingWindow { .text_color(Hsla::from(theme.gray_12)) .child(permission.label()), ) - .child( - div() - .text_size(px(10.)) - .font_weight(FontWeight::MEDIUM) - .px(px(6.)) - .py(px(2.)) - .rounded_full() - .bg(Hsla::from(theme.gray_3)) - .text_color(Hsla::from(theme.gray_11)) - .child(if permission.required() { - "Required" - } else { - "Optional" - }), - ) - .when(denied, |this| { + .when(!permission.required(), |this| { this.child( div() - .text_size(px(11.)) - .font_weight(FontWeight::MEDIUM) - .text_color(Hsla::from(theme.red_11)) - .child("Denied"), + .text_size(px(10.)) + .px(px(6.)) + .py(px(2.)) + .rounded_full() + .bg(Hsla::from(theme.gray_3)) + .text_color(Hsla::from(theme.gray_10)) + .child("Optional"), ) }), ) .child( div() - .truncate() - .text_size(px(12.)) - .text_color(Hsla::from(theme.gray_11)) + .text_size(px(11.)) + .line_height(px(15.)) + .text_color(Hsla::from(theme.gray_10)) .child(permission.blurb()), ), ) diff --git a/apps/desktop-gpui/src/permissions.rs b/apps/desktop-gpui/src/permissions.rs index 609053b4376..759f38222fc 100644 --- a/apps/desktop-gpui/src/permissions.rs +++ b/apps/desktop-gpui/src/permissions.rs @@ -54,14 +54,16 @@ impl OSPermission { } } - /// The one-line "why we need it" under the row name. Single line on - /// purpose: the row's text column is a fixed width and truncates. pub fn blurb(self) -> &'static str { match self { - Self::ScreenRecording => "Captures your screen and windows for recordings.", - Self::Accessibility => "Tracks mouse activity for automatic zoom.", - Self::Microphone => "Adds your voice to recordings.", - Self::Camera => "Shows your webcam in recordings.", + Self::ScreenRecording => { + "Click Grant to allow when macOS asks, or pick Cap in System Settings if needed. Restart the app after allowing screen recording." + } + Self::Accessibility => { + "During recording, Cap collects mouse activity locally to generate automatic zoom in segments." + } + Self::Microphone => "This permission is required to record audio in your Caps.", + Self::Camera => "This permission is required to record your camera in your Caps.", } } @@ -71,19 +73,9 @@ impl OSPermission { matches!(self, Self::ScreenRecording | Self::Accessibility) } - pub fn icon(self) -> &'static str { - match self { - // `screen.svg`, not `monitor.svg`: the latter is a multicolor - // asset, and `svg()` is alpha-mask-only -- it renders as a blob. - Self::ScreenRecording => "icons/screen.svg", - Self::Accessibility => "icons/cursor.svg", - Self::Microphone => "icons/microphone.svg", - Self::Camera => "icons/camera.svg", - } - } - /// The exact System Settings pane, verbatim from /// `macos_permission_settings_url` in the Tauri app. + #[cfg(any(target_os = "macos", test))] fn settings_url(self) -> &'static str { match self { Self::ScreenRecording => { diff --git a/apps/desktop-gpui/src/permissions_ui.rs b/apps/desktop-gpui/src/permissions_ui.rs index 72cc4a738f5..8297a4339eb 100644 --- a/apps/desktop-gpui/src/permissions_ui.rs +++ b/apps/desktop-gpui/src/permissions_ui.rs @@ -123,7 +123,15 @@ impl PermissionsState { } } - /// (granted, total) over the shown rows -- the header progress readout. + pub fn refreshed_action( + &mut self, + permission: OSPermission, + raw: Option, + ) -> Option { + self.apply_raw(raw); + self.action(permission) + } + pub fn granted_counts(&self) -> (usize, usize) { let mut granted = 0; let mut total = 0; @@ -360,6 +368,70 @@ mod tests { assert_eq!(state.granted_counts(), (4, 4)); } + #[test] + fn stale_request_is_skipped_when_permission_was_already_granted() { + for &permission in OSPermission::ALL { + let mut state = fresh_ungranted(); + assert_eq!(state.action(permission), Some(RowAction::Request)); + + let action = state.refreshed_action( + permission, + raw( + true, + true, + MediaAuthorization::Authorized, + MediaAuthorization::Authorized, + ), + ); + + assert_eq!(action, None); + assert_eq!(state.status(permission), OSPermissionStatus::Granted); + } + } + + #[test] + fn stale_settings_action_is_skipped_after_permission_is_granted() { + let mut state = fresh_ungranted(); + state.note_request_failed(OSPermission::ScreenRecording); + assert_eq!( + state.action(OSPermission::ScreenRecording), + Some(RowAction::OpenSettings) + ); + + let action = state.refreshed_action( + OSPermission::ScreenRecording, + raw( + true, + false, + MediaAuthorization::NotDetermined, + MediaAuthorization::NotDetermined, + ), + ); + + assert_eq!(action, None); + assert_eq!( + state.status(OSPermission::ScreenRecording), + OSPermissionStatus::Granted + ); + } + + #[test] + fn refreshed_denial_opens_settings_instead_of_requesting_again() { + let mut state = fresh_ungranted(); + + let action = state.refreshed_action( + OSPermission::Microphone, + raw( + false, + false, + MediaAuthorization::Denied, + MediaAuthorization::NotDetermined, + ), + ); + + assert_eq!(action, Some(RowAction::OpenSettings)); + } + #[test] fn apply_raw_reports_no_change_for_identical_sweeps() { let mut state = fresh_ungranted(); diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index 52c86b087f9..b8899ba5fc6 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -59,6 +59,15 @@ pub fn active_material(cx: &gpui::App) -> Option { .and_then(|material| material.0) } +#[cfg(any(not(target_os = "macos"), test))] +fn confirmation_accepted(result: rfd::MessageDialogResult, accept: &str) -> bool { + match result { + rfd::MessageDialogResult::Ok => true, + rfd::MessageDialogResult::Custom(label) => label == accept, + _ => false, + } +} + #[derive(Debug, Clone, Copy)] pub struct PanelBehavior { pub level: isize, @@ -320,7 +329,7 @@ mod mac { c"Q@:".as_ptr(), ) }; - if added { + if objc2::runtime::Bool::from_raw(added).as_bool() { tracing::info!("installed macOS 26 occlusion shim on {name}"); } } @@ -368,7 +377,7 @@ mod mac { c"{CGRect={CGPoint=dd}{CGSize=dd}}@:{CGRect={CGPoint=dd}{CGSize=dd}}@".as_ptr(), ) }; - if added { + if objc2::runtime::Bool::from_raw(added).as_bool() { tracing::info!("installed frame-constraint shim on {name}"); } } @@ -460,6 +469,20 @@ mod mac { } } + pub fn remove_popup_window_chrome(native: &NativeWindow) { + use objc2::msg_send; + + const TITLED: usize = 1 << 0; + + unsafe { + let mask: usize = msg_send![&*native.0, styleMask]; + let borderless = mask & !TITLED; + if mask != borderless { + let _: () = msg_send![&*native.0, setStyleMask: borderless]; + } + } + } + /// Dev probe (`CAP_GPUI_DEBUG_LIGHTS=1`): the window's style mask plus /// which standard titlebar buttons AppKit has materialized. Read-only /// `msg_send`s, safe inside a gpui update. @@ -579,11 +602,14 @@ mod mac { if screens.is_null() { return true; } - let count: usize = msg_send![screens, count]; - for index in 0..count { - let screen: *mut AnyObject = msg_send![screens, objectAtIndex: index]; + let enumerator: *mut AnyObject = msg_send![screens, objectEnumerator]; + if enumerator.is_null() { + return true; + } + loop { + let screen: *mut AnyObject = msg_send![enumerator, nextObject]; if screen.is_null() { - continue; + break; } let bounds: NSRect = msg_send![screen, frame]; let overlap_x = @@ -1591,6 +1617,7 @@ mod stub { None } pub fn restore_borderless_style(_native: &NativeWindow) {} + pub fn remove_popup_window_chrome(_native: &NativeWindow) {} pub fn place_overlay_panel( _native: &NativeWindow, _x: f64, @@ -1600,9 +1627,9 @@ mod stub { _level: isize, ) { } - pub fn install_occlusion_shim() {} pub fn kick_display_link(_window: &Window) {} - pub fn apply_panel_behavior(window: &Window, _behavior: PanelBehavior) { + pub fn apply_panel_behavior(window: &Window, behavior: PanelBehavior) { + let _ = (behavior.level, behavior.join_all_spaces, behavior.shadow); apply_always_on_top(window); } @@ -1613,7 +1640,7 @@ mod stub { use windows_sys::Win32::UI::WindowsAndMessaging::{ HWND_TOPMOST, SWP_NOMOVE, SWP_NOSIZE, SetWindowPos, }; - if let Ok(handle) = window.window_handle() + if let Ok(handle) = HasWindowHandle::window_handle(window) && let RawWindowHandle::Win32(win) = handle.as_raw() { let hwnd = win.hwnd.get() as windows_sys::Win32::Foundation::HWND; @@ -1636,7 +1663,7 @@ mod stub { CLIENT_MESSAGE_EVENT, ClientMessageEvent, ConnectionExt, EventMask, }; - let Ok(handle) = window.window_handle() else { + let Ok(handle) = HasWindowHandle::window_handle(window) else { return; }; let window_id = match handle.as_raw() { @@ -1685,9 +1712,6 @@ mod stub { } pub fn close_native(_native: &NativeWindow) {} - pub fn debug_window_state(_native: &NativeWindow) -> String { - String::new() - } pub fn set_dock_icon(_png: &[u8]) {} pub fn escape_hotkey_events() -> flume::Receiver<()> { // A channel whose sender is dropped immediately: the drain task's @@ -1724,7 +1748,7 @@ mod stub { } else { rfd::MessageLevel::Info }; - rfd::MessageDialog::new() + let result = rfd::MessageDialog::new() .set_title(title) .set_description(message) .set_buttons(rfd::MessageButtons::OkCancelCustom( @@ -1732,8 +1756,8 @@ mod stub { cancel.to_string(), )) .set_level(level) - .show() - == rfd::MessageDialogResult::Custom(accept.to_string()) + .show(); + super::confirmation_accepted(result, accept) } pub fn alert_dialog(title: &str, message: &str) { @@ -1809,3 +1833,36 @@ mod stub { #[cfg(not(target_os = "macos"))] pub use stub::*; + +#[cfg(test)] +mod tests { + #[test] + fn confirmation_accepts_native_ok_and_matching_custom_label_only() { + let accept = "Install update"; + + assert!(super::confirmation_accepted( + rfd::MessageDialogResult::Ok, + accept + )); + assert!(super::confirmation_accepted( + rfd::MessageDialogResult::Custom(accept.to_string()), + accept + )); + assert!(!super::confirmation_accepted( + rfd::MessageDialogResult::Custom("Ignore".to_string()), + accept + )); + assert!(!super::confirmation_accepted( + rfd::MessageDialogResult::Cancel, + accept + )); + assert!(!super::confirmation_accepted( + rfd::MessageDialogResult::No, + accept + )); + assert!(!super::confirmation_accepted( + rfd::MessageDialogResult::Yes, + accept + )); + } +} diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index b60f0b58b94..1bb497e37d4 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -59,6 +59,7 @@ enum Handle { pub struct ActiveRecording { handle: Handle, pub project_dir: PathBuf, + instant_upload: Option, /// Recording-scoped mic mute (payload zeroing at the consumer seam; the /// stream cadence is unaffected). `None` when the recording has no mic. pub mic_mute: Option>, @@ -73,6 +74,12 @@ pub struct ActiveRecording { } impl ActiveRecording { + pub fn instant_share_url(&self) -> Option<&str> { + self.instant_upload + .as_ref() + .map(|upload| upload.video().link.as_str()) + } + /// Stop and finalize. Returns the project directory. /// /// Finalization mirrors the CLI's `finalize_completed`: studio projects get @@ -121,6 +128,11 @@ impl ActiveRecording { Handle::Studio(handle) => handle.cancel().await?, Handle::Instant(handle) => handle.cancel().await?, } + let remote_result = if let Some(upload) = self.instant_upload { + upload.cancel().await + } else { + Ok(()) + }; tokio::task::spawn_blocking({ let dir = self.project_dir.clone(); move || std::fs::remove_dir_all(&dir) @@ -128,10 +140,12 @@ impl ActiveRecording { .await .context("delete task")? .with_context(|| format!("deleting {}", self.project_dir.display()))?; + remote_result.map_err(anyhow::Error::msg)?; Ok(()) } pub async fn stop(self) -> anyhow::Result { + let mut instant_upload = self.instant_upload; match self.handle { Handle::Studio(handle) => { let completed = handle.stop().await?; @@ -163,23 +177,32 @@ impl ActiveRecording { Handle::Instant(handle) => { let completed = handle.stop().await?; let project_path = completed.project_path.clone(); + let upload = instant_upload + .as_mut() + .ok_or_else(|| anyhow!("instant recording has no upload session"))?; + let segmented = upload.is_segmented(); + let segment_upload_result = upload.finish_segments().await; let display_dir = project_path.join("content/display"); let audio_dir = project_path.join("content/audio"); let output_path = project_path.join("content/output.mp4"); - let muxed = output_path.clone(); - tokio::task::spawn_blocking(move || { - cap_recording::recovery::RecoveryManager::finalize_instant_output( - &display_dir, - &audio_dir, - &muxed, - ) - }) - .await - .context("instant finalize task")? - .context("instant finalize")?; + if display_dir.is_dir() { + let muxed = output_path.clone(); + tokio::task::spawn_blocking(move || { + cap_recording::recovery::RecoveryManager::finalize_instant_output( + &display_dir, + &audio_dir, + &muxed, + ) + }) + .await + .context("instant finalize task")? + .context("instant finalize")?; + } else if !output_path.is_file() { + return Err(anyhow!("instant recording has no finalized output")); + } - persist_instant_meta(&completed)?; + persist_instant_meta(&completed, upload.video())?; // The Tauri app builds the instant thumbnail by concatenating // `content/display`'s init segment with the first media @@ -196,6 +219,52 @@ impl ActiveRecording { .await .context("instant thumbnail task")?; + if let Err(error) = segment_upload_result { + persist_instant_upload_failure(&completed.project_path, &error)?; + return Err(anyhow!(error)); + } + + let upload_result = if segmented { + upload.finish_screenshot(&completed.project_path).await + } else { + crate::upload::upload_exported_video( + completed.project_path.clone(), + None, + |_| {}, + Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .await + .and_then(|result| match result { + crate::upload::UploadResult::Success(_) => Ok(()), + crate::upload::UploadResult::NotAuthenticated => Err( + "Your session has expired. Please sign in again to upload this recording." + .to_string(), + ), + crate::upload::UploadResult::UpgradeRequired => { + Err("Instant recording requires an upgraded plan.".to_string()) + } + }) + }; + if let Err(error) = upload_result { + persist_instant_upload_failure(&completed.project_path, &error)?; + return Err(anyhow!(error)); + } + + let mut meta = + cap_project::RecordingMeta::load_for_project(&completed.project_path) + .map_err(|error| anyhow!("loading instant recording metadata: {error}"))?; + meta.upload = Some(cap_project::UploadMeta::Complete); + meta.save_for_project() + .map_err(|error| anyhow!("saving completed instant upload: {error}"))?; + + if crate::store::GeneralSettings::load().delete_instant_recordings_after_upload { + let directory = completed.project_path.clone(); + tokio::task::spawn_blocking(move || std::fs::remove_dir_all(directory)) + .await + .context("instant upload cleanup task")? + .context("deleting uploaded instant recording")?; + } + Ok(completed.project_path) } } @@ -361,7 +430,10 @@ pub fn apply_camera_blur_to_project_config( /// `persist_instant_recording_meta` from the CLI, verbatim in behavior: without /// this pair of files the recording plays but no Cap surface lists it. -fn persist_instant_meta(completed: &instant_recording::CompletedRecording) -> anyhow::Result<()> { +fn persist_instant_meta( + completed: &instant_recording::CompletedRecording, + upload: &cap_project::VideoUploadInfo, +) -> anyhow::Result<()> { use cap_project::{ InstantRecordingMeta, Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner, }; @@ -380,13 +452,21 @@ fn persist_instant_meta(completed: &instant_recording::CompletedRecording) -> an other => other.clone(), }; + let previous_upload = RecordingMeta::load_for_project(&completed.project_path) + .ok() + .and_then(|meta| meta.upload); + RecordingMeta { platform: Some(Platform::default()), project_path: completed.project_path.clone(), pretty_name, - sharing: None, + sharing: Some(cap_project::SharingMeta { + id: upload.id.clone(), + link: upload.link.clone(), + content_hash: None, + }), inner: RecordingMetaInner::Instant(meta), - upload: None, + upload: previous_upload, } .save_for_project() .map_err(|e| anyhow!("saving instant recording meta: {e}"))?; @@ -397,6 +477,61 @@ fn persist_instant_meta(completed: &instant_recording::CompletedRecording) -> an Ok(()) } +fn persist_instant_upload_failure( + project_path: &std::path::Path, + error: &str, +) -> anyhow::Result<()> { + let mut meta = cap_project::RecordingMeta::load_for_project(project_path) + .map_err(|load_error| anyhow!("loading failed instant recording metadata: {load_error}"))?; + meta.upload = Some(cap_project::UploadMeta::Failed { + error: error.to_string(), + }); + meta.save_for_project() + .map_err(|save_error| anyhow!("saving failed instant upload: {save_error}"))?; + Ok(()) +} + +fn persist_in_progress_instant_meta( + project_path: &std::path::Path, + video: &cap_project::VideoUploadInfo, + segmented: bool, +) -> anyhow::Result<()> { + let upload = if segmented { + cap_project::UploadMeta::SegmentUpload { + video_id: video.id.clone(), + pre_created_video: video.clone(), + recording_dir: project_path.to_path_buf(), + } + } else { + cap_project::UploadMeta::MultipartUpload { + video_id: video.id.clone(), + file_path: project_path.join("content/output.mp4"), + pre_created_video: video.clone(), + recording_dir: project_path.to_path_buf(), + } + }; + let pretty_name = project_path + .file_stem() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Cap Recording") + .to_string(); + + cap_project::RecordingMeta { + platform: Some(cap_project::Platform::default()), + project_path: project_path.to_path_buf(), + pretty_name, + sharing: None, + inner: cap_project::RecordingMetaInner::Instant( + cap_project::InstantRecordingMeta::InProgress { recording: true }, + ), + upload: Some(upload), + } + .save_for_project() + .map_err(|error| anyhow!("saving in-progress instant recording metadata: {error}"))?; + Ok(()) +} + pub async fn start(config: StartConfig) -> anyhow::Result { match start_attempt(config.clone()).await { Ok(active) => Ok(active), @@ -421,7 +556,55 @@ pub async fn start(config: StartConfig) -> anyhow::Result { } async fn start_attempt(config: StartConfig) -> anyhow::Result { - let project_dir = create_project_dir(&config.target)?; + if matches!(config.target, ScreenCaptureTarget::CameraOnly) && config.camera.is_none() { + return Err(anyhow!("Camera-only recording requires a selected camera.")); + } + if config.mode == RecordingMode::Instant && !crate::store::auth_snapshot().signed_in() { + return Err(anyhow!("Please sign in to use instant recording")); + } + + let project_dir = create_project_dir(&config.target, config.mode)?; + let project_name = project_dir + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("Cap Recording") + .to_string(); + let organization_id = crate::store::store_section(crate::store::RECORDING_SETTINGS) + .get("organizationId") + .and_then(serde_json::Value::as_str) + .filter(|organization| !organization.is_empty()) + .map(str::to_string); + + let pre_created_video = if config.mode == RecordingMode::Instant { + Some( + crate::upload::prepare_instant_upload( + matches!(config.target, ScreenCaptureTarget::CameraOnly), + project_name, + organization_id, + ) + .await + .map_err(anyhow::Error::msg)?, + ) + } else { + None + }; + + let result = start_attempt_with_upload(config, project_dir, pre_created_video.clone()).await; + if result.is_err() + && let Some(video) = pre_created_video + && let Err(error) = crate::upload::delete_instant_video(&video.id).await + { + tracing::error!(video_id = %video.id, "Failed to clean up instant recording: {error}"); + return Err(anyhow!("Failed to clean up instant recording: {error}")); + } + result +} + +async fn start_attempt_with_upload( + config: StartConfig, + project_dir: PathBuf, + pre_created_video: Option, +) -> anyhow::Result { tracing::info!(dir = %project_dir.display(), "starting recording"); // The app-scoped feeds (running previews/meters, owned by `Feeds`) are @@ -531,9 +714,7 @@ async fn start_attempt(config: StartConfig) -> anyhow::Result { } excluded }; - #[cfg(not(target_os = "macos"))] - let excluded_windows = config.excluded_windows.clone(); - + let mut instant_upload = None; let handle = match config.mode { RecordingMode::Studio => { let mut builder = defaults.apply_to_studio_builder( @@ -542,7 +723,10 @@ async fn start_attempt(config: StartConfig) -> anyhow::Result { camera_lock.is_some(), None, ); - builder = builder.with_excluded_windows(excluded_windows.clone()); + #[cfg(target_os = "macos")] + { + builder = builder.with_excluded_windows(excluded_windows.clone()); + } if let Some(lock) = camera_lock.clone() { builder = builder.with_camera_feed(lock); } @@ -563,15 +747,18 @@ async fn start_attempt(config: StartConfig) -> anyhow::Result { let mut builder = instant_recording::Actor::builder(project_dir.clone(), config.target.clone()) .with_system_audio(config.system_audio) - .with_max_output_size(instant_max_resolution) - .with_excluded_windows(excluded_windows.clone()); + .with_max_output_size(instant_max_resolution); + #[cfg(target_os = "macos")] + { + builder = builder.with_excluded_windows(excluded_windows.clone()); + } if let Some(lock) = camera_lock.clone() { builder = builder.with_camera_feed(lock); } if let Some(lock) = mic_lock.clone() { builder = builder.with_mic_feed(lock); } - Handle::Instant(Arc::new( + let handle = Arc::new( builder .build( #[cfg(target_os = "macos")] @@ -579,13 +766,23 @@ async fn start_attempt(config: StartConfig) -> anyhow::Result { ) .await .context("instant recording actor")?, - )) + ); + let video = pre_created_video + .ok_or_else(|| anyhow!("instant recording has no reserved upload"))?; + let segment_rx = handle.take_segment_rx(); + persist_in_progress_instant_meta(&project_dir, &video, segment_rx.is_some())?; + instant_upload = Some( + crate::upload::start_instant_upload(video, project_dir.clone(), segment_rx) + .map_err(anyhow::Error::msg)?, + ); + Handle::Instant(handle) } }; Ok(ActiveRecording { handle, project_dir, + instant_upload, mic_mute, _mic_feed: mic_feed, _camera_feed: camera_feed, @@ -764,19 +961,41 @@ pub fn delete_recording_directory(path: &std::path::Path) -> Result<(), String> /// `format_project_name` with the default template /// (`{target_name} ({target_kind}) {date} {time}`), then the same `:` -> `.` /// replacement and uniquing the Tauri app applies. -fn create_project_dir(target: &ScreenCaptureTarget) -> anyhow::Result { +fn create_project_dir( + target: &ScreenCaptureTarget, + recording_mode: RecordingMode, +) -> anyhow::Result { let base = recordings_dir(); std::fs::create_dir_all(&base) .with_context(|| format!("creating recordings dir {}", base.display()))?; + match cap_utils::disk_space::free_bytes_for_path(&base) { + Ok(bytes) if bytes <= cap_utils::disk_space::LOW_DISK_STOP_BYTES => { + return Err(anyhow!( + "Not enough disk space to start recording ({:.2} GB free). Free up at least {} MB and try again.", + bytes as f64 / 1_073_741_824.0, + cap_utils::disk_space::LOW_DISK_STOP_BYTES / (1024 * 1024) + )); + } + Ok(bytes) if bytes <= cap_utils::disk_space::LOW_DISK_WARN_BYTES => { + tracing::warn!( + bytes_remaining = bytes, + "Starting recording with low disk space" + ); + } + Ok(_) => {} + Err(error) => tracing::warn!("Failed to check disk space before recording: {error}"), + } + let target_name = target.title().unwrap_or_else(|| "Unknown".into()); let now = chrono::Local::now(); - let name = format!( - "{} ({}) {} {}", - target_name, + let settings = crate::store::GeneralSettings::load(); + let name = format_recording_project_name( + settings.default_project_name_template.as_deref(), + &target_name, target.kind_str(), - now.format("%Y-%m-%d"), - now.format("%I.%M %p"), + recording_mode, + now, ); // Same normalization chain as the Tauri app: colons break Finder, slashes // break paths. @@ -787,12 +1006,113 @@ fn create_project_dir(target: &ScreenCaptureTarget) -> anyhow::Result { Ok(base.join(filename)) } +fn format_recording_project_name( + template: Option<&str>, + target_name: &str, + target_kind: &str, + mode: RecordingMode, + datetime: chrono::DateTime, +) -> String { + let target_name = if target_name.chars().count() > 180 { + format!("{}...", target_name.chars().take(180).collect::()) + } else { + target_name.to_string() + }; + let (recording_mode, mode) = match mode { + RecordingMode::Studio => ("Studio", "studio"), + RecordingMode::Instant => ("Instant", "instant"), + }; + let formatted = template + .unwrap_or(crate::store::DEFAULT_PROJECT_NAME_TEMPLATE) + .replace("{recording_mode}", recording_mode) + .replace("{mode}", mode) + .replace("{target_kind}", target_kind) + .replace("{target_name}", &target_name); + let formatted = replace_datetime_template_token(&formatted, "date", "%Y-%m-%d", datetime); + let formatted = replace_datetime_template_token(&formatted, "time", "%I:%M %p", datetime); + replace_datetime_template_token(&formatted, "moment", "%Y-%m-%d %H:%M", datetime) +} + +fn replace_datetime_template_token( + input: &str, + name: &str, + default_format: &str, + datetime: chrono::DateTime, +) -> String { + let mut output = String::with_capacity(input.len()); + let mut remaining = input; + let prefix = format!("{{{name}"); + + while let Some(start) = remaining.find(&prefix) { + output.push_str(&remaining[..start]); + let candidate = &remaining[start..]; + let Some(end) = candidate.find('}') else { + output.push_str(candidate); + return output; + }; + let token = &candidate[1..end]; + if token == name { + output.push_str(&datetime.format(default_format).to_string()); + } else if let Some(custom_format) = token.strip_prefix(&format!("{name}:")) { + let format = cap_utils::moment_format_to_chrono(custom_format); + output.push_str(&datetime.format(&format).to_string()); + } else { + output.push_str(&candidate[..=end]); + } + remaining = &candidate[end + 1..]; + } + output.push_str(remaining); + output +} + #[cfg(test)] mod tests { use super::*; use crate::store::BlurMode; + use chrono::TimeZone as _; use serde_json::Value; + #[test] + fn recording_project_names_honor_mode_target_and_custom_datetime_formats() { + let timestamp = chrono::Local + .with_ymd_and_hms(2026, 8, 25, 14, 7, 9) + .single() + .unwrap(); + + assert_eq!( + format_recording_project_name( + Some( + "{recording_mode}-{mode}-{target_kind}-{target_name}-{date:DD/MM/YYYY}-{time:HH.mm}-{moment:YYYYMMDD_HHmmss}" + ), + "Example Window", + "Window", + RecordingMode::Instant, + timestamp, + ), + "Instant-instant-Window-Example Window-25/08/2026-14.07-20260825_140709" + ); + } + + #[test] + fn recording_project_names_preserve_unknown_tokens_and_limit_target_length() { + let timestamp = chrono::Local + .with_ymd_and_hms(2026, 8, 25, 9, 15, 0) + .single() + .unwrap(); + let target = "x".repeat(200); + + assert_eq!( + format_recording_project_name( + Some("{mode}-{target_name}-{unknown}"), + &target, + "Display", + RecordingMode::Studio, + timestamp, + ), + format!("studio-{}...-{{unknown}}", "x".repeat(180)) + ); + } + fn temp_project(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "cap-gpui-config-{tag}-{}-{:?}", diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index a49a200ec76..562265e85da 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -964,6 +964,10 @@ pub struct ScreenshotEditorWindow { } impl ScreenshotEditorWindow { + pub(crate) fn export_in_flight(&self) -> bool { + self.exporting + } + pub fn new(bundle: PathBuf, window: &mut Window, cx: &mut Context) -> Self { let close_bundle = bundle.clone(); window.on_window_should_close(cx, move |_window, cx| { diff --git a/apps/desktop-gpui/src/session.rs b/apps/desktop-gpui/src/session.rs index efa97c47c84..8063df86938 100644 --- a/apps/desktop-gpui/src/session.rs +++ b/apps/desktop-gpui/src/session.rs @@ -210,6 +210,13 @@ impl RecordingSession { let Some(active) = self.active.take() else { return; }; + let instant_share_url = active.instant_share_url().map(ToString::to_string); + if let Some(link) = &instant_share_url + && !crate::store::GeneralSettings::load().disable_auto_open_links + { + let separator = if link.contains('?') { '&' } else { '?' }; + cx.open_url(&format!("{link}{separator}recordingStopped=1")); + } self.phase = Phase::Stopping; cx.notify(); @@ -228,6 +235,8 @@ impl RecordingSession { tracing::info!(dir = %project_dir.display(), "recording finished"); if this.mode() == Some(crate::recording::RecordingMode::Studio) { this.finished_studio = Some(project_dir); + } else if let Some(link) = instant_share_url { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(link)); } } Ok(Err(error)) => { diff --git a/apps/desktop-gpui/src/settings_pages.rs b/apps/desktop-gpui/src/settings_pages.rs index d382be3458d..70ff5a6e735 100644 --- a/apps/desktop-gpui/src/settings_pages.rs +++ b/apps/desktop-gpui/src/settings_pages.rs @@ -1967,15 +1967,22 @@ fn takeover_frame(elapsed_ms: f32) -> (usize, f32, u32) { /// bundle and is not a dev build either. const CLASSIC_APP_FALLBACK: &str = "/Applications/Cap.app"; +#[cfg(windows)] +const CLASSIC_EXECUTABLE_NAME: &str = "Cap.exe"; +#[cfg(not(windows))] +const CLASSIC_EXECUTABLE_NAME: &str = "Cap"; + /// What "the classic app" means for this process -- decided from where its /// binary lives, so dev sessions reopen the dev app and installed ones the /// installed app. #[derive(Debug, PartialEq)] enum ClassicTarget { /// `open` this bundle: the shipped layout is - /// `.../Cap.app/Contents/Resources/gpui/cap-gpui`, so the nearest `.app` + /// `.../Cap.app/Contents/MacOS/cap-gpui`, so the nearest `.app` /// ancestor is the classic app this binary shipped inside. Bundle(std::path::PathBuf), + /// Windows and Linux install Tauri sidecars beside the main executable. + Executable(std::path::PathBuf), /// A cargo-built binary (an ancestor directory literally named `target`): /// the classic app here is the `tauri dev` harness, which cannot be /// `open`ed -- ask the dev-session supervisor to restart it instead @@ -1996,6 +2003,14 @@ fn classic_target_for_exe(exe: &std::path::Path) -> Option { { return Some(ClassicTarget::DevSupervisor); } + + if let Some(parent) = exe.parent() { + let executable = parent.join(CLASSIC_EXECUTABLE_NAME); + if executable.is_file() && executable != exe { + return Some(ClassicTarget::Executable(executable)); + } + } + let fallback = std::path::PathBuf::from(CLASSIC_APP_FALLBACK); fallback.is_dir().then_some(ClassicTarget::Bundle(fallback)) } @@ -2007,28 +2022,128 @@ fn classic_target() -> Option { .and_then(classic_target_for_exe) } -impl SettingsWindow { - pub(crate) fn render_experimental(&self, cx: &mut Context) -> Vec { - let theme = self.theme; +pub(crate) fn start_update_handoff(cx: &mut gpui::App) { + begin_update_handoff(cx, store::request_update_handoff); +} - if cfg!(target_os = "windows") { - return vec![ - div() - .px(px(4.)) - .text_size(px(12.)) - .line_height(px(18.)) - .text_color(theme.settings_muted()) - .child("No experimental features are currently available on this platform.") - .into_any_element(), - ]; +fn quit_after_flushing_editors(cx: &mut gpui::App) { + crate::app_windows::flush_pending_editor_saves(cx); + crate::menus::quit(cx); +} + +#[cfg(debug_assertions)] +pub(crate) fn simulate_update_handoff(cx: &mut gpui::App) { + cx.spawn(async move |cx| { + crate::platform::activate_app(); + if crate::platform::confirm_dialog( + "Update Cap", + "Version 99.0.0 of Cap is available. Would you like to install it?", + "Update", + "Ignore", + false, + ) { + cx.update(|cx| begin_update_handoff(cx, store::request_simulated_update_handoff)); + } + }) + .detach(); +} + +fn begin_update_handoff(cx: &mut gpui::App, request_handoff: fn() -> std::io::Result<()>) { + if update_handoff_blocked(cx) { + return; + } + + let Some(target) = classic_target() else { + cx.open_url("https://cap.so/download"); + return; + }; + + if matches!(target, ClassicTarget::DevSupervisor) && !cfg!(debug_assertions) { + cx.open_url("https://cap.so/download"); + return; + } + + if update_handoff_blocked(cx) { + return; + } + crate::app_windows::flush_pending_editor_saves(cx); + + if let Err(error) = request_handoff() { + tracing::error!("couldn't request the Tauri updater: {error}"); + cx.open_url("https://cap.so/download"); + return; + } + + let started = match &target { + ClassicTarget::Bundle(_) | ClassicTarget::Executable(_) => launch_classic(&target), + ClassicTarget::DevSupervisor => { + store::mark_classic_pending().and_then(|()| store::request_classic_reopen()) + } + }; + + match (started, target) { + (Ok(()), ClassicTarget::Bundle(_) | ClassicTarget::Executable(_)) => { + tracing::info!("handing off to the Tauri updater"); + quit_after_flushing_editors(cx); + } + (Ok(()), ClassicTarget::DevSupervisor) => { + tracing::info!("handing off to the Tauri updater; waiting for the dev app"); + cx.spawn(async move |cx| { + let started = std::time::Instant::now(); + loop { + cx.background_executor() + .timer(Duration::from_millis(250)) + .await; + + if !store::classic_pending_path().exists() { + tracing::info!("the Tauri updater is ready; quitting Cap GPUI"); + cx.update(quit_after_flushing_editors); + return; + } + + if started.elapsed() > CLASSIC_WAIT_TIMEOUT { + store::clear_update_handoff(); + tracing::error!("the dev app did not start for the update hand-off"); + return; + } + } + }) + .detach(); + } + (Err(error), _) => { + store::clear_update_handoff(); + tracing::error!("couldn't open the Tauri updater: {error}"); + cx.open_url("https://cap.so/download"); } + } +} + +fn update_handoff_blocked(cx: &mut gpui::App) -> bool { + if !crate::updates::work_in_flight(cx) { + return false; + } + + tracing::info!( + "deferring update hand-off while recording, exporting, uploading, importing, or transcribing" + ); + cx.spawn(async move |_| { + crate::platform::alert_dialog( + "Cap is busy", + "Finish your recording, export, upload, import, or transcription task before checking for updates.", + ); + }) + .detach(); + true +} +impl SettingsWindow { + pub(crate) fn render_experimental(&self, cx: &mut Context) -> Vec { // No "Native camera preview" toggle here: in this app the native // camera path is the only implementation, so the Tauri page's // experimental switch has nothing to switch. The store key // (`enableNativeCameraPreview`) stays readable in `store.rs` and is // left untouched in the shared store -- the Tauri app still uses it. - vec![ + let sections = vec![ self.section( "Reliability", None, @@ -2067,7 +2182,44 @@ impl SettingsWindow { vec![self.rows(vec![self.native_app_row(cx)]).into_any_element()], ) .into_any_element(), - ] + ]; + + #[cfg(debug_assertions)] + let sections = { + let mut sections = sections; + sections.push( + self.section( + "Updates", + None, + None, + vec![ + self.rows(vec![ + self.setting_row( + "Simulate an update", + Some( + "Preview the complete update flow without downloading or \ + installing anything.", + ), + self.button( + "simulate-update", + (ui::ButtonVariant::Dark, None), + "Simulate update", + false, + cx, + |_, _, cx| simulate_update_handoff(cx), + ) + .into_any_element(), + ), + ]) + .into_any_element(), + ], + ) + .into_any_element(), + ); + sections + }; + + sections } /// The mirror of the Tauri page's Native app row: it hands the session over @@ -2205,6 +2357,10 @@ impl SettingsWindow { } fn start_switch_back(&mut self, cx: &mut Context) { + if self.switch_back_blocked(cx) { + return; + } + self.pages.switch_back = Some(SwitchBack::Running(std::time::Instant::now())); // gpui only renders on invalidation, so the fades and the countdown // need a pulse to run at all -- the `toggle_placeholders` shape. @@ -2243,11 +2399,28 @@ impl SettingsWindow { cx.notify(); } + fn switch_back_blocked(&mut self, cx: &mut Context) -> bool { + if !crate::updates::work_in_flight(cx) { + return false; + } + + self.pages.switch_back = Some(SwitchBack::Failed( + "Finish your recording, export, upload, import, or transcription task before switching to the classic app." + .to_string(), + )); + cx.notify(); + true + } + /// Write the flag, start the classic app, then quit -- in that order, so /// the app that comes up already owns the session. Nothing is written when /// there is no way to start one: the flag would strand the user in an app /// that redirects to one that is not installed. fn finish_switch_back(&mut self, cx: &mut Context) { + if self.switch_back_blocked(cx) { + return; + } + let Some(target) = classic_target() else { self.pages.switch_back = Some(SwitchBack::Failed( "Couldn't find the Cap app to switch back to.".to_string(), @@ -2256,14 +2429,16 @@ impl SettingsWindow { return; }; + if self.switch_back_blocked(cx) { + return; + } + crate::app_windows::flush_pending_editor_saves(cx); + self.settings.enable_gpui_app = false; self.write_bool("enableGpuiApp", false, cx); let started = match &target { - ClassicTarget::Bundle(bundle) => std::process::Command::new("/usr/bin/open") - .arg(bundle) - .spawn() - .map(drop) + ClassicTarget::Bundle(_) | ClassicTarget::Executable(_) => launch_classic(&target) .map_err(|error| format!("Couldn't open the Cap app: {error}")), ClassicTarget::DevSupervisor => store::mark_classic_pending() .and_then(|()| store::request_classic_reopen()) @@ -2272,9 +2447,9 @@ impl SettingsWindow { match (started, target) { // An installed bundle opens in a moment; quit right away. - (Ok(()), ClassicTarget::Bundle(_)) => { + (Ok(()), ClassicTarget::Bundle(_) | ClassicTarget::Executable(_)) => { tracing::info!("handing back to the classic app"); - crate::menus::quit(cx); + quit_after_flushing_editors(cx); } // The dev harness has to rebuild first, which can take minutes. // Stay up until the classic app deletes the pending file to say @@ -2304,7 +2479,7 @@ impl SettingsWindow { } if !store::classic_pending_path().exists() { tracing::info!("classic app is up; quitting"); - cx.update(crate::menus::quit); + cx.update(quit_after_flushing_editors); break; } if started.elapsed() > CLASSIC_WAIT_TIMEOUT { @@ -2333,6 +2508,19 @@ impl SettingsWindow { } } +fn launch_classic(target: &ClassicTarget) -> std::io::Result<()> { + match target { + ClassicTarget::Bundle(bundle) => std::process::Command::new("/usr/bin/open") + .arg(bundle) + .spawn() + .map(drop), + ClassicTarget::Executable(executable) => { + std::process::Command::new(executable).spawn().map(drop) + } + ClassicTarget::DevSupervisor => Ok(()), + } +} + // --------------------------------------------------------------------------- // Feedback (feedback.tsx) // --------------------------------------------------------------------------- @@ -5691,12 +5879,8 @@ fn action_is_dangerous(kind: ActionType) -> bool { /// everything, with OCR only where Vision/Windows-OCR exists. `skipEditor` /// requires no capability at all (`required_capability` returns `None`). fn action_supported_here(action: &Action) -> bool { - match action { - Action::RecognizeTextToClipboard => { - cfg!(any(target_os = "macos", target_os = "windows")) - } - _ => true, - } + !matches!(action, Action::RecognizeTextToClipboard) + || cfg!(any(target_os = "macos", target_os = "windows")) } /// `ruleSummary`. @@ -8455,7 +8639,7 @@ mod tests { fn the_classic_target_matches_the_launch_context() { assert_eq!( classic_target_for_exe(std::path::Path::new( - "/Applications/Cap.app/Contents/Resources/gpui/cap-gpui" + "/Applications/Cap.app/Contents/MacOS/cap-gpui" )), Some(ClassicTarget::Bundle(std::path::PathBuf::from( "/Applications/Cap.app" @@ -8470,7 +8654,7 @@ mod tests { // A dev binary staged inside a bundle is still that bundle's. assert_eq!( classic_target_for_exe(std::path::Path::new( - "/Users/x/Cap/target/debug/bundle/osx/Cap.app/Contents/Resources/gpui/cap-gpui" + "/Users/x/Cap/target/debug/bundle/osx/Cap.app/Contents/MacOS/cap-gpui" )), Some(ClassicTarget::Bundle(std::path::PathBuf::from( "/Users/x/Cap/target/debug/bundle/osx/Cap.app" diff --git a/apps/desktop-gpui/src/settings_window.rs b/apps/desktop-gpui/src/settings_window.rs index 9e993b4c051..7339090dab4 100644 --- a/apps/desktop-gpui/src/settings_window.rs +++ b/apps/desktop-gpui/src/settings_window.rs @@ -39,6 +39,7 @@ pub const SETTINGS_WIDTH: f32 = 782.; pub const SETTINGS_HEIGHT: f32 = 775.; pub const SETTINGS_MIN_WIDTH: f32 = 780.; pub const SETTINGS_MIN_HEIGHT: f32 = 560.; +const MAX_PROFILE_IMAGE_BYTES: usize = 5 * 1024 * 1024; /// `CapWindowId::Settings::traffic_lights_position` -- `Some(Some( /// LogicalPosition::new(22.0, 22.0)))`. Unlike the main window these are the @@ -53,12 +54,9 @@ pub const TRAFFIC_LIGHTS: Point = Point { y: px(15.), }; -/// `applyMacOSWindowMaterial("settings")` -> `radius = 26` under liquid glass. -/// The vibrancy fallback uses 16, which is also `--macos-settings-window-radius` -/// in the `:root` block; the material install is told 26 either way because -/// `install_window_material` only uses it for the glass view's own corner and -/// the content-view clip, and the vibrancy path re-clips to the same rect. -pub const SETTINGS_MATERIAL_RADIUS: f64 = 26.; +/// AppKit's titled-window shadow uses a 16px contour; a larger content mask +/// leaves a transparent crescent between the settings window and its shadow. +pub const SETTINGS_MATERIAL_RADIUS: f64 = 16.; // -- Sidebar/content metrics, all `:root` custom properties ----------------- @@ -487,6 +485,118 @@ fn recordings_empty_message(tab: RecordingsTab, trimmed_search: &str) -> String format!("{prefix} {tab_label}") } +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct UserProfile { + name: Option, + email: Option, + image_url: Option, +} + +fn signed_in_user_id() -> Option> { + store::auth_snapshot().signed_in().then(|| { + store::store_section("auth") + .get("user_id") + .and_then(Value::as_str) + .map(str::to_string) + }) +} + +fn cached_user_profile( + user_id: Option<&str>, + cached: &serde_json::Map, +) -> Option { + let cached_user_id = match cached.get("userId")? { + Value::Null => None, + Value::String(value) => Some(value.as_str()), + _ => return None, + }; + if cached_user_id != user_id { + return None; + } + serde_json::from_value(cached.get("profile")?.clone()).ok() +} + +fn account_name(signed_in: bool, profile: Option<&UserProfile>) -> String { + if !signed_in { + return "Click to sign in".to_string(); + } + profile + .and_then(|profile| { + [&profile.name, &profile.email] + .into_iter() + .filter_map(Option::as_deref) + .map(str::trim) + .find(|value| !value.is_empty()) + }) + .unwrap_or("Signed in") + .to_string() +} + +async fn fetch_user_profile() -> Result { + let response = + crate::auth::authed_request(reqwest::Method::GET, "/api/desktop/user/profile", None) + .await?; + if !response.status().is_success() { + return Err(crate::auth::AuthApiError::Other(format!( + "Profile fetch returned {}", + response.status() + ))); + } + response + .json::() + .await + .map_err(|error| crate::auth::AuthApiError::Other(error.to_string())) +} + +async fn fetch_profile_image() -> Result, crate::auth::AuthApiError> { + let response = crate::auth::authed_request( + reqwest::Method::GET, + "/api/desktop/user/profile/image", + None, + ) + .await?; + if !response.status().is_success() { + return Err(crate::auth::AuthApiError::Other(format!( + "Profile image fetch returned {}", + response.status() + ))); + } + if response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .is_some_and(|value| { + value.to_str().map_or(true, |value| { + !value.to_ascii_lowercase().starts_with("image/") + }) + }) + { + return Err(crate::auth::AuthApiError::Other( + "Invalid profile image response".to_string(), + )); + } + if response + .content_length() + .is_some_and(|length| length > MAX_PROFILE_IMAGE_BYTES as u64) + { + return Err(crate::auth::AuthApiError::Other( + "Profile image is too large".to_string(), + )); + } + let bytes = response + .bytes() + .await + .map_err(|error| crate::auth::AuthApiError::Other(error.to_string()))?; + if bytes.len() > MAX_PROFILE_IMAGE_BYTES { + return Err(crate::auth::AuthApiError::Other( + "Profile image is too large".to_string(), + )); + } + let image = image::load_from_memory(&bytes) + .map_err(|error| crate::auth::AuthApiError::Other(error.to_string()))?; + Ok(library::rgba_to_render_image(image.into_rgba8())) +} + pub struct SettingsWindow { pub(crate) theme: Theme, pub(crate) page: Page, @@ -527,6 +637,8 @@ pub struct SettingsWindow { sign_in_pending: bool, sign_in_cancel: std::sync::Arc, sign_in_task: Option>, + user_profile: Option, + profile_image: Option>, } impl SettingsWindow { @@ -534,6 +646,9 @@ impl SettingsWindow { crate::theme::bind_window(window, cx); let theme = Theme::for_window(window, cx, true); let settings = GeneralSettings::load(); + let user_profile = signed_in_user_id().and_then(|user_id| { + cached_user_profile(user_id.as_deref(), &store::store_section("user_profile")) + }); // The Tauri app's close button and Cmd-W both go through the window's // own close, and `CapWindowId::Settings`'s `Destroyed` arm calls @@ -615,6 +730,8 @@ impl SettingsWindow { sign_in_pending: false, sign_in_cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), sign_in_task: None, + user_profile, + profile_image: None, } } @@ -624,6 +741,7 @@ impl SettingsWindow { /// a task spawned inside `open_window`'s builder closure updates the model /// without ever scheduling a frame. pub fn start_enumeration(&mut self, window: &mut Window, cx: &mut Context) { + self.refresh_user_profile(window, cx); cx.spawn_in(window, async move |this, cx| { let windows = cx .background_executor() @@ -1364,12 +1482,9 @@ impl Render for SettingsWindow { .flex() .flex_row() .overflow_hidden() - // `.cap-settings-shell { background: transparent }` under the - // settings material; the panes paint. The radius is - // `--macos-settings-window-radius`, 26 under Liquid Glass, and - // the content view's layer is clipped to the same curve by - // `platform::install_window_material`. - .rounded(px(theme.settings_window_radius())) + .rounded(px(theme + .settings_window_radius() + .min(SETTINGS_MATERIAL_RADIUS as f32))) .font_family("Geist") // `body { font-weight: 500 }` (`ui-solid/src/main.css:189-192`). .font_weight(FontWeight::MEDIUM) @@ -1414,18 +1529,43 @@ impl SettingsWindow { window.start_window_move(); }), ) - .child(self.render_profile()) + .child(self.render_profile(cx)) .child(self.render_nav(cx)) .child(self.render_account(cx)) } - /// The account button. There is no auth here (same gap as the main - /// window's plan badge), so it renders the signed-out state and does - /// nothing when clicked -- see the README. - fn render_profile(&self) -> impl IntoElement { + fn render_profile(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; + let signed_in = store::auth_snapshot().signed_in(); + let avatar = match self.profile_image.clone() { + Some(image) if signed_in => { + use gpui::StyledImage as _; + img(image) + .size(px(32.)) + .flex_shrink_0() + .object_fit(gpui::ObjectFit::Cover) + .rounded_full() + .into_any_element() + } + _ => div() + .flex() + .items_center() + .justify_center() + .size(px(32.)) + .flex_shrink_0() + .rounded_full() + .bg(theme.settings_fill()) + .child( + svg() + .path("icons/user-round.svg") + .size(px(16.)) + .text_color(theme.settings_muted()), + ) + .into_any_element(), + }; div() + .id("settings-profile") .flex() .flex_row() .items_center() @@ -1439,25 +1579,10 @@ impl SettingsWindow { .px(px(4.)) .py(px(6.)) .rounded(px(8.)) - .child( - // `.cap-settings-profile-icon { width/height: 32px; color: - // var(--macos-settings-muted); background: - // var(--macos-settings-fill); border-radius: 999px }` - div() - .flex() - .items_center() - .justify_center() - .size(px(32.)) - .flex_shrink_0() - .rounded_full() - .bg(theme.settings_fill()) - .child( - svg() - .path("icons/user-round.svg") - .size(px(16.)) - .text_color(theme.settings_muted()), - ), - ) + .cursor_pointer() + .hover(move |style| style.bg(theme.settings_hover())) + .active(move |style| style.bg(theme.settings_selection())) + .child(avatar) .child( div() .flex() @@ -1468,13 +1593,11 @@ impl SettingsWindow { // `.cap-settings-profile-copy { gap: 2px }` .gap(px(2.)) .child( - // `text-[13px] leading-[15px] text-gray-12`, and - // `accountName()` with no auth. div() .truncate() .text_size(px(13.)) .line_height(px(15.)) - .child("Click to sign in"), + .child(account_name(signed_in, self.user_profile.as_ref())), ) .child( // `text-[11px] leading-[13px] text-gray-10` @@ -1486,6 +1609,13 @@ impl SettingsWindow { .child("Account"), ), ) + .on_click(cx.listener(|this, _, window, cx| { + if store::auth_snapshot().signed_in() { + cx.open_url(&format!("{}/dashboard", crate::auth::server_url())); + } else { + this.toggle_sign_in(window, cx); + } + })) } fn render_nav(&self, cx: &mut Context) -> impl IntoElement { @@ -1551,6 +1681,40 @@ impl SettingsWindow { fn render_account(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; + let update_links = div() + .flex() + .flex_col() + .items_start() + .gap(px(6.)) + .mb(px(8.)) + .text_size(px(12.)) + .text_color(theme.settings_muted()) + .child( + div() + .id("settings-version") + .px(px(4.)) + .py(px(2.)) + .rounded(px(4.)) + .child(format!("v{}", env!("CARGO_PKG_VERSION"))), + ) + .child( + div() + .id("settings-previous-versions") + .child("View previous versions") + .hover(|style| style.text_color(theme.settings_text())) + .on_click(|_, _, cx| cx.open_url("https://cap.so/download/versions")), + ) + .child( + div() + .id("settings-check-updates") + .cursor_pointer() + .hover(|style| style.text_color(theme.settings_text())) + .on_click(cx.listener(|_, _, _, cx| { + crate::updates::check_manually(cx); + })) + .child("Check for updates"), + ); + div() .flex() .flex_col() @@ -1562,50 +1726,15 @@ impl SettingsWindow { .pb(px(SIDEBAR_PADDING_X)) .border_t_1() .border_color(theme.settings_border()) - .child( - // `mb-2 text-xs text-gray-11 flex flex-col items-start gap-1.5` - div() - .flex() - .flex_col() - .items_start() - .gap(px(6.)) - .mb(px(8.)) - .text_size(px(12.)) - .text_color(theme.settings_muted()) - .child( - // The version button copies to the clipboard in the - // Tauri app; the string is this crate's version, not - // the shipping app's (there is no `getVersion()` - // here). - div() - .id("settings-version") - .px(px(4.)) - .py(px(2.)) - .rounded(px(4.)) - .child(format!("v{}", env!("CARGO_PKG_VERSION"))), - ) - .child( - div() - .id("settings-previous-versions") - .child("View previous versions") - .hover(|style| style.text_color(theme.settings_text())) - .on_click(|_, _, cx| cx.open_url("https://cap.so/download/versions")), - ) - .child( - // Inert: there is no updater in this app. Drawn in the - // disabled state the shipping button uses while a - // check is in flight (`disabled:opacity-50`). - div().opacity(0.5).child("Check for updates"), - ), - ) + .child(update_links) .child({ let signed_in = store::auth_snapshot().signed_in(); let (variant, label) = if self.sign_in_pending { (ui::ButtonVariant::Gray, "Cancel Sign In") } else if signed_in { - (ui::ButtonVariant::Dark, "Sign Out") + (ui::ButtonVariant::Gray, "Sign Out") } else { - (ui::ButtonVariant::Dark, "Sign In") + (ui::ButtonVariant::Primary, "Sign In") }; self.button( "settings-sign-in", @@ -1630,8 +1759,7 @@ impl SettingsWindow { return; } if store::auth_snapshot().signed_in() { - crate::auth::sign_out(); - cx.notify(); + self.clear_local_auth(window, cx); return; } @@ -1659,14 +1787,133 @@ impl SettingsWindow { tracing::warn!("updating auth plan after sign-in: {error}"); } crate::platform::activate_app(); - let _ = this.update(cx, |this, cx| { + let _ = this.update_in(cx, |this, window, cx| { this.sign_in_pending = false; + if store::auth_snapshot().signed_in() { + this.refresh_user_profile(window, cx); + } cx.notify(); + window.refresh(); }); })); cx.notify(); } + fn clear_local_auth(&mut self, window: &mut Window, cx: &mut Context) { + if !crate::auth::sign_out() { + tracing::error!("failed to clear the auth session"); + } + if !store::set_store_value("user_profile", Value::Null) { + tracing::error!("failed to clear the cached user profile"); + } + self.user_profile = None; + if let Some(image) = self.profile_image.take() { + let _ = window.drop_image(image); + } + cx.notify(); + window.refresh(); + } + + fn refresh_user_profile(&mut self, window: &mut Window, cx: &mut Context) { + let Some(user_id) = signed_in_user_id() else { + return; + }; + cx.spawn_in(window, async move |this, cx| { + let Ok(task) = cx.update(|_, cx| gpui_tokio::Tokio::spawn(cx, fetch_user_profile())) + else { + return; + }; + let Ok(result) = task.await else { + return; + }; + let profile = match result { + Ok(profile) => profile, + Err(crate::auth::AuthApiError::InvalidAuthentication) => { + let _ = this.update_in(cx, |this, window, cx| { + if signed_in_user_id().as_ref() == Some(&user_id) { + this.clear_local_auth(window, cx); + } + }); + return; + } + Err(error) => { + tracing::warn!("loading account profile: {error}"); + return; + } + }; + let image_url = profile + .image_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let Ok(true) = this.update_in(cx, |this, window, cx| { + if signed_in_user_id().as_ref() != Some(&user_id) { + return false; + } + if !store::set_store_value( + "user_profile", + serde_json::json!({ + "userId": &user_id, + "profile": &profile, + "updatedAt": chrono::Utc::now().timestamp_millis(), + }), + ) { + tracing::warn!("failed to cache the user profile"); + } + this.user_profile = Some(profile); + if let Some(image) = this.profile_image.take() { + let _ = window.drop_image(image); + } + cx.notify(); + window.refresh(); + true + }) else { + return; + }; + let Some(image_url) = image_url else { + return; + }; + let Ok(task) = cx.update(|_, cx| gpui_tokio::Tokio::spawn(cx, fetch_profile_image())) + else { + return; + }; + let Ok(result) = task.await else { + return; + }; + match result { + Ok(image) => { + let _ = this.update_in(cx, |this, window, cx| { + if signed_in_user_id().as_ref() != Some(&user_id) + || this + .user_profile + .as_ref() + .and_then(|profile| profile.image_url.as_deref()) + .map(str::trim) + != Some(image_url.as_str()) + { + return; + } + if let Some(old) = this.profile_image.replace(image) { + let _ = window.drop_image(old); + } + cx.notify(); + window.refresh(); + }); + } + Err(crate::auth::AuthApiError::InvalidAuthentication) => { + let _ = this.update_in(cx, |this, window, cx| { + if signed_in_user_id().as_ref() == Some(&user_id) { + this.clear_local_auth(window, cx); + } + }); + } + Err(error) => tracing::warn!("loading account profile image: {error}"), + } + }) + .detach(); + } + // -- Content pane ------------------------------------------------------ fn render_content(&self, window: &Window, cx: &mut Context) -> impl IntoElement { @@ -3526,6 +3773,10 @@ impl SettingsWindow { |this, value, cx| { this.settings.update_channel = value; this.write_enum("updateChannel", value, cx); + crate::updates::update_channel_changed( + this.settings.update_channel, + cx, + ); }, )), ) @@ -3865,21 +4116,21 @@ impl SettingsWindow { // `onKeyDown`: Escape clears the field, and *only* when there is // something in it -- `if (event.key === "Escape" && search())`. // With the field empty the key is not even preventDefault'd. - ui::TextInputEvent::Cancelled if field == Field::RecordingsSearch => { - if !self.recordings.search.is_empty() { - self.recordings.search.clear(); - self.recordings.visible_count = RECORDINGS_PAGE_SIZE; - input.update(cx, |input, cx| input.set_text("", cx)); - cx.notify(); - } + ui::TextInputEvent::Cancelled + if field == Field::RecordingsSearch && !self.recordings.search.is_empty() => + { + self.recordings.search.clear(); + self.recordings.visible_count = RECORDINGS_PAGE_SIZE; + input.update(cx, |input, cx| input.set_text("", cx)); + cx.notify(); } - ui::TextInputEvent::Cancelled if field == Field::ScreenshotsSearch => { - if !self.screenshots.search.is_empty() { - self.screenshots.search.clear(); - self.screenshots.visible_count = RECORDINGS_PAGE_SIZE; - input.update(cx, |input, cx| input.set_text("", cx)); - cx.notify(); - } + ui::TextInputEvent::Cancelled + if field == Field::ScreenshotsSearch && !self.screenshots.search.is_empty() => + { + self.screenshots.search.clear(); + self.screenshots.visible_count = RECORDINGS_PAGE_SIZE; + input.update(cx, |input, cx| input.set_text("", cx)); + cx.notify(); } ui::TextInputEvent::Cancelled => { // Revert to what is stored, the way leaving the field without @@ -4072,6 +4323,66 @@ mod tests { assert_eq!(origin_of(""), None); } + #[test] + fn account_name_matches_the_tauri_profile_fallbacks() { + let mut profile = UserProfile { + name: Some(" Taylor Example ".to_string()), + email: Some("taylor@example.com".to_string()), + image_url: None, + }; + + assert_eq!(account_name(false, Some(&profile)), "Click to sign in"); + assert_eq!(account_name(true, None), "Signed in"); + assert_eq!(account_name(true, Some(&profile)), "Taylor Example"); + + profile.name = Some(" ".to_string()); + assert_eq!(account_name(true, Some(&profile)), "taylor@example.com"); + + profile.email = None; + assert_eq!(account_name(true, Some(&profile)), "Signed in"); + } + + #[test] + fn cached_profile_must_belong_to_the_signed_in_user() { + let cached = serde_json::json!({ + "userId": "user-1", + "profile": { + "name": "Taylor", + "email": "taylor@example.com", + "imageUrl": "https://example.com/profile.png" + }, + "updatedAt": 1 + }); + let cached = cached.as_object().expect("cached profile object"); + + assert_eq!( + cached_user_profile(Some("user-1"), cached).and_then(|profile| profile.name), + Some("Taylor".to_string()) + ); + assert!(cached_user_profile(Some("user-2"), cached).is_none()); + assert!(cached_user_profile(None, cached).is_none()); + } + + #[test] + fn cached_profile_accepts_the_tauri_null_user_identity() { + let cached = serde_json::json!({ + "userId": null, + "profile": { + "name": null, + "email": "taylor@example.com", + "imageUrl": null + }, + "updatedAt": 1 + }); + let cached = cached.as_object().expect("cached profile object"); + + assert_eq!( + cached_user_profile(None, cached).and_then(|profile| profile.email), + Some("taylor@example.com".to_string()) + ); + assert!(cached_user_profile(Some("user-1"), cached).is_none()); + } + /// `coversDefaultExclusion`: the Reset button's "is this default already /// covered" test, which decides whether the amber warning shows. #[test] diff --git a/apps/desktop-gpui/src/single_instance.rs b/apps/desktop-gpui/src/single_instance.rs index d76018c5489..5b8f47376d2 100644 --- a/apps/desktop-gpui/src/single_instance.rs +++ b/apps/desktop-gpui/src/single_instance.rs @@ -22,7 +22,21 @@ //! second instance on macOS at all -- the GURL AppleEvent goes straight to //! the running process (`crate::platform::install_url_scheme_handler`). -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +#[cfg(windows)] +static INSTANCE_MUTEX: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[cfg(any(windows, target_os = "macos", test))] +const MAX_FORWARDED_DEEP_LINK_BYTES: usize = 1024 * 1024; + +#[cfg(any(windows, target_os = "macos", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ForwardingEndpoint { + pid: u32, + port: u16, + secret: u64, +} fn pidfile() -> PathBuf { crate::store::app_data_dir().join("cap-gpui.pid") @@ -30,6 +44,8 @@ fn pidfile() -> PathBuf { pub fn acquire() { let path = pidfile(); + #[cfg(windows)] + acquire_windows_instance(&path); #[cfg(unix)] if let Ok(raw) = std::fs::read_to_string(&path) && let Ok(pid) = raw.trim().parse::() @@ -60,6 +76,12 @@ pub fn acquire() { if let Err(error) = std::fs::write(&path, std::process::id().to_string()) { tracing::warn!(%error, "could not write the instance pidfile"); } + + #[cfg(target_os = "macos")] + if let Err(error) = start_deep_link_forwarding(&path) { + tracing::error!(%error, "could not start Cap GPUI deep-link forwarding"); + std::process::exit(1); + } } /// Guard against pid reuse: only ever signal a process that is actually this @@ -73,5 +95,491 @@ fn is_cap_gpui(pid: i32) -> bool { std::process::Command::new("ps") .args(["-p", &pid.to_string(), "-o", "comm="]) .output() - .is_ok_and(|output| String::from_utf8_lossy(&output.stdout).contains("cap-gpui")) + .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 { + #[cfg(windows)] + const IMAGE_NAME: &str = "cap-gpui.exe"; + #[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)) +} + +#[cfg(windows)] +fn acquire_windows_instance(path: &Path) { + use std::hash::{Hash, Hasher}; + use windows_sys::Win32::{ + Foundation::{CloseHandle, ERROR_ALREADY_EXISTS, GetLastError}, + System::Threading::CreateMutexW, + }; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + path.hash(&mut hasher); + let name = format!("Local\\CapGpui-{:016x}", hasher.finish()) + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let mutex = unsafe { CreateMutexW(std::ptr::null(), 0, name.as_ptr()) }; + let last_error = unsafe { GetLastError() }; + + if mutex.is_null() { + tracing::error!( + error = last_error, + "could not acquire the Cap GPUI instance mutex" + ); + std::process::exit(1); + } + + if last_error == ERROR_ALREADY_EXISTS { + for _ in 0..40 { + if let Some(pid) = windows_instance_pid(path) { + tracing::info!(pid, "Cap GPUI is already running; bringing it forward"); + forward_windows_deep_links(path, pid); + activate_windows_instance(pid); + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + + let _ = unsafe { CloseHandle(mutex) }; + std::process::exit(0); + } + + if INSTANCE_MUTEX.set(mutex as usize).is_err() { + let _ = unsafe { CloseHandle(mutex) }; + tracing::error!("the Cap GPUI instance mutex was initialized twice"); + std::process::exit(1); + } + + if let Err(error) = start_deep_link_forwarding(path) { + tracing::error!(%error, "could not start Cap GPUI deep-link forwarding"); + std::process::exit(1); + } +} + +#[cfg(any(windows, target_os = "macos"))] +fn start_deep_link_forwarding(path: &Path) -> std::io::Result<()> { + use std::hash::BuildHasher; + + let listener = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))?; + let endpoint = ForwardingEndpoint { + pid: std::process::id(), + port: listener.local_addr()?.port(), + secret: std::collections::hash_map::RandomState::new() + .hash_one((std::process::id(), listener.local_addr()?.port())), + }; + let endpoint_path = path.with_extension("ipc"); + let temporary_path = + path.with_extension(format!("ipc.{}.{:016x}.tmp", endpoint.pid, endpoint.secret)); + + if let Some(parent) = endpoint_path.parent() { + std::fs::create_dir_all(parent)?; + } + + write_forwarding_endpoint(&temporary_path, endpoint)?; + if let Err(error) = publish_forwarding_endpoint(&temporary_path, &endpoint_path) { + let _ = std::fs::remove_file(&temporary_path); + return Err(error); + } + + std::thread::Builder::new() + .name("cap-gpui-deep-link".into()) + .spawn(move || { + for connection in listener.incoming() { + match connection { + Ok(mut stream) => { + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(2))); + match read_forwarded_deep_link(&mut stream, endpoint.secret) { + Ok(url) => crate::deeplink::submit_deep_link(&url), + Err(error) => { + tracing::warn!(%error, "rejected a forwarded Cap deep link"); + } + } + } + Err(error) => { + tracing::warn!(%error, "could not accept a forwarded Cap deep link"); + } + } + } + })?; + + Ok(()) +} + +#[cfg(any(windows, target_os = "macos", test))] +fn write_forwarding_endpoint(path: &Path, endpoint: ForwardingEndpoint) -> std::io::Result<()> { + use std::io::Write; + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + let mut file = options.open(path)?; + write!( + file, + "{}:{}:{:016x}", + endpoint.pid, endpoint.port, endpoint.secret + ) +} + +#[cfg(all(unix, target_os = "macos"))] +fn publish_forwarding_endpoint(temporary_path: &Path, endpoint_path: &Path) -> std::io::Result<()> { + std::fs::rename(temporary_path, endpoint_path) +} + +#[cfg(windows)] +fn publish_forwarding_endpoint(temporary_path: &Path, endpoint_path: &Path) -> std::io::Result<()> { + match std::fs::remove_file(endpoint_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + std::fs::rename(temporary_path, endpoint_path) +} + +#[cfg(windows)] +fn forward_windows_deep_links(path: &Path, pid: u32) { + use std::io::Write; + + let urls = std::env::args() + .skip(1) + .filter(|argument| is_forwardable_deep_link(argument)) + .collect::>(); + if urls.is_empty() { + return; + } + + let endpoint_path = path.with_extension("ipc"); + let endpoint = (0..40).find_map(|attempt| { + let endpoint = std::fs::read_to_string(&endpoint_path) + .ok() + .and_then(|contents| parse_forwarding_endpoint(&contents)) + .filter(|endpoint| endpoint.pid == pid); + if endpoint.is_none() && attempt < 39 { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + endpoint + }); + let Some(endpoint) = endpoint else { + tracing::warn!("could not find the running Cap GPUI deep-link endpoint"); + return; + }; + + for url in urls { + let result = std::net::TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, endpoint.port)) + .and_then(|mut stream| { + stream.set_write_timeout(Some(std::time::Duration::from_secs(2)))?; + stream.write_all(&endpoint.secret.to_be_bytes())?; + stream.write_all(&(url.len() as u32).to_be_bytes())?; + stream.write_all(url.as_bytes()) + }); + if let Err(error) = result { + tracing::warn!(%error, "could not forward a Cap deep link to the running instance"); + } + } +} + +#[cfg(any(windows, test))] +fn parse_forwarding_endpoint(contents: &str) -> Option { + let mut parts = contents.trim().split(':'); + let pid = parts.next()?.parse().ok()?; + let port = parts.next()?.parse().ok()?; + let secret = u64::from_str_radix(parts.next()?, 16).ok()?; + (pid != 0 && port != 0 && parts.next().is_none()).then_some(ForwardingEndpoint { + pid, + port, + secret, + }) +} + +#[cfg(any(windows, target_os = "macos", test))] +fn is_forwardable_deep_link(url: &str) -> bool { + !url.is_empty() + && url.len() <= MAX_FORWARDED_DEEP_LINK_BYTES + && reqwest::Url::parse(url) + .is_ok_and(|parsed| matches!(parsed.scheme(), "cap-desktop" | "cap")) +} + +#[cfg(any(windows, target_os = "macos", test))] +fn read_forwarded_deep_link( + reader: &mut impl std::io::Read, + expected_secret: u64, +) -> std::io::Result { + let mut secret = [0_u8; 8]; + reader.read_exact(&mut secret)?; + if u64::from_be_bytes(secret) != expected_secret { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "incorrect forwarding secret", + )); + } + + let mut size = [0_u8; 4]; + reader.read_exact(&mut size)?; + let size = u32::from_be_bytes(size) as usize; + if size == 0 || size > MAX_FORWARDED_DEEP_LINK_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid forwarded deep-link size", + )); + } + + let mut bytes = vec![0_u8; size]; + reader.read_exact(&mut bytes)?; + let url = String::from_utf8(bytes).map_err(|error| { + std::io::Error::new(std::io::ErrorKind::InvalidData, error.utf8_error()) + })?; + if !is_forwardable_deep_link(&url) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid forwarded deep-link scheme", + )); + } + + Ok(url) +} + +#[cfg(windows)] +fn windows_instance_pid(path: &Path) -> Option { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::{ + Foundation::CloseHandle, + System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, + }, + }; + + let pid = std::fs::read_to_string(path).ok()?.trim().parse().ok()?; + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if process.is_null() { + return None; + } + + let mut image = [0_u16; 1024]; + let mut len = image.len() as u32; + let found = + unsafe { QueryFullProcessImageNameW(process, 0, image.as_mut_ptr(), &mut len) } != 0; + let _ = unsafe { CloseHandle(process) }; + if !found { + return None; + } + + let image = PathBuf::from(std::ffi::OsString::from_wide(&image[..len as usize])); + is_cap_gpui_image(&image).then_some(pid) +} + +#[cfg(windows)] +fn activate_windows_instance(pid: u32) { + use windows_sys::Win32::{ + Foundation::{BOOL, HWND, LPARAM, TRUE}, + UI::WindowsAndMessaging::{ + EnumWindows, GetWindowThreadProcessId, IsWindowVisible, SW_RESTORE, + SetForegroundWindow, ShowWindow, + }, + }; + + struct Activation { + pid: u32, + visible: Option, + fallback: Option, + } + + unsafe extern "system" fn find_window(window: HWND, data: LPARAM) -> BOOL { + let activation = unsafe { &mut *(data as *mut Activation) }; + let mut owner = 0; + unsafe { GetWindowThreadProcessId(window, &mut owner) }; + if owner == activation.pid { + if activation.fallback.is_none() { + activation.fallback = Some(window); + } + if activation.visible.is_none() && unsafe { IsWindowVisible(window) } != 0 { + activation.visible = Some(window); + } + } + TRUE + } + + let mut activation = Activation { + pid, + visible: None, + fallback: None, + }; + unsafe { + EnumWindows( + Some(find_window), + std::ptr::addr_of_mut!(activation) as isize, + ); + } + + if let Some(window) = activation.visible.or(activation.fallback) { + unsafe { + ShowWindow(window, SW_RESTORE); + SetForegroundWindow(window); + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + ForwardingEndpoint, MAX_FORWARDED_DEEP_LINK_BYTES, is_cap_gpui_image, + is_forwardable_deep_link, parse_forwarding_endpoint, read_forwarded_deep_link, + }; + use std::path::Path; + + #[test] + fn gpui_process_image_must_match_exactly() { + #[cfg(windows)] + let name = "cap-gpui.exe"; + #[cfg(not(windows))] + let name = "cap-gpui"; + + assert!(is_cap_gpui_image(Path::new(name))); + assert!(is_cap_gpui_image(Path::new(&name.to_ascii_uppercase()))); + assert!(!is_cap_gpui_image(Path::new("not-cap-gpui"))); + assert!(!is_cap_gpui_image(Path::new("cap-gpui-helper"))); + } + + #[test] + fn forwarding_endpoint_requires_a_live_instance_shape() { + assert_eq!( + parse_forwarding_endpoint("4321:49152:0123456789abcdef"), + Some(ForwardingEndpoint { + pid: 4321, + port: 49152, + secret: 0x0123_4567_89ab_cdef, + }) + ); + assert_eq!(parse_forwarding_endpoint("0:49152:0123"), None); + assert_eq!(parse_forwarding_endpoint("4321:0:0123"), None); + assert_eq!(parse_forwarding_endpoint("4321:49152:no-secret"), None); + assert_eq!(parse_forwarding_endpoint("4321:49152:0123:extra"), None); + } + + #[test] + fn forwarded_deep_links_only_accept_cap_schemes() { + assert!(is_forwardable_deep_link("cap-desktop://auth?token=test")); + assert!(is_forwardable_deep_link("cap://action?value=test")); + assert!(!is_forwardable_deep_link("https://cap.so")); + assert!(!is_forwardable_deep_link("cap-desktop-evil://auth")); + assert!(!is_forwardable_deep_link(&format!( + "cap://action?value={}", + "x".repeat(MAX_FORWARDED_DEEP_LINK_BYTES) + ))); + } + + #[test] + fn forwarded_project_actions_use_the_existing_deep_link_protocol() { + let action = serde_json::json!({ + "open_editor": { + "project_path": "/tmp/Recording With Spaces.cap" + } + }); + let url = reqwest::Url::parse_with_params( + "cap-desktop://action", + &[("value", action.to_string())], + ) + .unwrap(); + + assert!(is_forwardable_deep_link(url.as_str())); + let secret = 0x0123_4567_89ab_cdef_u64; + let mut payload = secret.to_be_bytes().to_vec(); + payload.extend_from_slice(&(url.as_str().len() as u32).to_be_bytes()); + payload.extend_from_slice(url.as_str().as_bytes()); + let authenticated = read_forwarded_deep_link(&mut payload.as_slice(), secret).unwrap(); + let authenticated = reqwest::Url::parse(&authenticated).unwrap(); + assert!(matches!( + crate::deeplink::DeepLinkAction::try_from(&authenticated), + Ok(crate::deeplink::DeepLinkAction::OpenEditor { project_path }) + if project_path == Path::new("/tmp/Recording With Spaces.cap") + )); + } + + #[cfg(unix)] + #[test] + fn forwarding_endpoint_is_private_and_never_follows_symlinks() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let root = std::env::temp_dir().join(format!( + "cap-gpui-forwarding-endpoint-{}", + std::process::id() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("endpoint.tmp"); + let endpoint = ForwardingEndpoint { + pid: 4321, + port: 49152, + secret: 0x0123_4567_89ab_cdef, + }; + + super::write_forwarding_endpoint(&path, endpoint).unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + parse_forwarding_endpoint(&std::fs::read_to_string(&path).unwrap()), + Some(endpoint) + ); + assert_eq!( + super::write_forwarding_endpoint(&path, endpoint) + .unwrap_err() + .kind(), + std::io::ErrorKind::AlreadyExists + ); + + let protected = root.join("protected"); + std::fs::write(&protected, "unchanged").unwrap(); + let link = root.join("endpoint-link.tmp"); + symlink(&protected, &link).unwrap(); + assert_eq!( + super::write_forwarding_endpoint(&link, endpoint) + .unwrap_err() + .kind(), + std::io::ErrorKind::AlreadyExists + ); + assert_eq!(std::fs::read_to_string(&protected).unwrap(), "unchanged"); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn forwarded_deep_links_require_the_owner_secret_and_bounded_payload() { + let secret = 0x0123_4567_89ab_cdef_u64; + let url = "cap-desktop://auth?token=test"; + let mut payload = secret.to_be_bytes().to_vec(); + payload.extend_from_slice(&(url.len() as u32).to_be_bytes()); + payload.extend_from_slice(url.as_bytes()); + + assert_eq!( + read_forwarded_deep_link(&mut payload.as_slice(), secret).unwrap(), + url + ); + assert_eq!( + read_forwarded_deep_link(&mut payload.as_slice(), secret + 1) + .unwrap_err() + .kind(), + std::io::ErrorKind::PermissionDenied + ); + + let mut oversized = secret.to_be_bytes().to_vec(); + oversized.extend_from_slice(&((MAX_FORWARDED_DEEP_LINK_BYTES + 1) as u32).to_be_bytes()); + assert_eq!( + read_forwarded_deep_link(&mut oversized.as_slice(), secret) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidData + ); + } } diff --git a/apps/desktop-gpui/src/store.rs b/apps/desktop-gpui/src/store.rs index b99f206f965..2a362c7db40 100644 --- a/apps/desktop-gpui/src/store.rs +++ b/apps/desktop-gpui/src/store.rs @@ -14,7 +14,11 @@ //! read-modify-write on the raw JSON that touches exactly one key: see //! [`set_store_setting`]. -use std::path::PathBuf; +use std::{ + io::Write, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -154,6 +158,39 @@ pub fn app_data_dir() -> PathBuf { } } +pub(crate) fn bundled_resource_dirs() -> Vec { + let executable = std::env::current_exe().ok(); + let override_dir = std::env::var_os("CAP_GPUI_RESOURCES_DIR").map(PathBuf::from); + bundled_resource_dirs_for(executable.as_deref(), override_dir.as_deref()) +} + +fn bundled_resource_dirs_for( + executable: Option<&Path>, + override_dir: Option<&Path>, +) -> Vec { + let mut directories = Vec::new(); + if let Some(directory) = override_dir { + directories.push(directory.to_path_buf()); + } + + if let Some(parent) = executable.and_then(Path::parent) { + if parent.file_name().is_some_and(|name| name == "MacOS") + && let Some(contents) = parent.parent() + { + directories.push(contents.join("Resources")); + } + if parent.file_name().is_some_and(|name| name == "bin") + && let Some(prefix) = parent.parent() + { + directories.push(prefix.join("lib").join("cap")); + } + directories.push(parent.join("resources")); + directories.push(parent.to_path_buf()); + } + + directories +} + /// The Tauri app's hand-off marker (`gpui_app.rs`). /// /// It writes this file immediately before spawning this app and never deletes @@ -177,6 +214,99 @@ pub fn clear_handoff_marker() { } } +/// One-shot request for the Tauri host to own the next startup and open its +/// updater. The GPUI preference stays enabled, so the relaunch after a signed +/// bundle update routes back to the new GPUI binary. +pub fn update_handoff_path() -> PathBuf { + app_data_dir().join("cap-gpui.update-handoff") +} + +pub fn request_update_handoff() -> std::io::Result<()> { + write_update_handoff(&std::process::id().to_string()) +} + +#[cfg(debug_assertions)] +pub fn request_simulated_update_handoff() -> std::io::Result<()> { + write_update_handoff(&format!("simulate:{}", std::process::id())) +} + +fn write_update_handoff(contents: &str) -> std::io::Result<()> { + write_update_handoff_at(&update_handoff_path(), contents) +} + +fn write_update_handoff_at(path: &Path, contents: &str) -> std::io::Result<()> { + static WRITES: std::sync::Mutex<()> = std::sync::Mutex::new(()); + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + + let _guard = WRITES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let (temporary_path, mut file) = loop { + let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed); + let mut temporary_path = path.as_os_str().to_os_string(); + temporary_path.push(format!(".tmp.{}.{sequence}", std::process::id())); + let temporary_path = PathBuf::from(temporary_path); + + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary_path) + { + Ok(file) => break (temporary_path, file), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + }; + + let written = file.write_all(contents.as_bytes()); + drop(file); + let published = written.and_then(|()| publish_update_handoff(&temporary_path, path)); + if published.is_err() { + let _ = std::fs::remove_file(&temporary_path); + } + published +} + +#[cfg(not(windows))] +fn publish_update_handoff(temporary_path: &Path, path: &Path) -> std::io::Result<()> { + std::fs::rename(temporary_path, path) +} + +#[cfg(windows)] +fn publish_update_handoff(temporary_path: &Path, path: &Path) -> std::io::Result<()> { + match std::fs::rename(temporary_path, path) { + Ok(()) => Ok(()), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied + ) => + { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + std::fs::rename(temporary_path, path) + } + Err(error) => Err(error), + } +} + +pub fn clear_update_handoff() { + let path = update_handoff_path(); + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => tracing::warn!("clearing the update hand-off: {error}"), + } +} + /// A dev-checkout switch-back has no `Cap.app` to `open`: the classic app only /// runs inside its `tauri dev` harness, which exited with the hand-off. This /// sentinel asks whatever supervises the dev session (`scripts/dev-desktop.mjs` @@ -248,12 +378,24 @@ pub fn update(mutate: impl FnOnce(&mut PersistedState)) { // The Tauri settings store // --------------------------------------------------------------------------- +#[cfg(test)] +thread_local! { + static TEST_TAURI_STORE_PATH: std::cell::RefCell> = const { + std::cell::RefCell::new(None) + }; +} + /// The tauri-plugin-store file, shared with the shipping app. /// /// `CAP_GPUI_TAURI_STORE` redirects it at a copy, which is how the tests -- /// and any verification run that must not touch the user's real settings -- /// work. pub fn tauri_store_path() -> PathBuf { + #[cfg(test)] + if let Some(path) = TEST_TAURI_STORE_PATH.with(|path| path.borrow().clone()) { + return path; + } + match std::env::var("CAP_GPUI_TAURI_STORE") { Ok(path) if !path.is_empty() => PathBuf::from(path), // `Store.load("store")`: no extension, and the sibling `store.json` @@ -1527,24 +1669,12 @@ pub fn preset_names() -> Vec { mod tests { use super::*; - /// `CAP_GPUI_TAURI_STORE` is process-global and `cargo test` runs these in - /// parallel threads, so the redirect is held under a lock -- without it - /// one test's store path is read by another's `load()`. - static STORE_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); - struct TempStore { path: PathBuf, - /// Held for the store's lifetime so no parallel test re-points the - /// env var mid-flight; never read, only dropped. - _guard: std::sync::MutexGuard<'static, ()>, } impl TempStore { fn new(name: &str, contents: Option<&str>) -> Self { - let guard = STORE_ENV.lock().unwrap_or_else(|error| error.into_inner()); - // Keyed by pid too: the mutex serializes threads, but two test - // *processes* (e.g. `cargo test` twice in parallel) sharing one - // path race each other's writes and drops. let path = std::env::temp_dir() .join(format!("cap-gpui-store-test-{}-{name}", std::process::id())); match contents { @@ -1553,13 +1683,11 @@ mod tests { let _ = std::fs::remove_file(&path); } } - // SAFETY: the guard above is the only writer of this var, and no - // other thread in the test binary reads the environment. - unsafe { std::env::set_var("CAP_GPUI_TAURI_STORE", &path) }; - Self { - path, - _guard: guard, - } + TEST_TAURI_STORE_PATH.with(|current| { + assert!(current.borrow().is_none()); + *current.borrow_mut() = Some(path.clone()); + }); + Self { path } } fn read(&self) -> Value { @@ -1570,8 +1698,37 @@ mod tests { impl Drop for TempStore { fn drop(&mut self) { let _ = std::fs::remove_file(&self.path); - unsafe { std::env::remove_var("CAP_GPUI_TAURI_STORE") }; + TEST_TAURI_STORE_PATH.with(|current| *current.borrow_mut() = None); + } + } + + #[test] + fn temporary_stores_are_thread_local_without_mutating_process_environment() { + let original = std::env::var_os("CAP_GPUI_TAURI_STORE"); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(4)); + let workers = (0..4) + .map(|index| { + let barrier = barrier.clone(); + let original = original.clone(); + std::thread::spawn(move || { + let store = TempStore::new(&format!("thread-local-{index}"), None); + assert_eq!(super::tauri_store_path(), store.path); + assert!(super::set_store_setting( + GENERAL_SETTINGS, + "maxFps", + Value::from(24 + index) + )); + barrier.wait(); + assert_eq!(GeneralSettings::load().max_fps, 24 + index); + assert_eq!(std::env::var_os("CAP_GPUI_TAURI_STORE"), original); + }) + }) + .collect::>(); + + for worker in workers { + worker.join().unwrap(); } + assert_eq!(std::env::var_os("CAP_GPUI_TAURI_STORE"), original); } /// The compatibility contract: writing one setting must leave every key @@ -1786,6 +1943,98 @@ mod tests { super::handoff_marker_path(), super::app_data_dir().join("cap-gpui.handoff") ); + assert_eq!( + super::update_handoff_path(), + super::app_data_dir().join("cap-gpui.update-handoff") + ); + } + + #[test] + fn bundled_resource_paths_follow_the_installed_executable() { + let executable = std::path::Path::new("/Applications/Cap.app/Contents/MacOS/cap-gpui"); + let override_dir = std::path::Path::new("/tmp/cap-resources"); + + assert_eq!( + super::bundled_resource_dirs_for(Some(executable), Some(override_dir)), + vec![ + override_dir.to_path_buf(), + std::path::PathBuf::from("/Applications/Cap.app/Contents/Resources"), + std::path::PathBuf::from("/Applications/Cap.app/Contents/MacOS/resources"), + std::path::PathBuf::from("/Applications/Cap.app/Contents/MacOS"), + ] + ); + + let executable = std::path::Path::new("/opt/cap/cap-gpui"); + assert_eq!( + super::bundled_resource_dirs_for(Some(executable), None), + vec![ + std::path::PathBuf::from("/opt/cap/resources"), + std::path::PathBuf::from("/opt/cap"), + ] + ); + + let executable = std::path::Path::new("/usr/bin/cap-gpui"); + assert_eq!( + super::bundled_resource_dirs_for(Some(executable), None), + vec![ + std::path::PathBuf::from("/usr/lib/cap"), + std::path::PathBuf::from("/usr/bin/resources"), + std::path::PathBuf::from("/usr/bin"), + ] + ); + } + + #[test] + fn update_handoff_publication_replaces_complete_markers() { + let directory = std::env::temp_dir().join(format!( + "cap-gpui-update-handoff-replace-{}", + std::process::id() + )); + let marker = directory.join("cap-gpui.update-handoff"); + + super::write_update_handoff_at(&marker, "1234").unwrap(); + assert_eq!(std::fs::read_to_string(&marker).unwrap(), "1234"); + + super::write_update_handoff_at(&marker, "simulate:5678").unwrap(); + assert_eq!(std::fs::read_to_string(&marker).unwrap(), "simulate:5678"); + + let entries = std::fs::read_dir(&directory) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + assert_eq!(entries, vec![marker.clone()]); + + std::fs::remove_file(marker).unwrap(); + std::fs::remove_dir(directory).unwrap(); + } + + #[test] + fn concurrent_update_handoff_writers_publish_complete_markers() { + let directory = std::env::temp_dir().join(format!( + "cap-gpui-update-handoff-concurrent-{}", + std::process::id() + )); + let marker = directory.join("cap-gpui.update-handoff"); + + let writers = (0..8) + .map(|index| { + let marker = marker.clone(); + std::thread::spawn(move || { + super::write_update_handoff_at(&marker, &(1000 + index).to_string()) + }) + }) + .collect::>(); + for writer in writers { + writer.join().unwrap().unwrap(); + } + + let contents = std::fs::read_to_string(&marker).unwrap(); + let pid = contents.parse::().unwrap(); + assert!((1000..1008).contains(&pid)); + assert_eq!(std::fs::read_dir(&directory).unwrap().count(), 1); + + std::fs::remove_file(marker).unwrap(); + std::fs::remove_dir(directory).unwrap(); } /// `normalizeTranscriptionHints`: NULs stripped, whitespace trimmed, @@ -1805,9 +2054,7 @@ mod tests { } /// An absent `transcriptionHints` key shows the four defaults - /// (`createDefaultGeneralSettings`), an empty array shows none. Scoped - /// blocks: each `TempStore` holds the global env lock, so the first must - /// drop before the second is created. + /// (`createDefaultGeneralSettings`), an empty array shows none. #[test] fn transcription_hints_default_only_when_absent() { { diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index a69bd11e38f..131c15017e8 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -63,6 +63,10 @@ fn area_min_size(mode: Mode) -> f32 { } } +fn required_window_title_matches(required: Option<&str>, actual: Option<&str>) -> bool { + required.is_none_or(|required| actual == Some(required)) +} + /// How close to an edge or corner counts as grabbing that handle. The TSX's /// corner buttons are 30px boxes hung 12px outside the crop and its edge /// buttons are 10px strips straddling the border; this is the same reach @@ -547,6 +551,26 @@ impl OverlayWindow { let Some(target) = self.target(cx) else { return; }; + if let ScreenCaptureTarget::Window { id } = &target { + let window = scap_targets::Window::from_id(id); + let expected_title = crate::main_window::auto_window_title(); + let actual_title = window.as_ref().and_then(scap_targets::Window::name); + if window.is_none() + || !required_window_title_matches( + expected_title.as_deref(), + actual_title.as_deref(), + ) + { + tracing::warn!( + window = %id, + expected = ?expected_title, + actual = ?actual_title, + "refusing unavailable or unexpected recording window" + ); + cx.defer(app_windows::reject_unavailable_window); + return; + } + } tracing::info!(target = ?target.kind_str(), "overlay start pressed"); if self.select.read(cx).recording_mode == Mode::Screenshot { // Screenshots never reach the recording actors: the target goes @@ -1591,6 +1615,28 @@ mod tests { assert_eq!(MODE_MENU[2].1, Mode::Screenshot); } + #[test] + fn required_window_titles_match_exactly_or_fail_closed() { + assert!(required_window_title_matches(None, None)); + assert!(required_window_title_matches(None, Some("Other window"))); + assert!(required_window_title_matches( + Some("Synthetic target"), + Some("Synthetic target") + )); + assert!(!required_window_title_matches( + Some("Synthetic target"), + Some("Synthetic target - private window") + )); + assert!(!required_window_title_matches( + Some("Synthetic target"), + Some("Private window") + )); + assert!(!required_window_title_matches( + Some("Synthetic target"), + None + )); + } + fn rect(x: f32, y: f32, width: f32, height: f32) -> AreaRect { AreaRect { x, diff --git a/apps/desktop-gpui/src/target_thumbnails.rs b/apps/desktop-gpui/src/target_thumbnails.rs index 8afc2d04f28..dc6a3664930 100644 --- a/apps/desktop-gpui/src/target_thumbnails.rs +++ b/apps/desktop-gpui/src/target_thumbnails.rs @@ -441,6 +441,7 @@ pub fn display_signature(list: &[DisplayOption]) -> String { /// (the card's slot background shows through, as in the Tauri picker). /// Requesting this size FROM SCK instead is not an option: for some sizes /// `capture_sample_buf` never resolves -- no error, the future hangs. +#[cfg(any(target_os = "macos", test))] pub fn fitted_capture_size(source: Option<(f64, f64)>) -> (usize, usize) { let Some((width, height)) = source else { return (THUMBNAIL_WIDTH as usize, THUMBNAIL_HEIGHT as usize); @@ -938,13 +939,14 @@ mod platform { } } -/// Windows and Linux have their own `thumbnails/{windows,linux}.rs` in the -/// Tauri app; neither is ported yet, and the picker falls back to the icon -/// card, which is exactly what it did before this unit. #[cfg(not(target_os = "macos"))] mod platform { + use cap_recording::screenshot::capture_screenshot; + use cap_recording::sources::screen_capture::ScreenCaptureTarget; use image::RgbaImage; + use super::normalize_thumbnail_dimensions; + #[derive(Clone)] pub struct ShareableContent; @@ -955,21 +957,42 @@ mod platform { } pub async fn shareable_content() -> Option { - None + Some(ShareableContent) } pub async fn capture_display_thumbnail( - _display: &scap_targets::Display, + display: &scap_targets::Display, _content: ShareableContent, ) -> Option { - None + capture_target_thumbnail(ScreenCaptureTarget::Display { id: display.id() }).await } pub async fn capture_window_thumbnail( - _window: &scap_targets::Window, + window: &scap_targets::Window, _content: ShareableContent, ) -> Option { - None + capture_target_thumbnail(ScreenCaptureTarget::Window { id: window.id() }).await + } + + async fn capture_target_thumbnail(target: ScreenCaptureTarget) -> Option { + #[cfg(target_os = "linux")] + if cap_recording::screenshot::is_pure_wayland_session() { + return None; + } + + let image = match capture_screenshot(target).await { + Ok(image) => image.into_rgba8(), + Err(error) => { + tracing::warn!(%error, "target thumbnail capture failed"); + return None; + } + }; + + if image.width() == 0 || image.height() == 0 { + return None; + } + + Some(normalize_thumbnail_dimensions(&image)) } } @@ -984,6 +1007,7 @@ use platform::{capture_display_thumbnail, capture_window_thumbnail, shareable_co /// The four 32-bit orders `capture_thumbnail_from_filter` accepts /// (`thumbnails/mac.rs:73-78`). #[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[cfg(any(target_os = "macos", test))] pub enum ChannelOrder { Bgra, Rgba, @@ -996,6 +1020,7 @@ pub enum ChannelOrder { /// # Safety /// `base_ptr` must be the base address of a locked pixel buffer holding at /// least `bytes_per_row * height` readable bytes. +#[cfg(target_os = "macos")] unsafe fn convert_32bit_pixel_buffer( base_ptr: *const u8, bytes_per_row: usize, @@ -1014,6 +1039,7 @@ unsafe fn convert_32bit_pixel_buffer( } /// The row loop of `convert_32bit_pixel_buffer`, over a slice. +#[cfg(any(target_os = "macos", test))] fn convert_32bit_rows( raw_data: &[u8], bytes_per_row: usize, @@ -1056,12 +1082,14 @@ fn convert_32bit_rows( } #[derive(Copy, Clone)] +#[cfg(any(target_os = "macos", test))] pub enum Nv12Range { Video, _Full, } /// The plane geometry `convert_nv12_pixel_buffer` reads out of the lock. +#[cfg(target_os = "macos")] struct Nv12Planes { y: *const u8, y_stride: usize, @@ -1076,7 +1104,7 @@ struct Nv12Planes { /// # Safety /// Both plane pointers must address at least `stride * plane_height` readable /// bytes of a locked pixel buffer. -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +#[cfg(target_os = "macos")] unsafe fn convert_nv12_pixel_buffer( planes: Nv12Planes, width: usize, @@ -1125,6 +1153,7 @@ unsafe fn convert_nv12_pixel_buffer( } /// The pixel loop of `convert_nv12_pixel_buffer`, over slices. +#[cfg(any(target_os = "macos", test))] fn convert_nv12_planes( y_plane: &[u8], y_stride: usize, @@ -1188,6 +1217,7 @@ fn convert_nv12_planes( /// `ycbcr_to_rgb` (`thumbnails/mac.rs:256-275`): BT.601 coefficients, with the /// video-range 16..235 luma expansion. +#[cfg(any(target_os = "macos", test))] fn ycbcr_to_rgb(y: u8, cb: u8, cr: u8, range: Nv12Range) -> (u8, u8, u8) { let y = y as f32; let cb = cb as f32 - 128.0; @@ -1205,6 +1235,7 @@ fn ycbcr_to_rgb(y: u8, cb: u8, cr: u8, range: Nv12Range) -> (u8, u8, u8) { (clamp_channel(r), clamp_channel(g), clamp_channel(b)) } +#[cfg(any(target_os = "macos", test))] fn clamp_channel(value: f32) -> u8 { value.clamp(0.0, 255.0) as u8 } diff --git a/apps/desktop-gpui/src/transcription.rs b/apps/desktop-gpui/src/transcription.rs index ab4fef84005..4f27fe5943f 100644 --- a/apps/desktop-gpui/src/transcription.rs +++ b/apps/desktop-gpui/src/transcription.rs @@ -155,6 +155,16 @@ struct Hub { generation_errors: HashMap, } +impl Hub { + fn work_in_flight(&self) -> bool { + self.download + .as_ref() + .is_some_and(|download| download.state == DownloadState::Downloading) + || !self.generating.is_empty() + || self.deleting.is_some() + } +} + static HUB: LazyLock> = LazyLock::new(|| Mutex::new(Hub::default())); fn hub() -> std::sync::MutexGuard<'static, Hub> { @@ -188,6 +198,10 @@ pub fn download_active() -> bool { .is_some_and(|download| download.state == DownloadState::Downloading) } +pub fn work_in_flight() -> bool { + hub().work_in_flight() +} + /// Rescan the catalogue against the disk -- `refreshDownloadedModels` /// (`CaptionsTab.tsx:481-492`), minus the async round-trips. pub fn refresh_downloaded_models() { @@ -450,6 +464,7 @@ async fn total_content_length(urls: &[&str]) -> u64 { } /// `PARAKEET_TDT_INT8_MODEL_FILES` (`captions.rs:2249-2268`). +#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] const PARAKEET_TDT_INT8_MODEL_FILES: &[(&str, &[&str])] = &[ ( "encoder-model.int8.onnx", @@ -472,6 +487,7 @@ const PARAKEET_TDT_INT8_MODEL_FILES: &[(&str, &[&str])] = &[ ]; /// `PARAKEET_TDT_FULL_MODEL_FILES` (`captions.rs:2270-2296`). +#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] const PARAKEET_TDT_FULL_MODEL_FILES: &[(&str, &[&str])] = &[ ( "encoder-model.onnx", @@ -501,6 +517,7 @@ const PARAKEET_TDT_FULL_MODEL_FILES: &[(&str, &[&str])] = &[ ]; /// `PARAKEET_MODEL_CLEANUP_FILES` (`captions.rs:2298-2306`). +#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] const PARAKEET_MODEL_CLEANUP_FILES: &[&str] = &[ "encoder-model.onnx", "encoder-model.onnx.data", @@ -512,6 +529,7 @@ const PARAKEET_MODEL_CLEANUP_FILES: &[&str] = &[ ]; /// `PARAKEET_KNOWN_PART_SIZES` (`captions.rs:2308-2316`). +#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] const PARAKEET_KNOWN_PART_SIZES: &[(&str, u64)] = &[ ("encoder-model.int8.onnx", 652_183_999), ("decoder_joint-model.int8.onnx", 18_202_004), @@ -522,12 +540,14 @@ const PARAKEET_KNOWN_PART_SIZES: &[(&str, u64)] = &[ ("vocab.txt", 93_939), ]; +#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] fn parakeet_known_part_size(url: &str) -> Option { PARAKEET_KNOWN_PART_SIZES .iter() .find_map(|(name, size)| url.ends_with(name).then_some(*size)) } +#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] fn parakeet_model_files_for_dir( output_dir: &Path, ) -> &'static [(&'static str, &'static [&'static str])] { @@ -537,6 +557,7 @@ fn parakeet_model_files_for_dir( } } +#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] fn parakeet_staging_dir(validated_dir: &Path) -> PathBuf { validated_dir.with_file_name(format!( "{}.downloading", @@ -547,6 +568,7 @@ fn parakeet_staging_dir(validated_dir: &Path) -> PathBuf { )) } +#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] async fn parakeet_model_file_sizes( model_files: &'static [(&'static str, &'static [&'static str])], ) -> Result, String> { @@ -580,6 +602,7 @@ async fn parakeet_model_file_sizes( Ok(sizes) } +#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] fn parakeet_model_files_match(dir: &Path, expected_files: &[(&str, u64)]) -> bool { expected_files.iter().all(|(filename, expected_size)| { let Ok(metadata) = std::fs::metadata(dir.join(filename)) else { @@ -589,6 +612,7 @@ fn parakeet_model_files_match(dir: &Path, expected_files: &[(&str, u64)]) -> boo }) } +#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] fn finalize_parakeet_model_download( validated_dir: &Path, staging_dir: &Path, @@ -2401,6 +2425,30 @@ mod tests { assert_eq!(model_path("medium"), base.join("medium.bin")); } + #[test] + fn downloads_generation_and_deletion_block_application_handoffs() { + let mut state = Hub::default(); + assert!(!state.work_in_flight()); + + state.download = Some(ModelDownload { + model: "small".to_string(), + state: DownloadState::Downloading, + progress: 25.0, + message: "Downloading".to_string(), + }); + assert!(state.work_in_flight()); + + state.download.as_mut().unwrap().state = DownloadState::Completed; + assert!(!state.work_in_flight()); + + state.generating.insert(PathBuf::from("/tmp/recording.cap")); + assert!(state.work_in_flight()); + state.generating.clear(); + + state.deleting = Some("small".to_string()); + assert!(state.work_in_flight()); + } + #[test] fn source_caption_id_strips_the_edl_suffix() { assert_eq!(source_caption_id("segment-0-1"), "segment-0-1"); diff --git a/apps/desktop-gpui/src/tray.rs b/apps/desktop-gpui/src/tray.rs index fec35c0bd1f..4c991e2c13a 100644 --- a/apps/desktop-gpui/src/tray.rs +++ b/apps/desktop-gpui/src/tray.rs @@ -38,6 +38,7 @@ const THUMBNAIL_SIZE: u32 = 32; /// `get_mode_icon(mode)`. Template images (`icon_as_template(true)`), so macOS /// tints them for the current menu-bar appearance. +#[cfg(target_os = "macos")] fn mode_icon(mode: Mode) -> &'static [u8] { match mode { Mode::Studio => include_bytes!("../assets/tray/tray-default-icon-studio.png"), @@ -47,6 +48,7 @@ fn mode_icon(mode: Mode) -> &'static [u8] { } /// `set_tray_stop_icon`. +#[cfg(target_os = "macos")] const STOP_ICON: &[u8] = include_bytes!("../assets/tray/tray-stop-icon.png"); // --------------------------------------------------------------------------- @@ -1071,19 +1073,22 @@ mod mac { mod stub { use gpui::App; - use super::{Entry, PreviousItem}; + use super::{Entry, PreviousItem, current_menu_entries, scan_previous}; pub fn init(_cx: &mut App) {} - pub fn rebuild(_cx: &mut App) {} pub fn set_recording(_recording: bool, _cx: &mut App) {} pub fn mode_changed(_mode: crate::main_window::Mode, _cx: &mut App) {} pub fn refresh_previous(_cx: &mut App) {} pub fn refresh_menu(_cx: &mut App) {} pub fn previous_items(_cx: &App) -> Vec { - Vec::new() + scan_previous(false) } - pub fn menu_snapshot(_cx: &App) -> Vec { - Vec::new() + pub fn menu_snapshot(cx: &App) -> Vec { + current_menu_entries( + cx, + crate::main_window::Mode::from_store(), + &scan_previous(false), + ) } } diff --git a/apps/desktop-gpui/src/updates.rs b/apps/desktop-gpui/src/updates.rs new file mode 100644 index 00000000000..69af5f4916a --- /dev/null +++ b/apps/desktop-gpui/src/updates.rs @@ -0,0 +1,516 @@ +//! Update discovery for sessions owned by GPUI. +//! +//! The endpoint response is advisory only. GPUI never downloads or installs +//! it; accepting the prompt hands control to Tauri, which repeats the check +//! and enforces the signed updater contract before changing the app bundle. + +use std::time::Duration; + +use futures_util::future::{Either, select}; +use gpui::{App, Global}; +use semver::Version; +use serde::Deserialize; + +use crate::{ + session::RecordingSession, + store::{GeneralSettings, UpdateChannel}, +}; + +const UPDATE_ENDPOINT: &str = + "https://cdn.crabnebula.app/update/cap/cap/{target}/{current_version}"; +const STABLE_FIRST_CHECK_DELAY: Duration = Duration::from_secs(10); +const NIGHTLY_FIRST_CHECK_DELAY: Duration = Duration::from_secs(60); +const NIGHTLY_CHECK_INTERVAL: Duration = Duration::from_secs(2 * 60 * 60); +const BUSY_RETRY_DELAY: Duration = Duration::from_secs(5 * 60); + +#[derive(Deserialize)] +struct AvailableUpdate { + version: String, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct PendingUpdateRequests { + manual: bool, + channel: Option, +} + +impl PendingUpdateRequests { + fn request_manual(&mut self, manual_in_flight: bool) -> bool { + if self.manual || manual_in_flight { + return false; + } + + self.manual = true; + true + } + + fn request_channel(&mut self, channel: UpdateChannel) { + self.channel = Some(channel); + } +} + +struct UpdateScheduler { + wake: flume::Sender<()>, + pending: PendingUpdateRequests, + manual_in_flight: bool, +} + +impl Global for UpdateScheduler {} + +fn updater_target() -> String { + 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}") + } +} + +fn endpoint(channel: UpdateChannel) -> String { + let url = UPDATE_ENDPOINT + .replace("{target}", &updater_target()) + .replace("{current_version}", env!("CARGO_PKG_VERSION")); + match channel { + UpdateChannel::Stable => url, + UpdateChannel::Nightly => format!("{url}?channel=nightly"), + } +} + +async fn remote_version(channel: UpdateChannel) -> Result, String> { + let response = reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .map_err(|error| error.to_string())? + .get(endpoint(channel)) + .send() + .await + .map_err(|error| error.to_string())?; + + if response.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(None); + } + + let update = response + .error_for_status() + .map_err(|error| error.to_string())? + .json::() + .await + .map_err(|error| error.to_string())?; + Version::parse(&update.version) + .map(Some) + .map_err(|error| error.to_string()) +} + +fn qualifies( + current: &Version, + remote: &Version, + configured_channel: UpdateChannel, + remote_channel: UpdateChannel, +) -> bool { + remote > current + || (configured_channel == UpdateChannel::Stable + && remote_channel == UpdateChannel::Stable + && !current.pre.is_empty() + && remote.pre.is_empty() + && remote != current) +} + +async fn available_version(channel: UpdateChannel) -> Result, String> { + let current = Version::parse(env!("CARGO_PKG_VERSION")).map_err(|error| error.to_string())?; + let stable = remote_version(UpdateChannel::Stable) + .await + .map(|candidate| { + candidate.filter(|remote| qualifies(¤t, remote, channel, UpdateChannel::Stable)) + }); + + if channel == UpdateChannel::Stable { + return stable; + } + + let nightly = remote_version(UpdateChannel::Nightly) + .await + .map(|candidate| { + candidate.filter(|remote| qualifies(¤t, remote, channel, UpdateChannel::Nightly)) + }); + + select_available_version(stable, nightly) +} + +fn select_available_version( + stable: Result, String>, + nightly: Result, String>, +) -> Result, String> { + match (stable, nightly) { + (Ok(Some(stable)), Ok(Some(nightly))) => Ok(Some(stable.max(nightly))), + (Ok(stable), Ok(nightly)) => Ok(stable.or(nightly)), + (Ok(candidate), Err(error)) | (Err(error), Ok(candidate)) => { + tracing::warn!("update check failed for one channel: {error}"); + Ok(candidate) + } + (Err(error), Err(_)) => Err(error), + } +} + +pub(crate) fn work_in_flight(cx: &mut App) -> bool { + RecordingSession::recording_in_flight(cx) + || crate::app_windows::export_in_flight(cx) + || crate::import::imports_in_flight(cx) + || crate::transcription::work_in_flight() +} + +pub(crate) fn check_manually(cx: &mut App) { + if !cx.has_global::() { + return; + } + + let scheduler = cx.global_mut::(); + if !scheduler.pending.request_manual(scheduler.manual_in_flight) { + return; + } + + let _ = scheduler.wake.try_send(()); +} + +pub(crate) fn update_channel_changed(channel: UpdateChannel, cx: &mut App) { + if !cx.has_global::() { + return; + } + + let scheduler = cx.global_mut::(); + scheduler.pending.request_channel(channel); + let _ = scheduler.wake.try_send(()); +} + +fn first_check_delay(channel: UpdateChannel) -> Duration { + match channel { + UpdateChannel::Stable => STABLE_FIRST_CHECK_DELAY, + UpdateChannel::Nightly => NIGHTLY_FIRST_CHECK_DELAY, + } +} + +fn next_check_delay(channel: UpdateChannel) -> Option { + (channel == UpdateChannel::Nightly).then_some(NIGHTLY_CHECK_INTERVAL) +} + +fn finish_manual_check(cx: &mut App, manual: bool) { + if manual { + cx.global_mut::().manual_in_flight = false; + } +} + +pub(crate) fn schedule_startup_check(cx: &mut App) { + let (wake, requests) = flume::bounded(1); + cx.set_global(UpdateScheduler { + wake, + pending: PendingUpdateRequests::default(), + manual_in_flight: false, + }); + + cx.spawn(async move |cx| { + let mut channel = cx + .background_executor() + .spawn(async { GeneralSettings::load().update_channel }) + .await; + let mut delay = (!cfg!(debug_assertions)).then(|| first_check_delay(channel)); + let mut ignored_version: Option = None; + + loop { + let signaled = match delay { + Some(delay) => { + let timer = cx.background_executor().timer(delay); + let request = requests.recv_async(); + futures_util::pin_mut!(timer, request); + match select(timer, request).await { + Either::Left(_) => false, + Either::Right((Ok(()), _)) => true, + Either::Right((Err(_), _)) => return, + } + } + None => { + if requests.recv_async().await.is_err() { + return; + } + true + } + }; + + let request = cx.update(|cx| { + let scheduler = cx.global_mut::(); + std::mem::take(&mut scheduler.pending) + }); + + if signaled && request == PendingUpdateRequests::default() { + continue; + } + + if let Some(next_channel) = request.channel { + channel = next_channel; + ignored_version = None; + } + + let manual = request.manual; + if cfg!(debug_assertions) && !manual { + delay = None; + continue; + } + + if manual { + cx.update(|cx| cx.global_mut::().manual_in_flight = true); + } + + delay = next_check_delay(channel); + + if cx.update(work_in_flight) { + if manual { + crate::platform::activate_app(); + crate::platform::alert_dialog( + "Cap is busy", + "Finish your recording, export, upload, import, or transcription task before checking for updates.", + ); + cx.update(|cx| finish_manual_check(cx, true)); + } else { + delay = Some(BUSY_RETRY_DELAY); + } + continue; + } + + let result = match cx + .update(|cx| gpui_tokio::Tokio::spawn(cx, available_version(channel))) + .await + { + Ok(result) => result, + Err(error) => Err(error.to_string()), + }; + + let superseded = cx.update(|cx| { + let scheduler = cx.global_mut::(); + let channel_changed = scheduler.pending.channel.is_some(); + if channel_changed && manual { + scheduler.pending.manual = true; + scheduler.manual_in_flight = false; + } + channel_changed || (!manual && scheduler.pending.manual) + }); + if superseded { + continue; + } + + let version = match result { + Ok(Some(version)) => version, + Ok(None) => { + if manual { + crate::platform::activate_app(); + crate::platform::alert_dialog( + "No Update Available", + "You're already using the latest version of Cap.", + ); + cx.update(|cx| finish_manual_check(cx, true)); + } + continue; + } + Err(error) => { + tracing::warn!("update check failed: {error}"); + if manual { + crate::platform::activate_app(); + crate::platform::alert_dialog( + "Update Cap", + &format!("Couldn't check for updates: {error}"), + ); + cx.update(|cx| finish_manual_check(cx, true)); + } + continue; + } + }; + + if !manual && ignored_version.as_ref() == Some(&version) { + continue; + } + + if cx.update(work_in_flight) { + if manual { + crate::platform::activate_app(); + crate::platform::alert_dialog( + "Cap is busy", + "Finish your recording, export, upload, import, or transcription task before checking for updates.", + ); + cx.update(|cx| finish_manual_check(cx, true)); + } else { + delay = Some(BUSY_RETRY_DELAY); + } + continue; + } + + crate::platform::activate_app(); + if crate::platform::confirm_dialog( + "Update Cap", + &format!("Version {version} of Cap is available. Would you like to install it?"), + "Update", + "Ignore", + false, + ) { + cx.update(|cx| { + finish_manual_check(cx, manual); + crate::settings_pages::start_update_handoff(cx); + }); + } else { + ignored_version = Some(version); + cx.update(|cx| finish_manual_check(cx, manual)); + } + } + }) + .detach(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn version(value: &str) -> Version { + Version::parse(value).unwrap() + } + + #[test] + fn stable_channel_accepts_newer_versions_and_explicit_prerelease_downgrades() { + assert!(qualifies( + &version("0.6.0"), + &version("0.6.1"), + UpdateChannel::Stable, + UpdateChannel::Stable, + )); + assert!(qualifies( + &version("0.6.1-nightly.4"), + &version("0.6.0"), + UpdateChannel::Stable, + UpdateChannel::Stable, + )); + assert!(!qualifies( + &version("0.6.1"), + &version("0.6.0"), + UpdateChannel::Stable, + UpdateChannel::Stable, + )); + } + + #[test] + fn nightly_channel_never_uses_the_stable_downgrade_rule() { + assert!(!qualifies( + &version("0.6.1-nightly.4"), + &version("0.6.0"), + UpdateChannel::Nightly, + UpdateChannel::Stable, + )); + assert!(qualifies( + &version("0.6.1-nightly.4"), + &version("0.6.1-nightly.5"), + UpdateChannel::Nightly, + UpdateChannel::Nightly, + )); + } + + #[test] + fn nightly_channel_survives_individual_endpoint_failures() { + assert_eq!( + select_available_version(Err("stable unavailable".into()), Ok(Some(version("0.6.1")))) + .unwrap(), + Some(version("0.6.1")), + ); + assert_eq!( + select_available_version( + Ok(Some(version("0.6.2"))), + Err("nightly unavailable".into()) + ) + .unwrap(), + Some(version("0.6.2")), + ); + assert_eq!( + select_available_version( + Err("stable unavailable".into()), + Err("nightly unavailable".into()), + ), + Err("stable unavailable".into()), + ); + } + + #[test] + fn nightly_channel_prefers_the_newest_successful_version() { + assert_eq!( + select_available_version( + Ok(Some(version("0.6.1"))), + Ok(Some(version("0.6.2-nightly.4"))), + ) + .unwrap(), + Some(version("0.6.2-nightly.4")), + ); + } + + #[test] + fn check_cadence_preserves_each_update_channel_contract() { + assert_eq!( + first_check_delay(UpdateChannel::Stable), + Duration::from_secs(10) + ); + assert_eq!( + first_check_delay(UpdateChannel::Nightly), + Duration::from_secs(60) + ); + assert_eq!(next_check_delay(UpdateChannel::Stable), None); + assert_eq!( + next_check_delay(UpdateChannel::Nightly), + Some(Duration::from_secs(2 * 60 * 60)) + ); + } + + #[test] + fn manual_checks_coalesce_while_pending_or_running() { + let mut requests = PendingUpdateRequests::default(); + + assert!(requests.request_manual(false)); + assert!(!requests.request_manual(false)); + + requests.manual = false; + assert!(!requests.request_manual(true)); + assert!(requests.request_manual(false)); + } + + #[test] + fn channel_changes_coalesce_without_losing_manual_checks() { + let mut requests = PendingUpdateRequests::default(); + + assert!(requests.request_manual(false)); + requests.request_channel(UpdateChannel::Nightly); + requests.request_channel(UpdateChannel::Stable); + + assert_eq!( + requests, + PendingUpdateRequests { + manual: true, + channel: Some(UpdateChannel::Stable), + } + ); + } + + #[test] + fn recording_exports_and_uploads_remain_update_blockers_until_finished() { + use crate::editor_export::ExportPhase; + + for phase in [ + ExportPhase::Starting, + ExportPhase::Rendering, + ExportPhase::Copying, + ExportPhase::Uploading, + ] { + assert!(phase.is_busy()); + } + + for phase in [ExportPhase::Idle, ExportPhase::Done, ExportPhase::Failed] { + assert!(!phase.is_busy()); + } + } +} diff --git a/apps/desktop-gpui/src/upload.rs b/apps/desktop-gpui/src/upload.rs index a7f44010206..b69c7d56b17 100644 --- a/apps/desktop-gpui/src/upload.rs +++ b/apps/desktop-gpui/src/upload.rs @@ -2,9 +2,12 @@ use std::collections::HashMap; use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; -use cap_project::{RecordingMeta, S3UploadMeta, SharingMeta, UploadMeta}; +use cap_enc_ffmpeg::segmented_stream::{SegmentCompletedEvent, SegmentMediaType}; +use cap_project::{RecordingMeta, S3UploadMeta, SharingMeta, UploadMeta, VideoUploadInfo}; +use futures_util::{StreamExt as _, stream::FuturesUnordered}; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -13,6 +16,17 @@ use crate::auth::{self, AuthApiError}; const MIN_CHUNK_SIZE: u64 = 5 * 1024 * 1024; const MAX_CHUNK_SIZE: u64 = 15 * 1024 * 1024; +const MAX_SEGMENT_UPLOADS: usize = 6; +const SEGMENT_URL_PREFETCH: u32 = 20; +const SEGMENT_UPLOAD_ATTEMPTS: u32 = 3; +const MANIFEST_UPLOAD_INTERVAL: Duration = Duration::from_secs(1); + +#[derive(Debug, Deserialize)] +struct SignedUploadTarget { + url: String, + #[serde(default)] + headers: HashMap, +} #[derive(Debug, Clone, PartialEq, Eq)] pub enum UploadResult { @@ -21,6 +35,526 @@ pub enum UploadResult { UpgradeRequired, } +pub struct InstantUpload { + pub video: VideoUploadInfo, + segment_upload: Option>>, + cancel: Arc, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct SegmentManifestEntry { + index: u32, + duration: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct SegmentUploadManifest { + version: u32, + video_init_uploaded: bool, + audio_init_uploaded: bool, + video_segments: Vec, + audio_segments: Vec, + is_complete: bool, +} + +impl Default for SegmentUploadManifest { + fn default() -> Self { + Self { + version: 2, + video_init_uploaded: false, + audio_init_uploaded: false, + video_segments: Vec::new(), + audio_segments: Vec::new(), + is_complete: false, + } + } +} + +impl SegmentUploadManifest { + fn record(&mut self, event: &SegmentCompletedEvent) { + match (event.is_init, event.media_type) { + (true, SegmentMediaType::Video) => self.video_init_uploaded = true, + (true, SegmentMediaType::Audio) => self.audio_init_uploaded = true, + (false, media_type) => { + let segments = match media_type { + SegmentMediaType::Video => &mut self.video_segments, + SegmentMediaType::Audio => &mut self.audio_segments, + }; + if let Some(segment) = segments + .iter_mut() + .find(|segment| segment.index == event.index) + { + segment.duration = event.duration; + } else { + segments.push(SegmentManifestEntry { + index: event.index, + duration: event.duration, + }); + segments.sort_unstable_by_key(|segment| segment.index); + } + } + } + } + + fn has_video_content(&self) -> bool { + self.video_init_uploaded && !self.video_segments.is_empty() + } +} + +pub async fn prepare_instant_upload( + camera_only: bool, + project_name: String, + organization_id: Option, +) -> Result { + if store_auth_missing() { + return Err("Please sign in to use instant recording".to_string()); + } + + let recording_mode = if camera_only { + "desktopMP4" + } else { + "desktopSegments" + }; + let config = create_or_get_video_with_mode( + false, + None, + Some(project_name), + None, + organization_id, + recording_mode, + ) + .await + .map_err(|error| match error { + AuthApiError::InvalidAuthentication => { + "Your session has expired. Please sign in again to use instant recording.".to_string() + } + AuthApiError::UpgradeRequired => "Instant recording requires an upgraded plan.".to_string(), + error => format!("Could not create the shareable link: {error}"), + })?; + + Ok(VideoUploadInfo { + id: config.id.clone(), + link: format!("{}/s/{}", auth::server_url(), config.id), + config, + }) +} + +pub fn start_instant_upload( + video: VideoUploadInfo, + project_path: PathBuf, + segment_rx: Option>, +) -> Result { + let cancel = Arc::new(AtomicBool::new(false)); + let segment_upload = if let Some(segment_rx) = segment_rx { + let (events_tx, events_rx) = flume::unbounded(); + std::thread::Builder::new() + .name("gpui-instant-segments".to_string()) + .spawn(move || { + while let Ok(event) = segment_rx.recv() { + if events_tx.send(event).is_err() { + break; + } + } + }) + .map_err(|error| format!("Failed to start instant upload: {error}"))?; + + let upload_video = video.clone(); + let upload_cancel = cancel.clone(); + Some(tokio::spawn(async move { + run_segment_upload(upload_video, project_path, events_rx, upload_cancel).await + })) + } else { + None + }; + + Ok(InstantUpload { + video, + segment_upload, + cancel, + }) +} + +impl InstantUpload { + pub fn video(&self) -> &VideoUploadInfo { + &self.video + } + + pub fn is_segmented(&self) -> bool { + self.segment_upload.is_some() + } + + pub async fn finish_segments(&mut self) -> Result<(), String> { + let Some(upload) = self.segment_upload.take() else { + return Ok(()); + }; + upload + .await + .map_err(|error| format!("Instant segment upload task failed: {error}"))? + } + + pub async fn finish_screenshot(&self, project_path: &Path) -> Result<(), String> { + upload_screenshot( + &self.video.id, + &project_path.join("screenshots/display.jpg"), + ) + .await + .map_err(|error| format!("Instant recording thumbnail upload failed: {error}")) + } + + pub async fn cancel(mut self) -> Result<(), String> { + self.cancel.store(true, Ordering::Release); + if let Some(upload) = self.segment_upload.take() { + upload.abort(); + } + + delete_instant_video(&self.video.id).await + } +} + +pub async fn delete_instant_video(video_id: &str) -> Result<(), String> { + let path = format!( + "/api/desktop/video/delete?videoId={}", + urlencoding(video_id) + ); + let response = auth::authed_request(reqwest::Method::DELETE, &path, None) + .await + .map_err(|error| format!("Failed to delete instant recording: {error}"))?; + let status = response.status(); + if status.is_success() || status == StatusCode::NOT_FOUND { + return Ok(()); + } + let body = response.text().await.unwrap_or_default(); + Err(format!( + "Failed to delete instant recording {video_id}: {status}: {body}" + )) +} + +async fn run_segment_upload( + video: VideoUploadInfo, + project_path: PathBuf, + events: flume::Receiver, + cancel: Arc, +) -> Result<(), String> { + let result = upload_segments(&video.id, events, cancel.clone()).await; + if let Err(error) = &result + && !cancel.load(Ordering::Acquire) + && let Ok(mut meta) = RecordingMeta::load_for_project(&project_path) + { + meta.upload = Some(UploadMeta::Failed { + error: error.clone(), + }); + if let Err(save_error) = meta.save_for_project() { + tracing::error!("Failed to persist instant upload failure: {save_error}"); + } + } + result +} + +async fn upload_segments( + video_id: &str, + events: flume::Receiver, + cancel: Arc, +) -> Result<(), String> { + let mut manifest = SegmentUploadManifest::default(); + let mut uploads = FuturesUnordered::new(); + let mut events_closed = false; + let mut last_manifest_upload: Option = None; + let mut next_prefetch = SEGMENT_URL_PREFETCH + 1; + let signed_urls = Arc::new(Mutex::new( + prefetch_segment_urls(video_id, 1, SEGMENT_URL_PREFETCH) + .await + .unwrap_or_else(|error| { + tracing::warn!("Failed to prefetch instant upload URLs: {error}"); + HashMap::new() + }), + )); + + loop { + tokio::select! { + next_event = events.recv_async(), if !events_closed && uploads.len() < MAX_SEGMENT_UPLOADS => { + match next_event { + Ok(event) => { + if cancel.load(Ordering::Acquire) { + return Err("Instant recording upload cancelled".to_string()); + } + if event.media_type == SegmentMediaType::Video + && event.index.saturating_add(5) >= next_prefetch + { + match prefetch_segment_urls(video_id, next_prefetch, SEGMENT_URL_PREFETCH).await { + Ok(urls) => { + signed_urls.lock().unwrap_or_else(|error| error.into_inner()).extend(urls); + next_prefetch = next_prefetch.saturating_add(SEGMENT_URL_PREFETCH); + } + Err(error) => tracing::warn!("Failed to extend instant upload URLs: {error}"), + } + } + uploads.push(upload_segment_with_retry( + video_id.to_string(), + event, + signed_urls.clone(), + cancel.clone(), + )); + } + Err(_) => events_closed = true, + } + } + Some(upload) = uploads.next(), if !uploads.is_empty() => { + let event = upload?; + manifest.record(&event); + if manifest.has_video_content() + && last_manifest_upload.is_none_or(|last| last.elapsed() >= MANIFEST_UPLOAD_INTERVAL) + { + upload_segment_manifest_with_retry(video_id, &manifest, &cancel).await?; + last_manifest_upload = Some(Instant::now()); + } + } + else => break, + } + } + + if cancel.load(Ordering::Acquire) { + return Err("Instant recording upload cancelled".to_string()); + } + if !manifest.has_video_content() { + return Err(format!( + "Segment upload completed without video segments for {video_id}" + )); + } + + manifest.is_complete = true; + upload_segment_manifest_with_retry(video_id, &manifest, &cancel).await?; + signal_recording_complete_with_retry(video_id, &cancel).await +} + +fn prefetched_segment_paths(start: u32, count: u32) -> Vec { + let mut subpaths = Vec::with_capacity((count as usize).saturating_mul(2).saturating_add(3)); + if start == 1 { + subpaths.push("segments/video/init.mp4".to_string()); + subpaths.push("segments/audio/init.mp4".to_string()); + subpaths.push("segments/manifest.json".to_string()); + } + for index in start..start.saturating_add(count) { + subpaths.push(format!("segments/video/segment_{index:03}.m4s")); + subpaths.push(format!("segments/audio/segment_{index:03}.m4s")); + } + subpaths +} + +async fn prefetch_segment_urls( + video_id: &str, + start: u32, + count: u32, +) -> Result, AuthApiError> { + #[derive(Deserialize)] + struct BatchResponse { + urls: HashMap, + } + + let response = auth::authed_request( + reqwest::Method::POST, + "/api/upload/signed/batch", + Some(json!({ + "videoId": video_id, + "subpaths": prefetched_segment_paths(start, count), + })), + ) + .await?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AuthApiError::Other(format!( + "api/upload_signed_batch/{status}: {body}" + ))); + } + response + .json::() + .await + .map(|batch| batch.urls) + .map_err(|error| AuthApiError::Other(format!("api/upload_signed_batch/response: {error}"))) +} + +async fn upload_segment_with_retry( + video_id: String, + event: SegmentCompletedEvent, + signed_urls: Arc>>, + cancel: Arc, +) -> Result { + let subpath = segment_subpath(&event); + let bytes = read_completed_segment(&event, &subpath, &cancel).await?; + let mut cached_url = signed_urls + .lock() + .unwrap_or_else(|error| error.into_inner()) + .remove(&subpath); + + for attempt in 0..SEGMENT_UPLOAD_ATTEMPTS { + if cancel.load(Ordering::Acquire) { + return Err("Instant recording upload cancelled".to_string()); + } + if attempt > 0 { + tokio::time::sleep(Duration::from_millis(250u64 << attempt)).await; + } + + let result = if let Some(url) = cached_url.take() { + upload_signed_bytes( + SignedUploadTarget { + url, + headers: HashMap::new(), + }, + &subpath, + bytes.clone(), + ) + .await + } else { + presigned_put_bytes(&video_id, &subpath, bytes.clone()).await + }; + + match result { + Ok(()) => return Ok(event), + Err(AuthApiError::InvalidAuthentication) => { + return Err("Authentication expired while uploading the instant recording".into()); + } + Err(error) if attempt + 1 == SEGMENT_UPLOAD_ATTEMPTS => { + return Err(format!( + "Failed to upload instant segment {subpath}: {error}" + )); + } + Err(error) => tracing::warn!( + subpath, + attempt = attempt + 1, + "Instant recording segment upload failed; retrying: {error}" + ), + } + } + + Err(format!("Failed to upload instant segment {subpath}")) +} + +async fn read_completed_segment( + event: &SegmentCompletedEvent, + subpath: &str, + cancel: &AtomicBool, +) -> Result, String> { + let started = Instant::now(); + loop { + if cancel.load(Ordering::Acquire) { + return Err("Instant recording upload cancelled".to_string()); + } + let bytes = tokio::task::spawn_blocking({ + let path = event.path.clone(); + move || std::fs::read(path) + }) + .await + .map_err(|error| format!("Failed to read instant segment {subpath}: {error}"))?; + + match bytes { + Ok(bytes) + if !bytes.is_empty() + && (event.file_size == 0 || bytes.len() >= event.file_size as usize) => + { + return Ok(bytes); + } + Ok(_) if started.elapsed() >= Duration::from_secs(10) => { + return Err(format!( + "Instant recording segment is incomplete: {subpath}" + )); + } + Err(error) if started.elapsed() >= Duration::from_secs(10) => { + return Err(format!("Failed to read instant segment {subpath}: {error}")); + } + _ => tokio::time::sleep(Duration::from_millis(50)).await, + } + } +} + +fn segment_subpath(event: &SegmentCompletedEvent) -> String { + match (event.is_init, event.media_type) { + (true, SegmentMediaType::Video) => "segments/video/init.mp4".to_string(), + (true, SegmentMediaType::Audio) => "segments/audio/init.mp4".to_string(), + (false, SegmentMediaType::Video) => { + format!("segments/video/segment_{:03}.m4s", event.index) + } + (false, SegmentMediaType::Audio) => { + format!("segments/audio/segment_{:03}.m4s", event.index) + } + } +} + +async fn upload_segment_manifest( + video_id: &str, + manifest: &SegmentUploadManifest, +) -> Result<(), String> { + let bytes = serde_json::to_vec(manifest) + .map_err(|error| format!("Failed to serialize instant upload manifest: {error}"))?; + presigned_put_bytes(video_id, "segments/manifest.json", bytes) + .await + .map_err(|error| format!("Failed to upload instant recording manifest: {error}")) +} + +async fn upload_segment_manifest_with_retry( + video_id: &str, + manifest: &SegmentUploadManifest, + cancel: &AtomicBool, +) -> Result<(), String> { + for attempt in 0..SEGMENT_UPLOAD_ATTEMPTS { + if cancel.load(Ordering::Acquire) { + return Err("Instant recording upload cancelled".to_string()); + } + match upload_segment_manifest(video_id, manifest).await { + Ok(()) => return Ok(()), + Err(error) if attempt + 1 == SEGMENT_UPLOAD_ATTEMPTS => return Err(error), + Err(error) => { + tracing::warn!( + attempt = attempt + 1, + "Instant manifest upload failed: {error}" + ); + tokio::time::sleep(Duration::from_millis(250u64 << attempt)).await; + } + } + } + Err("Instant recording manifest upload failed".to_string()) +} + +async fn signal_recording_complete_with_retry( + video_id: &str, + cancel: &AtomicBool, +) -> Result<(), String> { + for attempt in 0..SEGMENT_UPLOAD_ATTEMPTS { + if cancel.load(Ordering::Acquire) { + return Err("Instant recording upload cancelled".to_string()); + } + match signal_recording_complete(video_id).await { + Ok(()) => return Ok(()), + Err(error) if attempt + 1 == SEGMENT_UPLOAD_ATTEMPTS => return Err(error), + Err(error) => { + tracing::warn!( + attempt = attempt + 1, + "Instant completion signal failed: {error}" + ); + tokio::time::sleep(Duration::from_millis(250u64 << attempt)).await; + } + } + } + Err("Failed to finish instant recording upload".to_string()) +} + +async fn signal_recording_complete(video_id: &str) -> Result<(), String> { + let response = auth::authed_request( + reqwest::Method::POST, + "/api/upload/recording-complete", + Some(json!({ "videoId": video_id })), + ) + .await + .map_err(|error| format!("Failed to finish instant recording upload: {error}"))?; + let status = response.status(); + if status.is_success() { + return Ok(()); + } + let body = response.text().await.unwrap_or_default(); + Err(format!( + "Failed to finish instant recording upload: {status}: {body}" + )) +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct UploadedPart { @@ -199,7 +733,29 @@ async fn create_or_get_video( meta: Option<&VideoMeta>, organization_id: Option, ) -> Result { - let mut path = "/api/desktop/video/create?recordingMode=desktopMP4".to_string(); + create_or_get_video_with_mode( + is_screenshot, + video_id, + name, + meta, + organization_id, + "desktopMP4", + ) + .await +} + +async fn create_or_get_video_with_mode( + is_screenshot: bool, + video_id: Option, + name: Option, + meta: Option<&VideoMeta>, + organization_id: Option, + recording_mode: &str, +) -> Result { + let mut path = format!( + "/api/desktop/video/create?recordingMode={}", + urlencoding(recording_mode) + ); if let Some(id) = video_id { path.push_str(&format!("&videoId={id}")); path.push_str("&createWithId=true"); @@ -577,13 +1133,7 @@ async fn presigned_put_bytes( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Response { - presigned_put_data: SignedUpload, - } - #[derive(Deserialize)] - struct SignedUpload { - url: String, - #[serde(default)] - headers: HashMap, + presigned_put_data: SignedUploadTarget, } let response = auth::authed_request( @@ -608,17 +1158,40 @@ async fn presigned_put_bytes( .await .map_err(|error| AuthApiError::Other(format!("api/upload_signed/response: {error}")))? .presigned_put_data; - let mut request = reqwest::Client::new() - .put(target.url) - .header("Content-Length", bytes.len()) + upload_signed_bytes(target, subpath, bytes).await +} + +async fn upload_signed_bytes( + target: SignedUploadTarget, + subpath: &str, + bytes: Vec, +) -> Result<(), AuthApiError> { + static CLIENT: OnceLock = OnceLock::new(); + + let length = bytes.len() as u64; + let mut request = CLIENT + .get_or_init(reqwest::Client::new) + .put(&target.url) + .header("Content-Length", length) + .header("Content-Type", upload_content_type(subpath)) + .timeout(Duration::from_secs(5 * 60)) .body(bytes); + if is_google_drive_resumable_url(&target.url) && length > 0 { + request = request.header( + "Content-Range", + format!("bytes 0-{}/{}", length.saturating_sub(1), length), + ); + } for (name, value) in target.headers { request = request.header(name, value); } - let response = request - .send() - .await - .map_err(|error| AuthApiError::Other(error.to_string()))?; + let response = request.send().await.map_err(|error| { + if error.is_timeout() { + AuthApiError::Timeout + } else { + AuthApiError::Other(error.to_string()) + } + })?; if !response.status().is_success() { return Err(AuthApiError::Other(format!( "upload failed: {}", @@ -628,6 +1201,20 @@ async fn presigned_put_bytes( Ok(()) } +fn upload_content_type(subpath: &str) -> &'static str { + if subpath.ends_with(".json") { + "application/json" + } else if subpath.ends_with(".mp4") || subpath.ends_with(".m4s") { + "video/mp4" + } else if subpath.ends_with(".png") { + "image/png" + } else if subpath.ends_with(".jpg") || subpath.ends_with(".jpeg") { + "image/jpeg" + } else { + "application/octet-stream" + } +} + // --------------------------------------------------------------------------- // Screenshot share upload // --------------------------------------------------------------------------- @@ -817,6 +1404,137 @@ fn urlencoding(value: &str) -> String { mod tests { use super::*; + fn segment_event( + index: u32, + duration: f64, + is_init: bool, + media_type: SegmentMediaType, + ) -> SegmentCompletedEvent { + SegmentCompletedEvent { + path: PathBuf::from("segment.m4s"), + index, + duration, + file_size: 16, + is_init, + media_type, + } + } + + #[test] + fn segment_manifest_requires_init_and_orders_video_and_audio() { + let mut manifest = SegmentUploadManifest::default(); + manifest.record(&segment_event(3, 1.5, false, SegmentMediaType::Video)); + manifest.record(&segment_event(1, 2.0, false, SegmentMediaType::Video)); + manifest.record(&segment_event(2, 2.0, false, SegmentMediaType::Audio)); + manifest.record(&segment_event(1, 1.8, false, SegmentMediaType::Audio)); + + assert!(!manifest.has_video_content()); + manifest.record(&segment_event(0, 0.0, true, SegmentMediaType::Video)); + manifest.record(&segment_event(0, 0.0, true, SegmentMediaType::Audio)); + + assert!(manifest.has_video_content()); + assert_eq!( + manifest + .video_segments + .iter() + .map(|segment| segment.index) + .collect::>(), + vec![1, 3] + ); + assert_eq!( + manifest + .audio_segments + .iter() + .map(|segment| segment.index) + .collect::>(), + vec![1, 2] + ); + assert!(manifest.video_init_uploaded); + assert!(manifest.audio_init_uploaded); + assert!(!manifest.is_complete); + } + + #[test] + fn initial_segment_batch_prefetches_initializers_and_matching_media_pairs() { + let paths = prefetched_segment_paths(1, 3); + + assert_eq!(paths.len(), 9); + assert!(paths.contains(&"segments/video/init.mp4".to_string())); + assert!(paths.contains(&"segments/audio/init.mp4".to_string())); + assert!(paths.contains(&"segments/manifest.json".to_string())); + assert!(paths.contains(&"segments/video/segment_001.m4s".to_string())); + assert!(paths.contains(&"segments/audio/segment_003.m4s".to_string())); + } + + #[test] + fn subsequent_segment_batches_do_not_repeat_initializers() { + assert_eq!( + prefetched_segment_paths(21, 2), + vec![ + "segments/video/segment_021.m4s", + "segments/audio/segment_021.m4s", + "segments/video/segment_022.m4s", + "segments/audio/segment_022.m4s", + ] + ); + } + + #[test] + fn segment_manifest_updates_replayed_segments_without_duplicates() { + let mut manifest = SegmentUploadManifest::default(); + manifest.record(&segment_event(4, 1.0, false, SegmentMediaType::Video)); + manifest.record(&segment_event(4, 2.5, false, SegmentMediaType::Video)); + + assert_eq!( + manifest.video_segments, + vec![SegmentManifestEntry { + index: 4, + duration: 2.5, + }] + ); + } + + #[test] + fn segment_upload_paths_match_tauri_storage_layout() { + assert_eq!( + segment_subpath(&segment_event(0, 0.0, true, SegmentMediaType::Video)), + "segments/video/init.mp4" + ); + assert_eq!( + segment_subpath(&segment_event(0, 0.0, true, SegmentMediaType::Audio)), + "segments/audio/init.mp4" + ); + assert_eq!( + segment_subpath(&segment_event(7, 2.0, false, SegmentMediaType::Video)), + "segments/video/segment_007.m4s" + ); + assert_eq!( + segment_subpath(&segment_event(14, 2.0, false, SegmentMediaType::Audio)), + "segments/audio/segment_014.m4s" + ); + } + + #[test] + fn signed_upload_content_types_match_media() { + assert_eq!( + upload_content_type("segments/manifest.json"), + "application/json" + ); + assert_eq!(upload_content_type("segments/video/init.mp4"), "video/mp4"); + assert_eq!( + upload_content_type("segments/audio/segment_001.m4s"), + "video/mp4" + ); + assert_eq!( + upload_content_type("screenshot/screen-capture.jpg"), + "image/jpeg" + ); + assert_eq!( + upload_content_type("screenshot/screen-capture.png"), + "image/png" + ); + } + #[test] fn chunk_size_clamps() { assert_eq!(chunk_size_for(1), MIN_CHUNK_SIZE); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e2e03c5c877..cd41bbe52ca 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,14 +2,16 @@ "name": "@cap/desktop", "type": "module", "scripts": { - "dev": "pnpm -w cap-setup && pnpm build:sidecar && dotenv -e ../../.env -- pnpm run preparescript && dotenv -e ../../.env -- node ../../scripts/dev-desktop.mjs", - "build:tauri": "pnpm build:sidecar && dotenv -e ../../.env -- pnpm run preparescript && dotenv -e ../../.env -- pnpm tauri build", + "dev": "pnpm -w cap-setup && pnpm build:sidecar && pnpm build:gpui:dev && dotenv -e ../../.env -- pnpm run preparescript && dotenv -e ../../.env -- node ../../scripts/dev-desktop.mjs", + "build:tauri": "pnpm build:sidecar && pnpm build:gpui && dotenv -e ../../.env -- pnpm run preparescript --release && node ../../scripts/verify-gpui-release-inputs.mjs && dotenv -e ../../.env -- pnpm tauri build", "build:sidecar": "node ../../scripts/build-desktop-binaries.mjs", + "build:gpui": "node ../../scripts/run-gpui-build.mjs release", + "build:gpui:dev": "dotenv -e ../../.env -- node ../../scripts/run-gpui-build.mjs debug", "preparescript": "node scripts/prepare.js", "localdev": "dotenv -e ../../.env -- vinxi dev --port 3002", "build": "vinxi build", "tauri": "tauri", - "test:display-transport": "node scripts/desktop-display-transport-benchmark.js", + "test:display-transport": "node --experimental-websocket scripts/desktop-display-transport-benchmark.js", "test:memory": "node scripts/desktop-memory-soak.js", "test:memory:unit": "vitest run scripts/desktop-memory-soak.test.js" }, @@ -80,7 +82,7 @@ "@solid-devtools/overlay": "^0.33.5", "@tailwindcss/postcss": "^4.2.2", "@tailwindcss/typography": "^0.5.9", - "@tauri-apps/cli": ">=2.1.0", + "@tauri-apps/cli": "2.8.4", "@total-typescript/ts-reset": "^0.6.1", "@types/dom-webcodecs": "^0.1.11", "@types/uuid": "^9.0.8", diff --git a/apps/desktop/scripts/desktop-display-transport-benchmark.js b/apps/desktop/scripts/desktop-display-transport-benchmark.js index 492a4c9ab15..162d2ef4980 100644 --- a/apps/desktop/scripts/desktop-display-transport-benchmark.js +++ b/apps/desktop/scripts/desktop-display-transport-benchmark.js @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { build } from "vite"; +import { resolveDesktopBenchmarkBrowser } from "./desktop-display-transport-browser.js"; const DESKTOP_ROOT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -13,8 +14,6 @@ const DESKTOP_ROOT = path.resolve( const REPO_ROOT = path.resolve(DESKTOP_ROOT, "../.."); const DEFAULT_RECORDING = "/tmp/cap-performance-fixtures/reference-recording.cap"; -const CHROME_PATH = - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; function parseArgs(argv) { const options = { @@ -84,9 +83,22 @@ if (!wsUrl) throw new Error("Missing ws query param"); const canvas = document.getElementById("canvas"); const status = document.getElementById("status"); const samples = []; +const animationFrameIntervals = []; let frameNotifications = 0; let latestFrame = null; let lastRequestFrameCount = 0; +let previousAnimationFrameAt = null; +let animationFrameId = null; + +function observeAnimationFrame(timestamp) { + if (previousAnimationFrameAt !== null) { + animationFrameIntervals.push(timestamp - previousAnimationFrameAt); + } + previousAnimationFrameAt = timestamp; + animationFrameId = requestAnimationFrame(observeAnimationFrame); +} + +animationFrameId = requestAnimationFrame(observeAnimationFrame); const [ws, isConnected, isWorkerReady, controls] = createImageDataWS( wsUrl, @@ -186,10 +198,22 @@ window.__capDisplayBenchmarkResult = () => { frameNotifications, lastRequestFrameCount, readyState: ws.readyState, + animationFrames: { + sampleCount: animationFrameIntervals.length, + averageFps: + animationFrameIntervals.length === 0 + ? 0 + : (animationFrameIntervals.length * 1000) / + animationFrameIntervals.reduce((total, interval) => total + interval, 0), + maximumIntervalMs: Math.max(0, ...animationFrameIntervals), + visibilityState: document.visibilityState, + hasFocus: document.hasFocus(), + }, }; }; window.__capDisplayBenchmarkDispose = () => { window.clearInterval(interval); + if (animationFrameId !== null) cancelAnimationFrame(animationFrameId); controls.dispose(); }; window.__capDisplayBenchmarkReady = true; @@ -294,7 +318,7 @@ function spawnRustBenchmark(options) { function spawnChrome(debuggingPort, tempDir) { const child = spawn( - CHROME_PATH, + resolveDesktopBenchmarkBrowser(), [ `--user-data-dir=${path.join(tempDir, "chrome-profile")}`, `--remote-debugging-port=${debuggingPort}`, @@ -305,6 +329,15 @@ function spawnChrome(debuggingPort, tempDir) { "--disable-extensions", "--allow-file-access-from-files", "--enable-unsafe-webgpu", + ...(process.platform === "linux" && process.getuid?.() === 0 + ? ["--no-sandbox"] + : []), + ...(process.env.CAP_DESKTOP_BENCHMARK_HEADLESS === "1" + ? ["--headless=new"] + : []), + ...(process.env.CAP_DESKTOP_BENCHMARK_SOFTWARE_GPU === "1" + ? ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"] + : []), "about:blank", ], { stdio: ["ignore", "ignore", "pipe"] }, @@ -509,6 +542,8 @@ async function main() { }); await cdp.send("Page.enable"); await cdp.send("Runtime.enable"); + await cdp.send("Page.bringToFront"); + await cdp.send("Emulation.setFocusEmulationEnabled", { enabled: true }); const loadPromise = waitForCdpEvent(cdp, "Page.loadEventFired").catch( () => null, ); diff --git a/apps/desktop/scripts/desktop-display-transport-browser.js b/apps/desktop/scripts/desktop-display-transport-browser.js new file mode 100644 index 00000000000..29f642a61b2 --- /dev/null +++ b/apps/desktop/scripts/desktop-display-transport-browser.js @@ -0,0 +1,50 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; + +export function resolveDesktopBenchmarkBrowser({ + platform = process.platform, + environment = process.env, + fileExists = existsSync, +} = {}) { + const override = environment.CAP_DESKTOP_BENCHMARK_BROWSER?.trim(); + if (override) { + if (fileExists(override)) return override; + throw new Error(`Benchmark browser does not exist: ${override}`); + } + + const candidates = []; + if (platform === "darwin") { + candidates.push( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ); + } else if (platform === "win32") { + const roots = [ + environment["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)", + environment.PROGRAMFILES ?? "C:\\Program Files", + environment.LOCALAPPDATA, + ].filter(Boolean); + for (const root of roots) { + candidates.push( + path.win32.join(root, "Google", "Chrome", "Application", "chrome.exe"), + path.win32.join(root, "Microsoft", "Edge", "Application", "msedge.exe"), + ); + } + } else if (platform === "linux") { + candidates.push( + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/microsoft-edge", + ); + } + + const browser = candidates.find(fileExists); + if (browser) return browser; + + throw new Error( + `No Chromium browser found for ${platform}; set CAP_DESKTOP_BENCHMARK_BROWSER to its executable path`, + ); +} diff --git a/apps/desktop/scripts/desktop-display-transport-browser.test.js b/apps/desktop/scripts/desktop-display-transport-browser.test.js new file mode 100644 index 00000000000..3304ee6e4f2 --- /dev/null +++ b/apps/desktop/scripts/desktop-display-transport-browser.test.js @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { resolveDesktopBenchmarkBrowser } from "./desktop-display-transport-browser.js"; + +describe("resolveDesktopBenchmarkBrowser", () => { + it("preserves the existing macOS Google Chrome selection", () => { + const chrome = + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + expect( + resolveDesktopBenchmarkBrowser({ + platform: "darwin", + environment: {}, + fileExists: (candidate) => candidate === chrome, + }), + ).toBe(chrome); + }); + + it("finds Microsoft Edge in Windows Program Files", () => { + const edge = "D:\\Programs\\Microsoft\\Edge\\Application\\msedge.exe"; + expect( + resolveDesktopBenchmarkBrowser({ + platform: "win32", + environment: { + "PROGRAMFILES(X86)": "D:\\Programs", + }, + fileExists: (candidate) => candidate === edge, + }), + ).toBe(edge); + }); + + it("finds a Linux Chromium installation", () => { + expect( + resolveDesktopBenchmarkBrowser({ + platform: "linux", + environment: {}, + fileExists: (candidate) => candidate === "/usr/bin/chromium", + }), + ).toBe("/usr/bin/chromium"); + }); + + it("uses an explicitly configured browser before platform discovery", () => { + const browser = "/opt/browser/chrome"; + expect( + resolveDesktopBenchmarkBrowser({ + platform: "linux", + environment: { CAP_DESKTOP_BENCHMARK_BROWSER: browser }, + fileExists: (candidate) => candidate === browser, + }), + ).toBe(browser); + }); + + it("rejects a missing explicitly configured browser", () => { + expect(() => + resolveDesktopBenchmarkBrowser({ + platform: "linux", + environment: { CAP_DESKTOP_BENCHMARK_BROWSER: "/missing/browser" }, + fileExists: () => false, + }), + ).toThrow("Benchmark browser does not exist: /missing/browser"); + }); + + it("explains how to configure unsupported browser installations", () => { + expect(() => + resolveDesktopBenchmarkBrowser({ + platform: "linux", + environment: {}, + fileExists: () => false, + }), + ).toThrow("set CAP_DESKTOP_BENCHMARK_BROWSER"); + }); +}); diff --git a/apps/desktop/scripts/prepare.js b/apps/desktop/scripts/prepare.js index 93b9ea0656f..66df72366f2 100644 --- a/apps/desktop/scripts/prepare.js +++ b/apps/desktop/scripts/prepare.js @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import { shouldBundleGpui } from "../../../scripts/run-gpui-build.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -47,7 +48,7 @@ async function semverToWIXCompatibleVersion(cargoFilePath) { * @param {Object} source * @returns {Object} */ -function deepMerge(target, source) { +export function deepMerge(target, source) { for (const key of Object.keys(source)) { if ( source[key] instanceof Object && @@ -71,6 +72,40 @@ export async function createTauriPlatformConfigs( configOptions = undefined, ) { const srcTauri = path.join(__dirname, "../src-tauri/"); + const profile = process.argv.includes("--release") ? "release" : "debug"; + const hostArchitecture = process.arch === "arm64" ? "aarch64" : "x86_64"; + const hostTargets = { + darwin: `${hostArchitecture}-apple-darwin`, + win32: `${hostArchitecture}-pc-windows-msvc`, + linux: `${hostArchitecture}-unknown-linux-gnu`, + }; + const target = process.env.RUST_TARGET_TRIPLE ?? hostTargets[platform]; + const extension = platform === "win32" ? ".exe" : ""; + const sidecarAvailable = target + ? await fs + .access( + path.join(srcTauri, "binaries", `cap-gpui-${target}${extension}`), + ) + .then(() => true) + .catch(() => false) + : false; + const developmentWorkspaceAvailable = await fs + .access(path.join(__dirname, "../../desktop-gpui/dev.sh")) + .then(() => true) + .catch(() => false); + const includeGpui = shouldBundleGpui( + platform, + process.env, + profile, + sidecarAvailable, + developmentWorkspaceAvailable, + ); + const externalBin = [ + "binaries/cap-muxer", + "binaries/cap-exporter", + "binaries/cap-cli", + ...(includeGpui ? ["binaries/cap-gpui"] : []), + ]; let baseConfig = {}; let configFileName = null; @@ -80,11 +115,7 @@ export async function createTauriPlatformConfigs( baseConfig = { ...baseConfig, bundle: { - externalBin: [ - "binaries/cap-muxer", - "binaries/cap-exporter", - "binaries/cap-cli", - ], + externalBin, resources: { "../../../target/ffmpeg/bin/*.dll": "./", "../../../target/native-deps/dxc/*.dll": "./", @@ -107,11 +138,7 @@ export async function createTauriPlatformConfigs( baseConfig = { ...baseConfig, bundle: { - externalBin: [ - "binaries/cap-muxer", - "binaries/cap-exporter", - "binaries/cap-cli", - ], + externalBin, resources: { "../../../target/native-deps/onnxruntime/lib/libonnxruntime.dylib": "onnxruntime/lib/libonnxruntime.dylib", @@ -120,6 +147,18 @@ export async function createTauriPlatformConfigs( }; } + if (platform === "linux") { + configFileName = "tauri.linux.conf.json"; + const existingConfig = await fs + .readFile(path.join(srcTauri, configFileName), "utf-8") + .then(JSON.parse) + .catch((error) => { + if (error.code === "ENOENT") return {}; + throw error; + }); + baseConfig = deepMerge(existingConfig, { bundle: { externalBin } }); + } + if (!configFileName) return; const mergedConfig = configOptions @@ -137,11 +176,14 @@ async function main() { console.log("--- Preparation finished"); } -main().catch((err) => { - console.error("\n--- Preparation Failed"); - console.error(err); - console.error("---"); -}); +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { + main().catch((err) => { + console.error("\n--- Preparation Failed"); + console.error(err); + console.error("---"); + process.exitCode = 1; + }); +} async function writeFileIfChanged(filePath, contents) { const currentContents = await fs diff --git a/apps/desktop/scripts/prepare.test.js b/apps/desktop/scripts/prepare.test.js new file mode 100644 index 00000000000..2ad7bfc581d --- /dev/null +++ b/apps/desktop/scripts/prepare.test.js @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +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 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, + ); + }); + + it("preserves Windows resource mappings when platform overrides are applied", () => { + const merged = deepMerge( + { + bundle: { + externalBin: ["binaries/cap-cli", "binaries/cap-gpui"], + resources: { "ffmpeg/*.dll": "./" }, + }, + }, + { bundle: { windows: { wix: { version: "0.6.0" } } } }, + ); + + expect(merged.bundle.externalBin).toContain("binaries/cap-gpui"); + expect(merged.bundle.resources).toEqual({ "ffmpeg/*.dll": "./" }); + expect(merged.bundle.windows.wix.version).toBe("0.6.0"); + }); +}); diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 953b9fc176d..8f0ebdc6042 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -153,6 +153,7 @@ parakeet-rs = "0.3.4" [target.'cfg(target_os = "linux")'.dependencies] libappindicator = "0.9.0" +notify-rust = "4.11" [target.'cfg(target_os= "windows")'.dependencies] windows = { workspace = true, features = [ diff --git a/apps/desktop/src-tauri/binaries/gpui/README.md b/apps/desktop/src-tauri/binaries/gpui/README.md deleted file mode 100644 index 691b333566b..00000000000 --- a/apps/desktop/src-tauri/binaries/gpui/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Cap GPUI staging - -Place the `cap-gpui` binary (built from `apps/desktop-gpui`) in this folder to -bundle it into the Tauri app as the `gpui/` resource dir. When present, the -Experimental settings page offers launching it; when absent, the section is -hidden. This file exists so the `binaries/gpui/*` bundle glob always matches. diff --git a/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs b/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs index 2783552ba18..47cac690d83 100644 --- a/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs +++ b/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs @@ -242,8 +242,12 @@ async fn main() { tokio::time::sleep(Duration::from_millis(startup_delay_ms)).await; let layers_rx = start_renderer_layers_creation(&render_constants, &project); + let force_ffmpeg_for_editor = cfg!(target_os = "windows") + || std::env::var_os("CAP_EDITOR_FORCE_FFMPEG_DECODER").is_some(); let segment_medias = - match cap_editor::create_segments(&recording_meta, meta.as_ref(), false).await { + match cap_editor::create_segments(&recording_meta, meta.as_ref(), force_ffmpeg_for_editor) + .await + { Ok(segments) => Arc::new(segments), Err(e) => { eprintln!("Failed to create segments: {e}"); diff --git a/apps/desktop/src-tauri/src/automation.rs b/apps/desktop/src-tauri/src/automation.rs index a05f0a030f8..abf41047e0f 100644 --- a/apps/desktop/src-tauri/src/automation.rs +++ b/apps/desktop/src-tauri/src/automation.rs @@ -4,6 +4,7 @@ use cap_automation::{ Trigger, TriggerContext, sanitize_filename_component, }; use cap_recording::sources::screen_capture::ScreenCaptureTarget; +#[cfg(not(target_os = "linux"))] use clipboard_rs::Clipboard; use clipboard_rs::common::RustImage; use serde_json::json; @@ -426,6 +427,7 @@ impl AutomationHost for DesktopAutomationHost { title_template: &str, body_template: &str, ) -> Result<(), String> { + #[cfg(not(target_os = "linux"))] use tauri_plugin_notification::NotificationExt; let enabled = crate::general_settings::GeneralSettingsStore::get(&self.app) @@ -439,6 +441,10 @@ impl AutomationHost for DesktopAutomationHost { let title = apply_body_template(&apply_filename_template(title_template, ctx), ctx); let body = apply_body_template(&apply_filename_template(body_template, ctx), ctx); + #[cfg(target_os = "linux")] + crate::notifications::show_linux_notification(&title, &body).await?; + + #[cfg(not(target_os = "linux"))] self.app .notification() .builder() diff --git a/apps/desktop/src-tauri/src/exit_shutdown.rs b/apps/desktop/src-tauri/src/exit_shutdown.rs index ea47192fa7f..f64df56f84a 100644 --- a/apps/desktop/src-tauri/src/exit_shutdown.rs +++ b/apps/desktop/src-tauri/src/exit_shutdown.rs @@ -85,18 +85,22 @@ pub(crate) enum ExitRequestDecision { AlreadyExiting, ExportActive, AllowRuntimeExit, + AllowRuntimeRestart, } pub(crate) fn handle_exit_requested( is_exiting: bool, export_active: bool, runtime_exit_requested: bool, + runtime_restart_requested: bool, prevent_exit: FPrevent, ) -> ExitRequestDecision where FPrevent: FnOnce(), { - if is_exiting && runtime_exit_requested { + if runtime_restart_requested { + ExitRequestDecision::AllowRuntimeRestart + } else if is_exiting && runtime_exit_requested { ExitRequestDecision::AllowRuntimeExit } else if export_active { prevent_exit(); diff --git a/apps/desktop/src-tauri/src/gpui_app.rs b/apps/desktop/src-tauri/src/gpui_app.rs index b5be66ddfbd..43de84e9170 100644 --- a/apps/desktop/src-tauri/src/gpui_app.rs +++ b/apps/desktop/src-tauri/src/gpui_app.rs @@ -8,8 +8,8 @@ //! relaunch. //! //! The binary is discovered in order: `CAP_GPUI_BIN` (explicit override), the -//! bundled `gpui/` resource dir (staged by the release pipeline; absent in -//! builds that don't ship it), and -- in debug builds only -- the sibling +//! installed executable directory (where Tauri bundles and signs sidecars), the +//! legacy `gpui/` resource dir, and -- in debug builds only -- the sibling //! `apps/desktop-gpui` target dir, so the toggle works from a source checkout. //! //! ## The handoff marker @@ -35,6 +35,47 @@ const BINARY_NAME: &str = "cap-gpui.exe"; #[cfg(not(windows))] const BINARY_NAME: &str = "cap-gpui"; +#[cfg(any(target_os = "macos", windows, test))] +const MAX_FORWARDED_DEEP_LINK_BYTES: usize = 1024 * 1024; + +#[cfg(any(target_os = "macos", windows, test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct GpuiForwardingEndpoint { + pid: u32, + port: u16, + secret: u64, +} + +#[cfg(any(target_os = "macos", test))] +#[derive(Default)] +pub(crate) struct StartupRedirectState(std::sync::atomic::AtomicU8); + +#[cfg(any(target_os = "macos", test))] +impl StartupRedirectState { + pub(crate) fn begin_forwarding(&self) -> bool { + self.transition(0, 1) + } + + pub(crate) fn exit_if_pending(&self) -> bool { + self.transition(0, 2) + } + + pub(crate) fn exit_after_forwarding(&self) -> bool { + self.transition(1, 2) + } + + fn transition(&self, from: u8, to: u8) -> bool { + self.0 + .compare_exchange( + from, + to, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ) + .is_ok() + } +} + fn existing_file(path: PathBuf) -> Option { path.is_file().then_some(path) } @@ -46,6 +87,13 @@ fn binary_path(app: &AppHandle) -> Option { return Some(path); } + if let Ok(executable) = std::env::current_exe() + && let Some(parent) = executable.parent() + && let Some(path) = existing_file(parent.join(BINARY_NAME)) + { + return Some(path); + } + if let Ok(resources) = app.path().resource_dir() && let Some(path) = existing_file(resources.join("gpui").join(BINARY_NAME)) { @@ -99,7 +147,6 @@ fn shared_data_dir() -> PathBuf { base } -#[cfg(unix)] fn gpui_pidfile() -> PathBuf { shared_data_dir().join("cap-gpui.pid") } @@ -172,6 +219,120 @@ fn handoff_marker() -> PathBuf { shared_data_dir().join("cap-gpui.handoff") } +fn update_handoff_marker() -> PathBuf { + shared_data_dir().join("cap-gpui.update-handoff") +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct UpdateHandoff { + pid: u32, + simulated: bool, +} + +fn parse_update_handoff(contents: &str) -> Option { + let contents = contents.trim(); + let (pid, simulated) = match contents.strip_prefix("simulate:") { + Some(pid) if cfg!(debug_assertions) => (pid, true), + Some(_) => return None, + None => (contents, false), + }; + let pid = pid.parse().ok()?; + (pid != 0).then_some(UpdateHandoff { pid, simulated }) +} + +fn take_update_handoff() -> Option { + let marker = update_handoff_marker(); + let contents = match std::fs::read_to_string(&marker) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, + Err(error) => { + warn!(%error, "could not read the Cap GPUI update hand-off"); + return None; + } + }; + + let Some(handoff) = parse_update_handoff(&contents) else { + warn!("discarding an invalid Cap GPUI update hand-off"); + let _ = std::fs::remove_file(&marker); + return None; + }; + + let expected_pid = std::fs::read_to_string(gpui_pidfile()) + .ok() + .and_then(|contents| contents.trim().parse::().ok()); + let stale = marker + .metadata() + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age > std::time::Duration::from_secs(15 * 60)); + if expected_pid.is_some_and(|pid| pid != handoff.pid) || stale { + warn!( + pid = handoff.pid, + "discarding a stale Cap GPUI update hand-off" + ); + let _ = std::fs::remove_file(&marker); + return None; + } + + match std::fs::remove_file(&marker) { + Ok(()) => { + info!( + pid = handoff.pid, + "taking ownership from Cap GPUI for an update check" + ); + Some(handoff.simulated) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + warn!(%error, "could not consume the Cap GPUI update hand-off"); + None + } + } +} + +fn show_update_page_when_ready(app: AppHandle, simulated: bool) { + tauri::async_runtime::spawn(async move { + for _ in 0..300 { + if let Some(window) = app + .get_webview_window("main") + .or_else(|| app.get_webview_window("onboarding")) + { + match window.url() { + Ok(mut url) => { + url.set_path("/update"); + url.set_query(Some(if simulated { + "source=gpui&simulateUpdate=1" + } else { + "source=gpui" + })); + if let Err(error) = window.navigate(url) { + warn!(%error, "could not open the updater after the GPUI hand-off"); + } else { + let _ = window.show(); + let _ = window.set_focus(); + } + } + Err(error) => { + warn!(%error, "could not read the window URL for the GPUI updater"); + } + } + return; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + warn!("no window became ready for the GPUI update hand-off"); + }); +} + +pub(crate) fn handle_update_handoff(app: &AppHandle) -> bool { + let Some(simulated) = take_update_handoff() else { + return false; + }; + show_update_page_when_ready(app.clone(), simulated); + true +} + /// The dev switch-back's readiness handshake (`store::classic_pending_path` /// in apps/desktop-gpui): `cap-gpui` writes this and stays on screen until the /// classic app deletes it, so a minutes-long dev rebuild never leaves the user @@ -185,30 +346,190 @@ fn classic_pending() -> PathBuf { /// kills the previous process, so a handoff that relaunched unconditionally /// would restart a session the user may be recording in. fn running_instance_pid() -> Option { + let pid = std::fs::read_to_string(gpui_pidfile()) + .ok()? + .trim() + .parse::() + .ok()?; + #[cfg(unix)] { - let pid = std::fs::read_to_string(gpui_pidfile()) - .ok()? - .trim() - .parse::() - .ok()?; let alive = std::process::Command::new("ps") .args(["-p", &pid.to_string(), "-o", "comm="]) .output() .is_ok_and(|output| { output.status.success() - && String::from_utf8_lossy(&output.stdout).contains("cap-gpui") + && is_gpui_process_image(std::path::Path::new( + String::from_utf8_lossy(&output.stdout).trim(), + )) }); alive.then_some(pid) } - #[cfg(not(unix))] + + #[cfg(windows)] { - None + let process_id = sysinfo::Pid::from_u32(pid); + let mut system = sysinfo::System::new(); + system.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[process_id]), true); + system + .process(process_id) + .and_then(sysinfo::Process::exe) + .is_some_and(is_gpui_process_image) + .then_some(pid) } } +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)) +} + +#[cfg(any(target_os = "macos", windows, test))] +fn parse_gpui_forwarding_endpoint(contents: &str) -> Option { + let mut parts = contents.trim().split(':'); + let pid = parts.next()?.parse().ok()?; + let port = parts.next()?.parse().ok()?; + let secret = u64::from_str_radix(parts.next()?, 16).ok()?; + (pid != 0 && port != 0 && parts.next().is_none()).then_some(GpuiForwardingEndpoint { + pid, + port, + secret, + }) +} + +#[cfg(any(target_os = "macos", windows, test))] +fn is_forwardable_gpui_deep_link(url: &str) -> bool { + !url.is_empty() + && url.len() <= MAX_FORWARDED_DEEP_LINK_BYTES + && reqwest::Url::parse(url) + .is_ok_and(|parsed| matches!(parsed.scheme(), "cap-desktop" | "cap")) +} + +#[cfg(any(target_os = "macos", windows, test))] +fn forwarded_gpui_argument(argument: &str) -> Option { + if is_forwardable_gpui_deep_link(argument) { + return Some(argument.to_string()); + } + + let path = match reqwest::Url::parse(argument) { + Ok(url) if url.scheme() == "file" => url.to_file_path().ok()?, + _ => PathBuf::from(argument), + }; + if !path + .extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("cap")) + { + return None; + } + + let metadata = std::fs::symlink_metadata(&path).ok()?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return None; + } + let path = path.canonicalize().ok()?; + let value = serde_json::json!({ "open_editor": { "project_path": path } }).to_string(); + let mut url = reqwest::Url::parse("cap-desktop://action").ok()?; + url.query_pairs_mut().append_pair("value", &value); + let url = url.to_string(); + is_forwardable_gpui_deep_link(&url).then_some(url) +} + +#[cfg(any(target_os = "macos", windows))] +fn forward_deep_links_to_gpui(pid: u32, args: &[String]) -> bool { + use std::io::Write; + + let urls = args + .iter() + .filter_map(|argument| forwarded_gpui_argument(argument)) + .collect::>(); + if urls.is_empty() { + return false; + } + + let endpoint_path = gpui_pidfile().with_extension("ipc"); + let endpoint = (0..40).find_map(|attempt| { + let endpoint = std::fs::read_to_string(&endpoint_path) + .ok() + .and_then(|contents| parse_gpui_forwarding_endpoint(&contents)) + .filter(|endpoint| endpoint.pid == pid); + if endpoint.is_none() && attempt < 39 { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + endpoint + }); + let Some(endpoint) = endpoint else { + warn!(pid, "could not find the Cap GPUI deep-link endpoint"); + return false; + }; + + let mut forwarded = false; + for url in urls { + let result = std::net::TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, endpoint.port)) + .and_then(|mut stream| { + stream.set_write_timeout(Some(std::time::Duration::from_secs(2)))?; + stream.write_all(&endpoint.secret.to_be_bytes())?; + stream.write_all(&(url.len() as u32).to_be_bytes())?; + stream.write_all(url.as_bytes()) + }); + match result { + Ok(()) => forwarded = true, + Err(error) => warn!(%error, "could not forward a deep link to Cap GPUI"), + } + } + + forwarded +} + +#[cfg(any(target_os = "macos", windows))] +pub(crate) fn forward_deep_links_to_active_gpui(app: &AppHandle, args: &[String]) -> bool { + let own = GeneralSettingsStore::get(app) + .ok() + .flatten() + .is_some_and(|settings| settings.enable_gpui_app); + let enabled = if own_store_is_shared(app) { + own + } else { + shared_store_flag().unwrap_or(false) + }; + if !enabled { + return false; + } + + let Some(pid) = running_instance_pid() else { + return false; + }; + if !forward_deep_links_to_gpui(pid, args) { + return false; + } + + activate_instance(pid); + true +} + #[cfg(target_os = "macos")] -fn activate_instance(pid: u32) { +pub(crate) fn forward_deep_links_to_gpui_when_ready(args: &[String]) -> Option { + if !args + .iter() + .any(|argument| forwarded_gpui_argument(argument).is_some()) + { + return None; + } + + let pid = (0..40).find_map(|attempt| { + let pid = running_instance_pid(); + if pid.is_none() && attempt < 39 { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + pid + })?; + + forward_deep_links_to_gpui(pid, args).then_some(pid) +} + +#[cfg(target_os = "macos")] +pub(crate) fn activate_instance(pid: u32) { use objc2_app_kit::{NSApplicationActivationOptions, NSRunningApplication}; if let Some(instance) = @@ -220,7 +541,61 @@ fn activate_instance(pid: u32) { } } -#[cfg(not(target_os = "macos"))] +#[cfg(windows)] +fn activate_instance(pid: u32) { + use windows::{ + Win32::{ + Foundation::{HWND, LPARAM, TRUE}, + UI::WindowsAndMessaging::{ + EnumWindows, GetWindowThreadProcessId, IsWindowVisible, SW_RESTORE, + SetForegroundWindow, ShowWindow, + }, + }, + core::BOOL, + }; + + struct Activation { + pid: u32, + visible: Option, + fallback: Option, + } + + unsafe extern "system" fn find_window(window: HWND, data: LPARAM) -> BOOL { + let activation = unsafe { &mut *(data.0 as *mut Activation) }; + let mut owner = 0; + unsafe { GetWindowThreadProcessId(window, Some(&mut owner)) }; + if owner == activation.pid { + if activation.fallback.is_none() { + activation.fallback = Some(window); + } + if activation.visible.is_none() && unsafe { IsWindowVisible(window) }.as_bool() { + activation.visible = Some(window); + } + } + TRUE + } + + let mut activation = Activation { + pid, + visible: None, + fallback: None, + }; + let _ = unsafe { + EnumWindows( + Some(find_window), + LPARAM(std::ptr::addr_of_mut!(activation) as isize), + ) + }; + + if let Some(window) = activation.visible.or(activation.fallback) { + unsafe { + let _ = ShowWindow(window, SW_RESTORE); + let _ = SetForegroundWindow(window); + } + } +} + +#[cfg(not(any(target_os = "macos", windows)))] fn activate_instance(_pid: u32) {} fn write_handoff_marker() { @@ -244,7 +619,7 @@ fn write_handoff_marker() { /// second after launch. Its output goes to a file instead of `/dev/null` /// because a handoff launch has no terminal, and a startup failure there is /// only ever diagnosable from that file. -fn spawn_detached(path: &std::path::Path) -> Result<(), String> { +fn spawn_detached(path: &std::path::Path, args: &[String]) -> Result<(), String> { use std::process::Stdio; let log_path = shared_data_dir().join("cap-gpui.log"); @@ -267,7 +642,11 @@ fn spawn_detached(path: &std::path::Path) -> Result<(), String> { }; let mut command = std::process::Command::new(path); - command.stdin(Stdio::null()).stdout(stdout).stderr(stderr); + command + .args(args) + .stdin(Stdio::null()) + .stdout(stdout) + .stderr(stderr); // Its own process group, so signals aimed at the terminal this app was // started from never reach the app that outlives it. #[cfg(unix)] @@ -309,6 +688,11 @@ pub async fn switch_to_gpui_app( "Wait for your export to finish before switching to the native app.".to_string(), ); } + if crate::upload::upload_session_active() { + return Err( + "Wait for your upload to finish before switching to the native app.".to_string(), + ); + } let path = binary_path(&app).ok_or_else(|| "Cap GPUI isn't included in this build".to_string())?; @@ -327,7 +711,7 @@ pub async fn switch_to_gpui_app( None => { write_handoff_marker(); info!(path = %path.display(), "handing off to Cap GPUI"); - if let Err(error) = spawn_detached(&path) { + if let Err(error) = spawn_detached(&path, &[]) { let _ = std::fs::remove_file(handoff_marker()); if !own_store_is_shared(&app) { write_shared_store_flag(false); @@ -345,15 +729,18 @@ pub async fn switch_to_gpui_app( /// /// `true` means the caller must exit before any window is created. pub fn redirect_at_startup_if_enabled(app: &AppHandle) -> bool { - let redirect = redirect_decision(app); + let update_handoff = handle_update_handoff(app); + let redirect = !update_handoff && redirect_decision(app); if !redirect { // Staying up IS the readiness signal the waiting `cap-gpui` needs -- // and the wait can begin while this app is ALREADY running (switching // back with both apps up), so the signal has to keep firing, not just // fire once at startup. - tauri::async_runtime::spawn(async { + let app = app.clone(); + tauri::async_runtime::spawn(async move { loop { let _ = std::fs::remove_file(classic_pending()); + handle_update_handoff(&app); tokio::time::sleep(std::time::Duration::from_secs(1)).await; } }); @@ -395,6 +782,11 @@ fn redirect_decision(app: &AppHandle) -> bool { // before proving itself, the marker survives it and the next launch heals. if let Some(pid) = running_instance_pid() { info!(pid, "Cap GPUI is already running; handing over to it"); + #[cfg(any(target_os = "macos", windows))] + { + let args = std::env::args().skip(1).collect::>(); + forward_deep_links_to_gpui(pid, &args); + } activate_instance(pid); return true; } @@ -426,10 +818,185 @@ fn redirect_decision(app: &AppHandle) -> bool { write_handoff_marker(); info!(path = %path.display(), "handing off to Cap GPUI at startup"); - if let Err(error) = spawn_detached(&path) { + #[cfg(any(target_os = "macos", windows))] + let args = std::env::args() + .skip(1) + .filter_map(|argument| forwarded_gpui_argument(&argument)) + .collect::>(); + #[cfg(not(any(target_os = "macos", windows)))] + let args = Vec::new(); + if let Err(error) = spawn_detached(&path, &args) { warn!("{error}"); let _ = std::fs::remove_file(handoff_marker()); return false; } true } + +#[cfg(test)] +mod tests { + use super::{ + BINARY_NAME, GpuiForwardingEndpoint, MAX_FORWARDED_DEEP_LINK_BYTES, StartupRedirectState, + UpdateHandoff, forwarded_gpui_argument, is_forwardable_gpui_deep_link, + is_gpui_process_image, parse_gpui_forwarding_endpoint, parse_update_handoff, + }; + + #[test] + fn startup_redirect_exits_once_when_no_open_event_arrives() { + let state = StartupRedirectState::default(); + + assert!(state.exit_if_pending()); + assert!(!state.exit_if_pending()); + assert!(!state.begin_forwarding()); + assert!(!state.exit_after_forwarding()); + } + + #[test] + fn startup_redirect_waits_for_forwarding_before_exiting_once() { + let state = StartupRedirectState::default(); + + assert!(state.begin_forwarding()); + assert!(!state.begin_forwarding()); + assert!(!state.exit_if_pending()); + assert!(state.exit_after_forwarding()); + assert!(!state.exit_after_forwarding()); + } + + #[test] + fn update_handoff_requires_a_valid_process_id() { + assert_eq!( + parse_update_handoff("1234"), + Some(UpdateHandoff { + pid: 1234, + simulated: false, + }) + ); + assert_eq!(parse_update_handoff("0"), None); + assert_eq!(parse_update_handoff("cap-gpui"), None); + assert_eq!(parse_update_handoff("simulate:0"), None); + } + + #[test] + fn simulated_update_handoffs_are_debug_only() { + let parsed = parse_update_handoff("simulate:4321"); + if cfg!(debug_assertions) { + assert_eq!( + parsed, + Some(UpdateHandoff { + pid: 4321, + simulated: true, + }) + ); + } else { + assert_eq!(parsed, None); + } + } + + #[test] + fn gpui_process_image_must_match_exactly() { + assert!(is_gpui_process_image(std::path::Path::new(BINARY_NAME))); + assert!(is_gpui_process_image(std::path::Path::new( + &BINARY_NAME.to_ascii_uppercase() + ))); + assert!(!is_gpui_process_image(std::path::Path::new("not-cap-gpui"))); + assert!(!is_gpui_process_image(std::path::Path::new( + "cap-gpui-helper" + ))); + } + + #[test] + fn gpui_forwarding_endpoint_requires_the_owner_identity() { + assert_eq!( + parse_gpui_forwarding_endpoint("4321:49152:0123456789abcdef"), + Some(GpuiForwardingEndpoint { + pid: 4321, + port: 49152, + secret: 0x0123_4567_89ab_cdef, + }) + ); + assert_eq!(parse_gpui_forwarding_endpoint("0:49152:1234"), None); + assert_eq!(parse_gpui_forwarding_endpoint("4321:0:1234"), None); + assert_eq!(parse_gpui_forwarding_endpoint("4321:49152:xyz"), None); + assert_eq!( + parse_gpui_forwarding_endpoint("4321:49152:1234:extra"), + None + ); + } + + #[test] + fn forwarded_gpui_deep_links_are_scheme_and_size_limited() { + assert!(is_forwardable_gpui_deep_link( + "cap-desktop://signin?token=test" + )); + assert!(is_forwardable_gpui_deep_link( + "cap://action?value=%22stop_recording%22" + )); + assert!(!is_forwardable_gpui_deep_link("https://cap.so/signin")); + assert!(!is_forwardable_gpui_deep_link("cap-desktop-other://signin")); + assert!(!is_forwardable_gpui_deep_link(&format!( + "cap://action?value={}", + "x".repeat(MAX_FORWARDED_DEEP_LINK_BYTES) + ))); + } + + #[test] + fn project_arguments_become_encoded_open_editor_actions() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("Recording #1 & draft.cap"); + std::fs::create_dir(&project).unwrap(); + + let forwarded = forwarded_gpui_argument(project.to_str().unwrap()).unwrap(); + let url = reqwest::Url::parse(&forwarded).unwrap(); + let value = url + .query_pairs() + .find_map(|(key, value)| (key == "value").then_some(value.into_owned())) + .unwrap(); + let action: serde_json::Value = serde_json::from_str(&value).unwrap(); + + assert_eq!(url.scheme(), "cap-desktop"); + assert_eq!(url.host_str(), Some("action")); + assert_eq!( + action["open_editor"]["project_path"], + project.canonicalize().unwrap().to_string_lossy().as_ref() + ); + } + + #[test] + fn forwarded_project_arguments_reject_missing_and_non_project_paths() { + let directory = tempfile::tempdir().unwrap(); + let ordinary = directory.path().join("notes.txt"); + std::fs::write(&ordinary, "notes").unwrap(); + + assert!(forwarded_gpui_argument(ordinary.to_str().unwrap()).is_none()); + assert!( + forwarded_gpui_argument(directory.path().join("missing.cap").to_str().unwrap()) + .is_none() + ); + assert_eq!( + forwarded_gpui_argument("cap-desktop://signin?token=secret"), + Some("cap-desktop://signin?token=secret".to_string()) + ); + } + + #[cfg(unix)] + #[test] + fn forwarded_project_arguments_reject_symlinks() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("Recording.cap"); + let symlink = directory.path().join("Shortcut.cap"); + std::fs::create_dir(&project).unwrap(); + std::os::unix::fs::symlink(&project, &symlink).unwrap(); + + assert!(forwarded_gpui_argument(symlink.to_str().unwrap()).is_none()); + } + + #[test] + fn file_urls_become_open_editor_actions() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("Recording.cap"); + std::fs::create_dir(&project).unwrap(); + let file_url = reqwest::Url::from_file_path(&project).unwrap(); + + assert!(forwarded_gpui_argument(file_url.as_str()).is_some()); + } +} diff --git a/apps/desktop/src-tauri/src/import.rs b/apps/desktop/src-tauri/src/import.rs index 94b4b89f722..3d543b216e6 100644 --- a/apps/desktop/src-tauri/src/import.rs +++ b/apps/desktop/src-tauri/src/import.rs @@ -1079,6 +1079,17 @@ fn get_audio_stream_info(input: &avformat::context::Input) -> Option<(usize, Aud Some((stream_index, audio_info)) } +fn reference_video_frame(frame: &ffmpeg::frame::Video) -> ffmpeg::frame::Video { + let mut referenced = ffmpeg::frame::Video::empty(); + let status = unsafe { ffmpeg::ffi::av_frame_ref(referenced.as_mut_ptr(), frame.as_ptr()) }; + + if status < 0 { + frame.clone() + } else { + referenced + } +} + fn transcode_video( app: &AppHandle, source_path: &Path, @@ -1237,7 +1248,7 @@ fn transcode_video( scaled_frame.set_pts(video_frame.pts()); scaled_frame } else { - video_frame.clone() + reference_video_frame(&video_frame) }; video_encoder @@ -1303,10 +1314,10 @@ fn transcode_video( scaled_frame.set_pts(video_frame.pts()); scaled_frame } else { - video_frame.clone() + reference_video_frame(&video_frame) } } else { - video_frame.clone() + reference_video_frame(&video_frame) }; video_encoder @@ -2083,6 +2094,43 @@ pub async fn check_import_ready(project_path: PathBuf) -> Result { mod tests { use super::*; + #[test] + fn imported_video_frames_share_reference_counted_pixel_storage() { + let mut source = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV420P, 16, 12); + source.set_pts(Some(417)); + source.data_mut(0)[..4].copy_from_slice(&[11, 22, 33, 44]); + let original = source.data(0).as_ptr(); + + let referenced = reference_video_frame(&source); + + assert_eq!(referenced.data(0).as_ptr(), original); + assert_eq!(referenced.format(), ffmpeg::format::Pixel::YUV420P); + assert_eq!((referenced.width(), referenced.height()), (16, 12)); + assert_eq!(referenced.pts(), Some(417)); + let buffer = unsafe { (*source.as_ptr()).buf[0] }; + assert!(!buffer.is_null()); + assert_eq!(unsafe { ffmpeg::ffi::av_buffer_get_ref_count(buffer) }, 2); + + drop(source); + + assert_eq!(&referenced.data(0)[..4], &[11, 22, 33, 44]); + let retained = unsafe { (*referenced.as_ptr()).buf[0] }; + assert_eq!(unsafe { ffmpeg::ffi::av_buffer_get_ref_count(retained) }, 1); + } + + #[test] + fn imported_video_frame_references_preserve_non_yuv_formats() { + let mut source = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, 12, 8); + source.set_pts(Some(93)); + + let referenced = reference_video_frame(&source); + + assert_eq!(referenced.data(0).as_ptr(), source.data(0).as_ptr()); + assert_eq!(referenced.format(), ffmpeg::format::Pixel::BGRA); + assert_eq!((referenced.width(), referenced.height()), (12, 8)); + assert_eq!(referenced.pts(), Some(93)); + } + #[test] fn source_asset_path_allows_file_inside_source_project() { let source_project = tempfile::tempdir().unwrap(); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 391c33e26d0..692dd7327e5 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -70,8 +70,12 @@ use cap_recording::{ sources::screen_capture::ScreenCaptureTarget, }; use cap_rendering::ProjectRecordingsMeta; +use clipboard_rs::Clipboard; +#[cfg(not(target_os = "linux"))] +use clipboard_rs::ClipboardContext; +#[cfg(target_os = "linux")] +use clipboard_rs::ClipboardContext as PlatformClipboardContext; use clipboard_rs::common::RustImage; -use clipboard_rs::{Clipboard, ClipboardContext}; use cpal::StreamError; use editor_window::{EditorInstances, PendingEditorInstances, WindowEditorInstance}; use ffmpeg::ffi::AV_TIME_BASE; @@ -107,6 +111,8 @@ use std::{ }; use tauri::Listener; use tauri::{AppHandle, Emitter, Manager, State, Window, WindowEvent, ipc::Channel}; +#[cfg(target_os = "linux")] +use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_dialog::DialogExt; use tauri_plugin_global_shortcut::GlobalShortcutExt; @@ -203,6 +209,22 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[test] + fn wayland_clipboard_fallback_requires_wayland_without_x11() { + use std::ffi::OsStr; + + assert!(uses_wayland_clipboard_fallback( + Some(OsStr::new("wayland-1")), + None + )); + assert!(!uses_wayland_clipboard_fallback( + Some(OsStr::new("wayland-1")), + Some(OsStr::new(":0")) + )); + assert!(!uses_wayland_clipboard_fallback(None, None)); + } + #[test] fn graphics_recovery_only_engages_for_gpu_init_deaths() { use crash_sentinel::UnexpectedTermination; @@ -320,23 +342,38 @@ impl CameraWindowCloseGate { } } -pub struct AppExitState(AtomicBool); +pub struct AppExitState { + exiting: AtomicBool, + restarting: AtomicBool, +} impl Default for AppExitState { fn default() -> Self { - Self(AtomicBool::new(false)) + Self { + exiting: AtomicBool::new(false), + restarting: AtomicBool::new(false), + } } } impl AppExitState { pub fn begin(&self) -> bool { - self.0 + self.exiting .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_ok() } + pub fn begin_restart(&self) { + self.restarting.store(true, Ordering::Release); + self.exiting.store(true, Ordering::Release); + } + pub fn is_exiting(&self) -> bool { - self.0.load(Ordering::Acquire) + self.exiting.load(Ordering::Acquire) + } + + pub fn is_restarting(&self) -> bool { + self.restarting.load(Ordering::Acquire) } } @@ -1774,9 +1811,6 @@ fn spawn_devices_snapshot_emitter(app_handle: AppHandle) { continue; } - // Device snapshots only feed UI pickers via DevicesUpdated, so - // polling while every window sits hidden in the tray probes the - // OS device stack and burns CPU for nobody (#2132). if !any_webview_window_visible(&app_handle) { tokio::time::sleep(std::time::Duration::from_secs(5)).await; continue; @@ -2490,6 +2524,83 @@ pub struct NewNotification { type ArcLock = Arc>; pub type MutableState<'a, T> = State<'a, Arc>>; +#[cfg(target_os = "linux")] +pub enum ClipboardContext { + Platform(PlatformClipboardContext), + Wayland(AppHandle), +} + +#[cfg(target_os = "linux")] +fn uses_wayland_clipboard_fallback( + wayland_display: Option<&std::ffi::OsStr>, + x11_display: Option<&std::ffi::OsStr>, +) -> bool { + wayland_display.is_some() && x11_display.is_none() +} + +#[cfg(target_os = "linux")] +impl ClipboardContext { + fn new(app: &AppHandle) -> clipboard_rs::common::Result { + match PlatformClipboardContext::new() { + Ok(context) => Ok(Self::Platform(context)), + Err(error) + if uses_wayland_clipboard_fallback( + std::env::var_os("WAYLAND_DISPLAY").as_deref(), + std::env::var_os("DISPLAY").as_deref(), + ) => + { + info!(%error, "Using native Wayland clipboard"); + Ok(Self::Wayland(app.clone())) + } + Err(error) => Err(error), + } + } + + fn set_text(&self, text: String) -> clipboard_rs::common::Result<()> { + match self { + Self::Platform(context) => context.set_text(text), + Self::Wayland(app) => app.clipboard().write_text(text).map_err(Into::into), + } + } + + fn set_image(&self, image: clipboard_rs::RustImageData) -> clipboard_rs::common::Result<()> { + match self { + Self::Platform(context) => context.set_image(image), + Self::Wayland(app) => { + let rgba = image.to_rgba8()?; + let width = rgba.width(); + let height = rgba.height(); + let image = tauri::image::Image::new_owned(rgba.into_raw(), width, height); + app.clipboard().write_image(&image).map_err(Into::into) + } + } + } + + fn set_files(&self, files: Vec) -> clipboard_rs::common::Result<()> { + match self { + Self::Platform(context) => context.set_files(files), + Self::Wayland(app) => { + let urls = files + .into_iter() + .map(|path| { + tauri::Url::from_file_path(&path) + .map(|url| url.to_string()) + .map_err(|()| format!("Invalid clipboard file path: {path}")) + }) + .collect::, _>>()?; + + if urls.is_empty() { + return Err("No files supplied for clipboard".into()); + } + + app.clipboard() + .write_text(urls.join("\r\n")) + .map_err(Into::into) + } + } + } +} + type SingleTuple = (T,); #[derive(Serialize, Type)] @@ -5208,7 +5319,16 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { #[cfg(not(target_os = "linux"))] { builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { - trace!("Single instance invoked with args {args:?}"); + trace!(arg_count = args.len(), "Single instance invoked"); + + if gpui_app::handle_update_handoff(app) { + return; + } + + #[cfg(any(target_os = "macos", windows))] + if gpui_app::forward_deep_links_to_active_gpui(app, &args) { + return; + } let action_urls = args .iter() @@ -5313,7 +5433,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { } specta_builder.mount_events(&app); - hotkeys::init(&app); general_settings::init(&app); // Before anything shows a window or initialises further state: when // the native app owns the session, this one only exists to start it. @@ -5325,9 +5444,28 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { exit_state.begin(); app.manage(exit_state); crash_sentinel::mark_clean_exit(); + + #[cfg(target_os = "macos")] + { + app.manage(gpui_app::StartupRedirectState::default()); + let app = app.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(750)).await; + if app + .try_state::() + .is_some_and(|state| state.exit_if_pending()) + { + app.exit(0); + } + }); + } + + #[cfg(not(target_os = "macos"))] app.exit(0); + return Ok(()); } + hotkeys::init(&app); configure_camera_blur_recovery(&app, previous_termination); fake_window::init(&app); app.manage(target_select_overlay::WindowFocusManager::default()); @@ -5468,9 +5606,13 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { install_macos_native_terminate_handler(&app); spawn_process_memory_sampler(app.clone()); - app.manage(Arc::new(RwLock::new( - ClipboardContext::new().expect("Failed to create clipboard context"), - ))); + #[cfg(target_os = "linux")] + let clipboard = ClipboardContext::new(&app) + .expect("Failed to create clipboard context"); + #[cfg(not(target_os = "linux"))] + let clipboard = ClipboardContext::new() + .expect("Failed to create clipboard context"); + app.manage(Arc::new(RwLock::new(clipboard))); } app.listen_any("main-window-ready", { @@ -6118,8 +6260,70 @@ where fn handle_run_event(_handle: &AppHandle, event: tauri::RunEvent) { match event { + #[cfg(target_os = "macos")] + tauri::RunEvent::Opened { urls } => { + let arguments = urls + .iter() + .map(|url| url.as_str().to_string()) + .collect::>(); + + if let Some(redirect) = _handle.try_state::() { + if redirect.begin_forwarding() { + let app = _handle.clone(); + tokio::spawn(async move { + let forwarded = tokio::task::spawn_blocking(move || { + gpui_app::forward_deep_links_to_gpui_when_ready(&arguments) + }) + .await + .ok() + .flatten(); + + if let Some(pid) = forwarded { + if let Err(error) = app.run_on_main_thread(move || { + gpui_app::activate_instance(pid); + }) { + warn!(%error, "Could not activate Cap GPUI after forwarding a project"); + } + } else { + warn!("Could not forward the requested project to Cap GPUI"); + } + if app + .try_state::() + .is_some_and(|state| state.exit_after_forwarding()) + { + app.exit(0); + } + }); + } + return; + } + + if gpui_app::forward_deep_links_to_active_gpui(_handle, &arguments) { + return; + } + + for url in urls { + if url.scheme() == "file" + && let Ok(path) = url.to_file_path() + && let Err(error) = open_project_from_path(&path, _handle.clone()) + { + warn!(path = %path.display(), %error, "Could not open the requested project"); + } + } + } #[cfg(target_os = "macos")] tauri::RunEvent::Reopen { .. } => { + if _handle + .try_state::() + .is_some() + { + return; + } + + if gpui_app::handle_update_handoff(_handle) { + return; + } + let should_focus_onboarding = should_show_onboarding(_handle); if should_focus_onboarding @@ -6171,12 +6375,12 @@ fn handle_run_event(_handle: &AppHandle, event: tauri::RunEvent) { _handle .try_state::() .is_some_and(|state| state.is_exiting()), - export::export_session_active(), + export::export_session_active() || upload::upload_session_active(), code.is_some(), + code == Some(tauri::RESTART_EXIT_CODE), || api.prevent_exit(), ) { ExitRequestDecision::StartCleanup => { - let _ = code; let handle = _handle.clone(); spawn_on_runtime(async move { request_app_exit(handle).await; @@ -6184,19 +6388,36 @@ fn handle_run_event(_handle: &AppHandle, event: tauri::RunEvent) { } ExitRequestDecision::AlreadyExiting => {} ExitRequestDecision::ExportActive => { - warn!("Preventing app exit request during active export"); + warn!("Preventing app exit request during an active export or upload"); } ExitRequestDecision::AllowRuntimeExit => {} + ExitRequestDecision::AllowRuntimeRestart => { + if let Some(state) = _handle.try_state::() { + state.begin_restart(); + } + crash_sentinel::mark_clean_exit(); + info!("Allowing Tauri to restart the app"); + } } } tauri::RunEvent::Exit => { #[cfg(target_os = "macos")] { // This arm runs on the AppKit main thread, so reverse the Liquid Glass - // SPI inline before the hard _exit. This is the last-chance teardown for - // terminal paths that skip cleanup_app_resources_for_exit; touching the - // NSWindow/NSView here is safe precisely because we are on main. + // SPI inline before restart or a hard _exit. This is the last-chance + // teardown for terminal paths that skip cleanup_app_resources_for_exit; + // touching the NSWindow/NSView here is safe because we are on main. let torn_down = crate::platform::teardown_all_liquid_glass_on_main(_handle); + if _handle + .try_state::() + .is_some_and(|state| state.is_restarting()) + { + info!( + windows = torn_down, + "macOS runtime exit reached; allowing Tauri to restart" + ); + return; + } warn!( windows = torn_down, "macOS runtime exit reached; tore down liquid glass, forcing process exit" @@ -6831,10 +7052,6 @@ fn show_import_error_dialog(app: &AppHandle, message: String) { .show(|_| {}); } -// Hidden webviews on Windows never see document.visibilityState change -// (tauri-apps/tauri#9524), so the frontend cannot detect hide-to-tray on its -// own; this event lets it pause polling, and only fires when the hide -// actually happened so a failed hide never pauses a visible window (#2132). pub(crate) fn hide_main_window(app: &AppHandle) { if let Some(main_window) = CapWindowId::Main.get(app) && main_window.hide().is_ok() diff --git a/apps/desktop/src-tauri/src/notifications.rs b/apps/desktop/src-tauri/src/notifications.rs index d872fd8380f..be0d16563d6 100644 --- a/apps/desktop/src-tauri/src/notifications.rs +++ b/apps/desktop/src-tauri/src/notifications.rs @@ -1,4 +1,5 @@ use crate::{AppSounds, general_settings::GeneralSettingsStore}; +#[cfg(not(target_os = "linux"))] use tauri_plugin_notification::NotificationExt; #[allow(unused)] @@ -99,6 +100,14 @@ pub fn send_notification(app: &tauri::AppHandle, notification_type: Notification let (title, body, _is_error) = notification_type.details(); + #[cfg(target_os = "linux")] + tauri::async_runtime::spawn(async move { + if let Err(error) = show_linux_notification(title, body).await { + tracing::warn!(%error, "Failed to send notification"); + } + }); + + #[cfg(not(target_os = "linux"))] app.notification() .builder() .title(title) @@ -118,3 +127,34 @@ pub fn send_notification(app: &tauri::AppHandle, notification_type: Notification AppSounds::Notification.play(); } } + +#[cfg(target_os = "linux")] +fn build_linux_notification(title: &str, body: &str) -> notify_rust::Notification { + let mut notification = notify_rust::Notification::new(); + notification.summary(title).body(body).auto_icon(); + notification +} + +#[cfg(target_os = "linux")] +pub(crate) async fn show_linux_notification(title: &str, body: &str) -> Result<(), String> { + build_linux_notification(title, body) + .show_async() + .await + .map(|_| ()) + .map_err(|error| format!("Failed to send notification: {error}")) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::build_linux_notification; + + #[test] + fn linux_notification_preserves_content_and_application_icon() { + let notification = build_linux_notification("Screenshot Saved", "Saved successfully"); + + assert_eq!(notification.summary, "Screenshot Saved"); + assert_eq!(notification.body, "Saved successfully"); + assert_eq!(notification.icon, notification.appname); + assert!(!notification.icon.is_empty()); + } +} diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 48cdd475c75..54c0d36dca2 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1154,6 +1154,14 @@ fn notify_recording_start_failed(app: &AppHandle, error: &str) { .emit(app); } +fn recording_start_mode_error(mode: RecordingMode, authenticated: bool) -> Option<&'static str> { + match mode { + RecordingMode::Instant if !authenticated => Some("Please sign in to use instant recording"), + RecordingMode::Screenshot => Some("Use take_screenshot for screenshots"), + RecordingMode::Studio | RecordingMode::Instant => None, + } +} + #[derive(Serialize, Type)] pub enum RecordingAction { Started, @@ -1515,6 +1523,17 @@ pub async fn start_recording( } } + let instant_auth = if matches!(inputs.mode, RecordingMode::Instant) { + AuthStore::get(&app).ok().flatten() + } else { + None + }; + if let Some(error) = recording_start_mode_error(inputs.mode, instant_auth.is_some()) { + state_mtx.write().await.clear_pending_recording(); + notify_recording_start_failed(&app, error); + return Err(error.to_string()); + } + macro_rules! pending_try { ($expr:expr, $map_err:expr) => { match $expr { @@ -1625,7 +1644,7 @@ pub async fn start_recording( let (video_upload_info, instant_mode_max_resolution) = match inputs.mode { RecordingMode::Instant => { - let Some(auth) = AuthStore::get(&app).ok().flatten() else { + let Some(auth) = instant_auth else { let error = "Please sign in to use instant recording".to_string(); state_mtx.write().await.clear_pending_recording(); notify_recording_start_failed(&app, &error); @@ -4238,6 +4257,30 @@ mod tests { use super::*; use tempfile::tempdir; + #[test] + fn recording_start_preflight_requires_authentication_for_instant_recordings() { + assert_eq!( + recording_start_mode_error(RecordingMode::Instant, false), + Some("Please sign in to use instant recording") + ); + assert_eq!( + recording_start_mode_error(RecordingMode::Instant, true), + None + ); + } + + #[test] + fn recording_start_preflight_preserves_studio_and_rejects_screenshot_modes() { + assert_eq!( + recording_start_mode_error(RecordingMode::Studio, false), + None + ); + assert_eq!( + recording_start_mode_error(RecordingMode::Screenshot, true), + Some("Use take_screenshot for screenshots") + ); + } + fn click_event_with_state(time_ms: f64, down: bool) -> CursorClickEvent { CursorClickEvent { active_modifiers: vec![], diff --git a/apps/desktop/src-tauri/src/thumbnails/linux.rs b/apps/desktop/src-tauri/src/thumbnails/linux.rs index df6136851ef..fbb3d7a2988 100644 --- a/apps/desktop/src-tauri/src/thumbnails/linux.rs +++ b/apps/desktop/src-tauri/src/thumbnails/linux.rs @@ -14,6 +14,10 @@ pub async fn capture_window_thumbnail(window: &scap_targets::Window) -> Option Option { + if cap_recording::screenshot::is_pure_wayland_session() { + return None; + } + let image = match capture_screenshot(target).await { Ok(image) => image, Err(error) => { diff --git a/apps/desktop/src-tauri/src/updates.rs b/apps/desktop/src-tauri/src/updates.rs index ac2e1cb37f8..6934af90570 100644 --- a/apps/desktop/src-tauri/src/updates.rs +++ b/apps/desktop/src-tauri/src/updates.rs @@ -15,6 +15,8 @@ const UPDATE_ENDPOINT: &str = const FIRST_CHECK_DELAY: Duration = Duration::from_secs(60); const CHECK_INTERVAL: Duration = Duration::from_secs(2 * 60 * 60); const BUSY_RETRY_DELAY: Duration = Duration::from_secs(5 * 60); +const UPDATE_BUSY_ERROR: &str = + "Finish your recording, export, or upload before updating or restarting Cap."; #[derive(Serialize, Deserialize, Type, Clone, Copy, PartialEq, Eq, Debug, Default)] #[serde(rename_all = "camelCase")] @@ -55,6 +57,7 @@ struct PendingUpdate { pub struct UpdatesState { pending: Mutex>, announced_version: Mutex>, + install: Mutex<()>, notify: Notify, } @@ -202,7 +205,7 @@ async fn download_with_progress(app: &AppHandle, update: &Update) -> Result bool { - if crate::export::export_session_active() { + if crate::export::export_session_active() || crate::upload::upload_session_active() { return true; } @@ -228,6 +231,11 @@ pub async fn updates_check(app: AppHandle) -> Result, #[specta::specta] pub async fn updates_download_and_install(app: AppHandle) -> Result<(), String> { let state = app.state::(); + let _install = state.install.lock().await; + + if is_busy(&app).await { + return Err(UPDATE_BUSY_ERROR.to_string()); + } let pending = match state.pending.lock().await.clone() { Some(pending) => pending, @@ -251,6 +259,10 @@ pub async fn updates_download_and_install(app: AppHandle) -> Result<(), String> let bytes = download_with_progress(&app, &pending.update).await?; + if is_busy(&app).await { + return Err(UPDATE_BUSY_ERROR.to_string()); + } + info!("Installing update {}", pending.version); pending.update.install(bytes).map_err(|e| e.to_string())?; @@ -318,6 +330,11 @@ pub fn spawn_background_loop(app: AppHandle) { } let installed = if cfg!(target_os = "macos") { + let _install = state.install.lock().await; + if is_busy(&app).await { + delay = BUSY_RETRY_DELAY; + continue; + } let already_installed = state .pending .lock() diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 8d197da43c1..95f5827eda9 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -24,7 +24,10 @@ use std::{ io, path::{Path, PathBuf}, pin::pin, - sync::{Arc, Mutex, PoisonError}, + sync::{ + Arc, Mutex, PoisonError, + atomic::{AtomicUsize, Ordering}, + }, time::Duration, }; use tauri::{AppHandle, Manager, ipc::Channel}; @@ -60,6 +63,27 @@ const NETWORK_RECOVERY_TIMEOUT: Duration = Duration::from_secs(5 * 60); const CONNECTIVITY_PROBE_INITIAL_DELAY: Duration = Duration::from_secs(2); const CONNECTIVITY_PROBE_MAX_DELAY: Duration = Duration::from_secs(30); +static ACTIVE_UPLOADS: AtomicUsize = AtomicUsize::new(0); + +struct ActiveUploadGuard<'a>(&'a AtomicUsize); + +impl<'a> ActiveUploadGuard<'a> { + fn new(active: &'a AtomicUsize) -> Self { + active.fetch_add(1, Ordering::AcqRel); + Self(active) + } +} + +impl Drop for ActiveUploadGuard<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + +pub(crate) fn upload_session_active() -> bool { + ACTIVE_UPLOADS.load(Ordering::Acquire) > 0 +} + fn is_google_drive_resumable_url(url: &str) -> bool { let Ok(url) = reqwest::Url::parse(url) else { return false; @@ -134,6 +158,7 @@ pub async fn upload_video( meta: S3VideoMeta, channel: Option>, ) -> Result { + let _active_upload = ActiveUploadGuard::new(&ACTIVE_UPLOADS); info!("Uploading video {video_id}..."); let start = Instant::now(); @@ -283,6 +308,7 @@ pub async fn upload_screenshot_bytes( video_id: Option, organization_id: Option, ) -> Result { + let _active_upload = ActiveUploadGuard::new(&ACTIVE_UPLOADS); let s3_config = create_or_get_video(app, true, video_id, None, None, organization_id).await?; let subpath = screenshot_upload_subpath(content_type); let total_size = image_bytes.len() as u64; @@ -312,6 +338,7 @@ pub async fn upload_screenshot_file( video_id: Option, organization_id: Option, ) -> Result { + let _active_upload = ActiveUploadGuard::new(&ACTIVE_UPLOADS); let content_type = screenshot_content_type_from_path(&file_path); let s3_config = create_or_get_video(app, true, video_id, None, None, organization_id).await?; let subpath = screenshot_upload_subpath(content_type); @@ -583,6 +610,7 @@ impl InstantMultipartUpload { recording_dir: PathBuf, realtime_video_done: Option>, ) -> Result, AuthedApiError> { + let _active_upload = ActiveUploadGuard::new(&ACTIVE_UPLOADS); let video_id = pre_created_video.id.clone(); debug!("Initiating multipart upload for {video_id}..."); @@ -1109,6 +1137,7 @@ impl SegmentUploader { ) -> Result { use cap_enc_ffmpeg::segmented_stream::SegmentMediaType; + let _active_upload = ActiveUploadGuard::new(&ACTIVE_UPLOADS); info!("Starting segment uploader for {video_id}"); let mut project_meta = RecordingMeta::load_for_project(&recording_dir).map_err(|err| { @@ -2551,6 +2580,34 @@ mod tests { use std::io::Write; use std::sync::atomic::{AtomicU32, Ordering}; + #[test] + fn active_upload_guards_track_overlapping_sessions() { + let active = AtomicUsize::new(0); + let first = ActiveUploadGuard::new(&active); + let second = ActiveUploadGuard::new(&active); + + assert_eq!(active.load(Ordering::Acquire), 2); + drop(first); + assert_eq!(active.load(Ordering::Acquire), 1); + drop(second); + assert_eq!(active.load(Ordering::Acquire), 0); + } + + #[test] + fn dropping_an_upload_future_releases_its_session() { + let active = AtomicUsize::new(0); + let mut upload = Box::pin(async { + let _guard = ActiveUploadGuard::new(&active); + std::future::pending::<()>().await; + }); + + let mut context = std::task::Context::from_waker(std::task::Waker::noop()); + assert!(std::future::Future::poll(upload.as_mut(), &mut context).is_pending()); + assert_eq!(active.load(Ordering::Acquire), 1); + drop(upload); + assert_eq!(active.load(Ordering::Acquire), 0); + } + #[test] fn screenshot_upload_subpath_matches_content_type() { assert_eq!( diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 542a0e4bb81..f31590d46a3 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -57,7 +57,6 @@ "icons/icon.ico" ], "resources": { - "binaries/gpui/*": "gpui/", "assets/backgrounds/macOS/*": "assets/backgrounds/macOS/", "assets/backgrounds/blue/*": "assets/backgrounds/blue/", "assets/backgrounds/cities/*": "assets/backgrounds/cities/", @@ -101,8 +100,12 @@ "libayatana-appindicator3-1", "libva2", "libva-drm2", + "pulseaudio-utils", "libpipewire-0.3-0", - "libasound2t64 | libasound2" + "libasound2t64 | libasound2", + "libxkbcommon0", + "libxkbcommon-x11-0", + "libssl3t64 | libssl3" ], "files": { "/usr/share/icons/hicolor/scalable/status/so.cap.desktop-tray-default-symbolic.svg": "icons/linux/so.cap.desktop-tray-default-symbolic.svg", diff --git a/apps/desktop/src-tauri/tests/exit_shutdown.rs b/apps/desktop/src-tauri/tests/exit_shutdown.rs index 7d38e14ffa4..962d62e7da9 100644 --- a/apps/desktop/src-tauri/tests/exit_shutdown.rs +++ b/apps/desktop/src-tauri/tests/exit_shutdown.rs @@ -210,7 +210,7 @@ fn exit_requested_prevents_user_exit_when_already_exiting() { let prevented = Arc::new(AtomicBool::new(false)); let prevented_flag = prevented.clone(); - let decision = handle_exit_requested(true, false, false, move || { + let decision = handle_exit_requested(true, false, false, false, move || { prevented_flag.store(true, Ordering::Release); }); @@ -223,7 +223,7 @@ fn exit_requested_allows_runtime_exit_when_already_exiting() { let prevented = Arc::new(AtomicBool::new(false)); let prevented_flag = prevented.clone(); - let decision = handle_exit_requested(true, false, true, move || { + let decision = handle_exit_requested(true, false, true, false, move || { prevented_flag.store(true, Ordering::Release); }); @@ -236,7 +236,7 @@ fn exit_requested_allows_runtime_exit_when_export_cancel_is_draining() { let prevented = Arc::new(AtomicBool::new(false)); let prevented_flag = prevented.clone(); - let decision = handle_exit_requested(true, true, true, move || { + let decision = handle_exit_requested(true, true, true, false, move || { prevented_flag.store(true, Ordering::Release); }); @@ -249,10 +249,36 @@ fn exit_requested_prevents_runtime_exit_during_export() { let prevented = Arc::new(AtomicBool::new(false)); let prevented_flag = prevented.clone(); - let decision = handle_exit_requested(false, true, true, move || { + let decision = handle_exit_requested(false, true, true, false, move || { prevented_flag.store(true, Ordering::Release); }); assert_eq!(decision, ExitRequestDecision::ExportActive); assert!(prevented.load(Ordering::Acquire)); } + +#[test] +fn exit_requested_allows_runtime_restart_without_starting_cleanup() { + let prevented = Arc::new(AtomicBool::new(false)); + let prevented_flag = prevented.clone(); + + let decision = handle_exit_requested(false, false, true, true, move || { + prevented_flag.store(true, Ordering::Release); + }); + + assert_eq!(decision, ExitRequestDecision::AllowRuntimeRestart); + assert!(!prevented.load(Ordering::Acquire)); +} + +#[test] +fn exit_requested_allows_unpreventable_runtime_restart_during_export() { + let prevented = Arc::new(AtomicBool::new(false)); + let prevented_flag = prevented.clone(); + + let decision = handle_exit_requested(false, true, true, true, move || { + prevented_flag.store(true, Ordering::Release); + }); + + assert_eq!(decision, ExitRequestDecision::AllowRuntimeRestart); + assert!(!prevented.load(Ordering::Acquire)); +} diff --git a/apps/desktop/src/app.tsx b/apps/desktop/src/app.tsx index 7dc46a86a00..44ed6b9725d 100644 --- a/apps/desktop/src/app.tsx +++ b/apps/desktop/src/app.tsx @@ -293,12 +293,6 @@ function prewarmFontCaches() { else setTimeout(warm, 250); } -// Hidden Tauri windows never flip document.visibilityState on Windows -// (tauri-apps/tauri#9524), so TanStack keeps every refetchInterval firing -// while the app idles in the tray (#2132). Pause queries when the backend -// hides the window; on focus, hand control back to TanStack's own -// visibilitychange detection (setFocused(undefined)) so platforms where it -// works, like macOS minimize, keep pausing natively. function createHiddenWindowQueryPause(currentWindow: WebviewWindow) { if (currentWindow.label !== "main") return; @@ -314,12 +308,7 @@ function createHiddenWindowQueryPause(currentWindow: WebviewWindow) { focusManager.setFocused(undefined); return; } - // Safety net for hide paths that bypass hide_main_window and - // hideCurrentWindow: a blur with the window no longer visible - // means hidden, not just unfocused. Not sufficient alone — an - // earlier benign blur (e.g. shell.open) masks a later hide. The - // generation guard stops a stale visibility result from pausing a - // window that regained focus while the check was in flight. + const generation = focusGeneration; void currentWindow.isVisible().then((visible) => { if (visible || generation !== focusGeneration) return; diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index d9376fe3731..7e71765ed91 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -19,7 +19,6 @@ import { PhysicalPosition, } from "@tauri-apps/api/window"; import * as dialog from "@tauri-apps/plugin-dialog"; -import { relaunch } from "@tauri-apps/plugin-process"; import * as shell from "@tauri-apps/plugin-shell"; import { cx } from "cva"; import { @@ -87,6 +86,7 @@ import { type UploadProgress, } from "~/utils/tauri"; import { openTeleprompter } from "~/utils/teleprompter"; +import { restartAfterUpdate } from "~/utils/updater"; import IconCapLogoFull from "~icons/cap/logo-full"; import IconCapLogoFullDark from "~icons/cap/logo-full-dark"; import IconLucideAppWindowMac from "~icons/lucide/app-window-mac"; @@ -1742,6 +1742,7 @@ export default function () { } let hasChecked = false; +const [installingUpdate, setInstallingUpdate] = createSignal(false); function createUpdateCheck() { if (import.meta.env.DEV) return; @@ -1800,17 +1801,21 @@ function createUpdateReadyToast() {
+
+ + + No update available + !updateError() && + !update.loading && ( +
+ No update available + + + +
) } keyed @@ -59,30 +107,57 @@ export default function () { const [updateStatus, setUpdateStatus] = createSignal(); - const unlisten = events.updateDownloadProgress.listen((e) => { - if (updateStatus()?.type === "done") return; + if (simulatedUpdate) { + let progress = 0; setUpdateStatus({ type: "downloading", - progress: e.payload.downloaded, - contentLength: e.payload.total ?? undefined, + progress, + contentLength: 100, }); - }); - onCleanup(() => { - unlisten.then((cleanup) => cleanup()); - }); - - commands - .updatesDownloadAndInstall() - .then(() => { - setUpdateStatus({ type: "done" }); - getCurrentWindow().requestUserAttention( - UserAttentionType.Informational, - ); - }) - .catch((e) => { - console.error("Failed to download/install update:", e); - setUpdateError("Failed to download or install the update."); + const interval = window.setInterval(() => { + progress = Math.min(progress + 4, 100); + if (progress === 100) { + window.clearInterval(interval); + setUpdateStatus({ type: "done" }); + return; + } + setUpdateStatus({ + type: "downloading", + progress, + contentLength: 100, + }); + }, 120); + onCleanup(() => window.clearInterval(interval)); + } else { + const unlisten = events.updateDownloadProgress.listen((e) => { + if (updateStatus()?.type === "done") return; + setUpdateStatus({ + type: "downloading", + progress: e.payload.downloaded, + contentLength: e.payload.total ?? undefined, + }); }); + onCleanup(() => { + unlisten.then((cleanup) => cleanup()); + }); + + commands + .updatesDownloadAndInstall() + .then(() => { + setUpdateStatus({ type: "done" }); + getCurrentWindow().requestUserAttention( + UserAttentionType.Informational, + ); + }) + .catch((e) => { + console.error("Failed to download/install update:", e); + setUpdateError( + typeof e === "string" + ? e + : "Failed to download or install the update.", + ); + }); + } return (
@@ -96,7 +171,7 @@ export default function () {

Update has been installed. Restart Cap to finish updating.

- +
({ + arch: vi.fn(() => "aarch64"), + osType: vi.fn(() => "macos"), + relaunch: vi.fn(async () => undefined), + switchToGpuiApp: vi.fn(async () => undefined), + updatesDownloadAndInstall: vi.fn(async () => undefined), +})); + +vi.mock("@tauri-apps/plugin-os", () => ({ + arch: mocks.arch, + type: mocks.osType, +})); + +vi.mock("@tauri-apps/plugin-process", () => ({ + relaunch: mocks.relaunch, +})); + +vi.mock("~/utils/tauri", () => ({ + commands: { + switchToGpuiApp: mocks.switchToGpuiApp, + updatesDownloadAndInstall: mocks.updatesDownloadAndInstall, + }, +})); + +describe("updater", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.arch.mockReturnValue("aarch64"); + mocks.osType.mockReturnValue("macos"); + mocks.updatesDownloadAndInstall.mockResolvedValue(undefined); + mocks.switchToGpuiApp.mockResolvedValue(undefined); + mocks.relaunch.mockResolvedValue(undefined); + }); + + it.each([ + { os: "macos", arch: "aarch64", target: "darwin-aarch64" }, + { os: "linux", arch: "x86_64", target: "linux-x86_64-deb" }, + { os: "windows", arch: "x86", target: "windows-i686" }, + ])("uses the expected updater target for $os on $arch", async (platform) => { + mocks.arch.mockReturnValue(platform.arch); + mocks.osType.mockReturnValue(platform.os); + const { getUpdaterCheckOptions } = await import("./updater"); + + expect(getUpdaterCheckOptions()).toEqual({ target: platform.target }); + }); + + it("checks update safety before requesting an unpreventable restart", async () => { + const { restartAfterUpdate } = await import("./updater"); + + await restartAfterUpdate(); + + expect(mocks.updatesDownloadAndInstall).toHaveBeenCalledOnce(); + expect(mocks.relaunch).toHaveBeenCalledOnce(); + expect( + mocks.updatesDownloadAndInstall.mock.invocationCallOrder[0], + ).toBeLessThan(mocks.relaunch.mock.invocationCallOrder[0]); + }); + + it("does not restart while recording, exporting, or uploading is blocked", async () => { + const error = new Error("Finish your recording, export, or upload first."); + mocks.updatesDownloadAndInstall.mockRejectedValueOnce(error); + const { restartAfterUpdate } = await import("./updater"); + + await expect(restartAfterUpdate()).rejects.toBe(error); + expect(mocks.relaunch).not.toHaveBeenCalled(); + }); + + it("propagates a restart failure after a successful safety check", async () => { + const error = new Error("Restart failed"); + mocks.relaunch.mockRejectedValueOnce(error); + const { restartAfterUpdate } = await import("./updater"); + + await expect(restartAfterUpdate()).rejects.toBe(error); + expect(mocks.updatesDownloadAndInstall).toHaveBeenCalledOnce(); + }); + + it("returns to GPUI through the guarded application handoff", async () => { + const { returnToGpui } = await import("./updater"); + + await returnToGpui(); + + expect(mocks.switchToGpuiApp).toHaveBeenCalledOnce(); + expect(mocks.relaunch).not.toHaveBeenCalled(); + }); + + it("does not return to GPUI while protected work is active", async () => { + const error = new Error("Wait for your upload to finish."); + mocks.switchToGpuiApp.mockRejectedValueOnce(error); + const { returnToGpui } = await import("./updater"); + + await expect(returnToGpui()).rejects.toBe(error); + expect(mocks.relaunch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/utils/updater.ts b/apps/desktop/src/utils/updater.ts index cf0f2a75aad..5ad1b835dbc 100644 --- a/apps/desktop/src/utils/updater.ts +++ b/apps/desktop/src/utils/updater.ts @@ -1,5 +1,7 @@ import { arch, type as ostype } from "@tauri-apps/plugin-os"; +import { relaunch } from "@tauri-apps/plugin-process"; import type { CheckOptions } from "@tauri-apps/plugin-updater"; +import { commands } from "~/utils/tauri"; function updaterArch() { const currentArch = arch(); @@ -19,3 +21,12 @@ function updaterTarget() { export function getUpdaterCheckOptions(): CheckOptions { return { target: updaterTarget() }; } + +export async function restartAfterUpdate(): Promise { + await commands.updatesDownloadAndInstall(); + await relaunch(); +} + +export async function returnToGpui(): Promise { + await commands.switchToGpuiApp(); +} diff --git a/crates/audio/src/audio_data.rs b/crates/audio/src/audio_data.rs index ae8a2f65c6c..490188e6894 100644 --- a/crates/audio/src/audio_data.rs +++ b/crates/audio/src/audio_data.rs @@ -4,7 +4,7 @@ use ffmpeg::{ frame::Audio as FFAudio, software::resampling, }; -use std::path::Path; +use std::{ops::Range, path::Path}; use crate::cast_bytes_to_f32_slice; @@ -12,6 +12,8 @@ use crate::cast_bytes_to_f32_slice; pub struct AudioData { samples: Vec, channels: u16, + source_start_sample: usize, + covered_source_end_sample: usize, } impl AudioData { @@ -20,78 +22,185 @@ impl AudioData { pub const SAMPLE_RATE: u32 = 48_000; pub fn from_file(path: impl AsRef) -> Result { - fn inner(path: &Path) -> Result { - let mut input_ctx = - ffmpeg::format::input(&path).map_err(|e| format!("Input Open / {e}"))?; - let input_stream = input_ctx - .streams() - .best(ffmpeg::media::Type::Audio) - .ok_or_else(|| "No Stream".to_string())?; - - let decoder_ctx = avcodec::Context::from_parameters(input_stream.parameters()) - .map_err(|e| format!("AudioData Parameters / {e}"))?; - let mut decoder = decoder_ctx - .decoder() - .audio() - .map_err(|e| format!("Set Parameters / {e}"))?; - - let source_channels = decoder.channels().max(1); - if decoder.channel_layout().is_empty() { - decoder.set_channel_layout(ChannelLayout::default(source_channels as i32)); + Self::decode(path.as_ref(), None, true) + } + + pub fn from_file_range( + path: impl AsRef, + source_start_sample: usize, + source_end_sample: usize, + ) -> Result { + Self::decode( + path.as_ref(), + Some(source_start_sample..source_end_sample.max(source_start_sample)), + true, + ) + } + + fn decode(path: &Path, range: Option>, allow_seek: bool) -> Result { + let mut input_ctx = + ffmpeg::format::input(&path).map_err(|e| format!("Input Open / {e}"))?; + let input_stream = input_ctx + .streams() + .best(ffmpeg::media::Type::Audio) + .ok_or_else(|| "No Stream".to_string())?; + + let decoder_ctx = avcodec::Context::from_parameters(input_stream.parameters()) + .map_err(|e| format!("AudioData Parameters / {e}"))?; + let mut decoder = decoder_ctx + .decoder() + .audio() + .map_err(|e| format!("Set Parameters / {e}"))?; + + let source_channels = decoder.channels().max(1); + if decoder.channel_layout().is_empty() { + decoder.set_channel_layout(ChannelLayout::default(source_channels as i32)); + } + let stream_time_base = input_stream.time_base(); + decoder.set_packet_time_base(stream_time_base); + + let target_channels = target_channels_for_source(source_channels); + let target_channel_layout = ChannelLayout::default(target_channels as i32); + let mut options = ffmpeg::Dictionary::new(); + options.set("filter_size", "128"); + options.set("cutoff", "0.97"); + + let mut resampler = resampling::Context::get_with( + decoder.format(), + decoder.channel_layout(), + decoder.rate(), + AudioData::SAMPLE_FORMAT, + target_channel_layout, + AudioData::SAMPLE_RATE, + options, + ) + .map_err(|e| format!("Resampler / {e}"))?; + + let index = input_stream.index(); + let stream_start_time = input_stream.start_time(); + let stream_start_time = if stream_start_time == i64::MIN { + 0 + } else { + stream_start_time + }; + let stream_time_base = f64::from(stream_time_base); + let source_start_sample = range.as_ref().map_or(0, |window| window.start); + let covered_source_end_sample = range.as_ref().map_or(usize::MAX, |window| window.end); + let mut sought = false; + + if allow_seek && source_start_sample > AudioData::SAMPLE_RATE as usize * 2 { + let seek_sample = + source_start_sample.saturating_sub(AudioData::SAMPLE_RATE as usize * 2); + let seek_seconds = seek_sample as f64 / AudioData::SAMPLE_RATE as f64 + + stream_start_time as f64 * stream_time_base; + let seek_timestamp = (seek_seconds * 1_000_000.0).round() as i64; + sought = input_ctx.seek(seek_timestamp, ..seek_timestamp).is_ok(); + } + + let mut decoded_frame = ffmpeg::frame::Audio::empty(); + let mut samples = Vec::new(); + let mut resampled_samples = Vec::new(); + let mut next_source_sample = if sought { None } else { Some(0usize) }; + let mut complete = source_start_sample == covered_source_end_sample; + + 'packets: for (stream, packet) in input_ctx.packets() { + if complete { + break; + } + if stream.index() != index { + continue; } - decoder.set_packet_time_base(input_stream.time_base()); - - let target_channels = target_channels_for_source(source_channels); - let target_channel_layout = ChannelLayout::default(target_channels as i32); - let mut options = ffmpeg::Dictionary::new(); - options.set("filter_size", "128"); - options.set("cutoff", "0.97"); - - let mut resampler = resampling::Context::get_with( - decoder.format(), - decoder.channel_layout(), - decoder.rate(), - AudioData::SAMPLE_FORMAT, - target_channel_layout, - AudioData::SAMPLE_RATE, - options, - ) - .map_err(|e| format!("Resampler / {e}"))?; - - let index = input_stream.index(); - - let mut decoded_frame = ffmpeg::frame::Audio::empty(); - let mut samples: Vec = vec![]; - - for (stream, packet) in input_ctx.packets() { - if stream.index() != index { + + decoder + .send_packet(&packet) + .map_err(|e| format!("Send Packet / {e}"))?; + + while decoder.receive_frame(&mut decoded_frame).is_ok() { + if range.is_none() { + run_resampler(&mut resampler, &decoded_frame, &mut samples)?; continue; } - decoder - .send_packet(&packet) - .map_err(|e| format!("Send Packet / {e}"))?; + if next_source_sample.is_none() { + let Some(timestamp) = decoded_frame.timestamp().or_else(|| decoded_frame.pts()) + else { + return Self::decode(path, range, false); + }; + let position = ((timestamp.saturating_sub(stream_start_time)) as f64 + * stream_time_base + * AudioData::SAMPLE_RATE as f64) + .round() + .max(0.0) as usize; + if position > source_start_sample { + return Self::decode(path, range, false); + } + next_source_sample = Some(position); + } - while decoder.receive_frame(&mut decoded_frame).is_ok() { - run_resampler(&mut resampler, &decoded_frame, &mut samples)?; + resampled_samples.clear(); + run_resampler(&mut resampler, &decoded_frame, &mut resampled_samples)?; + complete = append_sample_window( + &mut samples, + &resampled_samples, + &mut next_source_sample, + target_channels, + source_start_sample, + covered_source_end_sample, + ); + if complete { + break 'packets; } } + } + if !complete { decoder.send_eof().map_err(|e| format!("Send EOF / {e}"))?; while decoder.receive_frame(&mut decoded_frame).is_ok() { - run_resampler(&mut resampler, &decoded_frame, &mut samples)?; - } + if range.is_none() { + run_resampler(&mut resampler, &decoded_frame, &mut samples)?; + continue; + } - flush_resampler(&mut resampler, &mut samples)?; + resampled_samples.clear(); + run_resampler(&mut resampler, &decoded_frame, &mut resampled_samples)?; + complete = append_sample_window( + &mut samples, + &resampled_samples, + &mut next_source_sample, + target_channels, + source_start_sample, + covered_source_end_sample, + ); + if complete { + break; + } + } - Ok(AudioData { - samples, - channels: target_channels, - }) + if !complete { + if range.is_some() { + resampled_samples.clear(); + flush_resampler(&mut resampler, &mut resampled_samples)?; + append_sample_window( + &mut samples, + &resampled_samples, + &mut next_source_sample, + target_channels, + source_start_sample, + covered_source_end_sample, + ); + } else { + flush_resampler(&mut resampler, &mut samples)?; + } + } } - inner(path.as_ref()) + Ok(AudioData { + samples, + channels: target_channels, + source_start_sample, + covered_source_end_sample, + }) } pub fn channels(&self) -> u16 { @@ -106,12 +215,54 @@ impl AudioData { self.samples.len() / self.channels as usize } + pub fn source_start_sample(&self) -> usize { + self.source_start_sample + } + + pub fn covers_source_range( + &self, + source_start_sample: usize, + source_end_sample: usize, + ) -> bool { + source_start_sample >= self.source_start_sample + && source_end_sample <= self.covered_source_end_sample + } + #[cfg(test)] pub(crate) fn from_raw_f32(samples: Vec, channels: u16) -> Self { - Self { samples, channels } + Self { + samples, + channels, + source_start_sample: 0, + covered_source_end_sample: usize::MAX, + } } } +fn append_sample_window( + samples: &mut Vec, + resampled: &[f32], + next_source_sample: &mut Option, + channels: u16, + source_start_sample: usize, + covered_source_end_sample: usize, +) -> bool { + let channels = channels as usize; + let frame_start = next_source_sample.unwrap_or(0); + let frame_end = frame_start.saturating_add(resampled.len() / channels); + let overlap_start = frame_start.max(source_start_sample); + let overlap_end = frame_end.min(covered_source_end_sample); + + if overlap_start < overlap_end { + let start = (overlap_start - frame_start) * channels; + let end = (overlap_end - frame_start) * channels; + samples.extend_from_slice(&resampled[start..end]); + } + + *next_source_sample = Some(frame_end); + frame_end >= covered_source_end_sample +} + fn target_channels_for_source(channels: u16) -> u16 { if channels <= 1 { 1 } else { 2 } } @@ -306,4 +457,78 @@ mod tests { "centre/rear-only surround downmix collapsed to silence (channel truncation?)" ); } + + #[test] + fn from_file_range_seeks_to_requested_stereo_samples() { + let _ = ffmpeg::init(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("stereo_steps.wav"); + let frames = AudioData::SAMPLE_RATE as usize * 8; + write_pcm_wav(&path, AudioData::SAMPLE_RATE, frames, &[0, 0]); + + let mut bytes = std::fs::read(&path).unwrap(); + for (index, frame) in bytes[44..].chunks_exact_mut(4).enumerate() { + let sample = ((index / AudioData::SAMPLE_RATE as usize + 1) * 2_000) as i16; + frame[..2].copy_from_slice(&sample.to_le_bytes()); + frame[2..].copy_from_slice(&(-sample).to_le_bytes()); + } + std::fs::write(&path, bytes).unwrap(); + + let full = AudioData::from_file(&path).unwrap(); + let start = AudioData::SAMPLE_RATE as usize * 5 + 137; + let end = start + AudioData::SAMPLE_RATE as usize * 2 + 219; + let range = AudioData::from_file_range(&path, start, end).unwrap(); + + assert_eq!(range.channels(), 2); + assert_eq!(range.source_start_sample(), start); + assert_eq!(range.sample_count(), end - start); + assert_eq!(range.samples(), &full.samples()[start * 2..end * 2]); + assert!(range.covers_source_range(start, end)); + assert!(!range.covers_source_range(start.saturating_sub(1), end)); + assert!(!range.covers_source_range(start, end.saturating_add(1))); + } + + #[test] + fn from_file_range_resamples_and_clamps_at_end_of_file() { + let _ = ffmpeg::init(); + let dir = tempfile::tempdir().unwrap(); + + for (sample_rate, channels) in [(16_000u32, 1usize), (44_100, 2), (96_000, 1)] { + let path = dir + .path() + .join(format!("range_{sample_rate}_{channels}.wav")); + write_pcm_wav( + &path, + sample_rate, + sample_rate as usize * 6, + &vec![7_000; channels], + ); + + let start = AudioData::SAMPLE_RATE as usize * 4 + 333; + let end = AudioData::SAMPLE_RATE as usize * 8; + let full = AudioData::from_file(&path).unwrap(); + let range = AudioData::from_file_range(&path, start, end).unwrap(); + + assert_eq!(range.channels(), channels as u16); + assert_eq!(range.source_start_sample(), start); + assert!(range.sample_count().abs_diff(full.sample_count() - start) <= 4); + assert!(range.covers_source_range(start, end)); + assert!(rms(range.samples()) > 0.01); + } + } + + #[test] + fn from_file_range_supports_empty_windows() { + let _ = ffmpeg::init(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("empty_range.wav"); + write_pcm_wav(&path, AudioData::SAMPLE_RATE, 4_800, &[2_000]); + + let data = AudioData::from_file_range(&path, 1_000, 1_000).unwrap(); + + assert_eq!(data.channels(), 1); + assert_eq!(data.source_start_sample(), 1_000); + assert_eq!(data.sample_count(), 0); + assert!(data.covers_source_range(1_000, 1_000)); + } } diff --git a/crates/camera-effects/src/segmentation.rs b/crates/camera-effects/src/segmentation.rs index 5305ef95898..cf88201c71b 100644 --- a/crates/camera-effects/src/segmentation.rs +++ b/crates/camera-effects/src/segmentation.rs @@ -1,6 +1,6 @@ use anyhow::Context; use ort::session::Session; -use ort::value::Value; +use ort::value::TensorRef; #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] use std::path::PathBuf; @@ -13,34 +13,31 @@ const ORT_LIBRARY_NAME: &str = "onnxruntime.dll"; const MODEL_BYTES: &[u8] = include_bytes!("../assets/selfie_segmentation.onnx"); const MODEL_INPUT_SIZE: usize = 256; +const MODEL_CHANNEL_SIZE: usize = MODEL_INPUT_SIZE * MODEL_INPUT_SIZE; pub struct SegmentationModel { session: Session, + input: Vec, + output: Vec, } impl SegmentationModel { pub fn new() -> anyhow::Result { let session = create_session()?; - Ok(Self { session }) + Ok(Self { + session, + input: vec![0.0; 3 * MODEL_CHANNEL_SIZE], + output: Vec::with_capacity(MODEL_CHANNEL_SIZE), + }) } - pub fn run_inference(&mut self, rgba_256x256: &[u8]) -> anyhow::Result> { - let channel_size = MODEL_INPUT_SIZE * MODEL_INPUT_SIZE; - let mut flat = vec![0.0f32; 3 * channel_size]; - - let (r_plane, rest) = flat.split_at_mut(channel_size); - let (g_plane, b_plane) = rest.split_at_mut(channel_size); - - for i in 0..channel_size { - let px = i * 4; - r_plane[i] = rgba_256x256[px] as f32 / 255.0; - g_plane[i] = rgba_256x256[px + 1] as f32 / 255.0; - b_plane[i] = rgba_256x256[px + 2] as f32 / 255.0; - } - - let shape: Vec = vec![1, 3, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE]; - let input_value = Value::from_array((shape, flat.into_boxed_slice())) - .context("Failed to create input tensor")?; + pub fn run_inference(&mut self, rgba_256x256: &[u8]) -> anyhow::Result<&[f32]> { + populate_rgb_planes(&mut self.input, rgba_256x256); + let input_value = TensorRef::from_array_view(( + [1usize, 3, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE], + self.input.as_slice(), + )) + .context("Failed to create input tensor")?; let outputs = self .session @@ -52,14 +49,32 @@ impl SegmentationModel { .try_extract_tensor::() .context("Failed to extract output tensor")?; - Ok(raw_data.to_vec()) + self.output.clear(); + self.output.extend_from_slice(raw_data); + Ok(&self.output) + } +} + +fn populate_rgb_planes(input: &mut [f32], rgba: &[u8]) { + let (red, rest) = input.split_at_mut(MODEL_CHANNEL_SIZE); + let (green, blue) = rest.split_at_mut(MODEL_CHANNEL_SIZE); + + for (index, pixel) in rgba.chunks_exact(4).take(MODEL_CHANNEL_SIZE).enumerate() { + red[index] = f32::from(pixel[0]) / 255.0; + green[index] = f32::from(pixel[1]) / 255.0; + blue[index] = f32::from(pixel[2]) / 255.0; } } fn create_session() -> anyhow::Result { init_runtime()?; - let mut builder = Session::builder().context("Failed to create ONNX session builder")?; + let mut builder = Session::builder() + .context("Failed to create ONNX session builder")? + .with_intra_op_spinning(false) + .map_err(|error| anyhow::anyhow!("Failed to disable ONNX intra-op spinning: {error}"))? + .with_inter_op_spinning(false) + .map_err(|error| anyhow::anyhow!("Failed to disable ONNX inter-op spinning: {error}"))?; #[cfg(target_os = "macos")] { @@ -194,3 +209,46 @@ fn try_register_directml( } } } + +#[cfg(test)] +mod tests { + use super::{MODEL_CHANNEL_SIZE, populate_rgb_planes}; + + #[test] + fn rgba_pixels_are_written_to_normalized_rgb_planes() { + let mut input = vec![f32::NAN; 3 * MODEL_CHANNEL_SIZE]; + let mut rgba = vec![0; 4 * MODEL_CHANNEL_SIZE]; + rgba[..8].copy_from_slice(&[255, 128, 64, 17, 32, 16, 8, 222]); + let last_pixel = rgba.len() - 4; + rgba[last_pixel..].copy_from_slice(&[10, 20, 30, 40]); + + populate_rgb_planes(&mut input, &rgba); + + assert_eq!(input[0], 1.0); + assert_eq!(input[1], 32.0 / 255.0); + assert_eq!(input[MODEL_CHANNEL_SIZE], 128.0 / 255.0); + assert_eq!(input[MODEL_CHANNEL_SIZE + 1], 16.0 / 255.0); + assert_eq!(input[2 * MODEL_CHANNEL_SIZE], 64.0 / 255.0); + assert_eq!(input[2 * MODEL_CHANNEL_SIZE + 1], 8.0 / 255.0); + assert_eq!(input[MODEL_CHANNEL_SIZE - 1], 10.0 / 255.0); + assert_eq!(input[2 * MODEL_CHANNEL_SIZE - 1], 20.0 / 255.0); + assert_eq!(input[3 * MODEL_CHANNEL_SIZE - 1], 30.0 / 255.0); + } + + #[test] + fn rgba_planes_are_overwritten_without_reallocating() { + let mut input = vec![0.0; 3 * MODEL_CHANNEL_SIZE]; + let mut rgba = vec![0; 4 * MODEL_CHANNEL_SIZE]; + let pointer = input.as_ptr(); + rgba[..4].copy_from_slice(&[255, 0, 0, 255]); + + populate_rgb_planes(&mut input, &rgba); + rgba[..4].copy_from_slice(&[0, 255, 128, 0]); + populate_rgb_planes(&mut input, &rgba); + + assert_eq!(input.as_ptr(), pointer); + assert_eq!(input[0], 0.0); + assert_eq!(input[MODEL_CHANNEL_SIZE], 1.0); + assert_eq!(input[2 * MODEL_CHANNEL_SIZE], 128.0 / 255.0); + } +} diff --git a/crates/editor/src/audio.rs b/crates/editor/src/audio.rs index 9a96ba92c58..0a8ecdca8e4 100644 --- a/crates/editor/src/audio.rs +++ b/crates/editor/src/audio.rs @@ -281,21 +281,19 @@ impl AudioRenderer { samples: self.playhead_to_samples(cursor.segment_time), }; + self.render_segment_chunk( + project, + TimelineSource { + source_time: cursor.segment_time, + segment_index: cursor.segment_index, + segment: cursor.segment, + }, + chunk_samples, + written * 2, + &mut ret, + ); if cursor.segment.timescale == 1.0 { - self.render_current_chunk(project, chunk_samples, written * 2, &mut ret); self.cursor.samples += chunk_samples; - } else { - self.render_speed_audio_chunk( - project, - TimelineSource { - source_time: cursor.segment_time, - segment_index: cursor.segment_index, - segment: cursor.segment, - }, - chunk_samples, - written * 2, - &mut ret, - ); } self.elapsed_samples += chunk_samples; @@ -943,7 +941,7 @@ fn mix_transition_audio( } /// Below this volume a music track is treated as silent and skipped entirely. -const MUSIC_SILENCE_DB: f32 = -60.0; +pub(crate) const MUSIC_SILENCE_DB: f32 = -60.0; fn music_gain(volume_db: f32) -> f32 { if volume_db <= MUSIC_SILENCE_DB { @@ -1012,7 +1010,7 @@ fn mix_music( for out_sample in lo..hi { let local = out_sample - start_sample; - let src_index = trim_sample + local; + let src_index = trim_sample + local - data.source_start_sample() as i64; if src_index < 0 || src_index >= src_frames { continue; } @@ -2134,14 +2132,109 @@ mod tests { #[test] fn one_x_audio_bypasses_speed_processing() { let (_dir, mut renderer, mut project) = build_renderer_fixture(); - project.timeline.as_mut().unwrap().segments[0].speed_audio_mode = - Some(ClipSpeedAudioMode::MaintainPitch); + for mode in [ + None, + Some(ClipSpeedAudioMode::MaintainPitch), + Some(ClipSpeedAudioMode::MatchSpeed), + ] { + project.timeline.as_mut().unwrap().segments[0].speed_audio_mode = mode; + renderer.set_playhead(0.0, &project); + let (_, samples) = renderer.render_frame_raw(4_800, &project).unwrap(); - renderer.set_playhead(0.0, &project); - let (_, samples) = renderer.render_frame_raw(4_800, &project).unwrap(); + assert!(mean_abs(&samples) > 0.01); + assert!(renderer.speed_audio_processors.iter().all(Option::is_none)); + } + } - assert!(mean_abs(&samples) > 0.01); + #[test] + fn one_x_split_clip_mute_is_local_in_playback_and_export() { + let (_dir, mut renderer, mut project) = single_clip_fixture( + &[4000, 8000, 12000], + vec![ + segment(0, 0.0, 1.0, 1.0), + segment(0, 1.0, 2.0, 1.0), + segment(0, 2.0, 3.0, 1.0), + ], + ); + project.timeline.as_mut().unwrap().segments[1].speed_audio_mode = + Some(ClipSpeedAudioMode::Mute); + + let samples_per_second = AudioData::SAMPLE_RATE as usize * 2; + let export_stream = render_export_audio(&mut renderer, &project, 30, 90); + assert_eq!(export_stream.len(), samples_per_second * 3); + assert!((left_at_second(&export_stream, 0) - expected(4000)).abs() < 0.001); + assert!( + export_stream[samples_per_second..samples_per_second * 2] + .iter() + .all(|sample| *sample == 0.0) + ); + assert!((left_at_second(&export_stream, 2) - expected(12000)).abs() < 0.001); assert!(renderer.speed_audio_processors.iter().all(Option::is_none)); + + for duration_secs in [3.0, 3600.0] { + let mut playback = PrerenderedAudioBuffer::::new( + renderer.data.clone(), + MusicTracks::new(), + &project, + AudioRenderer::info(), + duration_secs, + 0.0, + ); + playback.wait_until_fully_rendered(); + let mut playback_stream = vec![0.0; export_stream.len()]; + for block in playback_stream.chunks_mut(1024) { + playback.fill(block); + } + for (index, (playback_sample, export_sample)) in + playback_stream.iter().zip(&export_stream).enumerate() + { + assert!( + (playback_sample - export_sample).abs() < 0.000_001, + "duration {duration_secs}, sample {index}: playback {playback_sample}, export {export_sample}" + ); + } + + playback.set_playhead(1.5); + let mut seek_samples = [1.0; 1024]; + playback.fill(&mut seek_samples); + assert!(seek_samples.iter().all(|sample| *sample == 0.0)); + } + + project.timeline.as_mut().unwrap().segments[1].speed_audio_mode = None; + renderer.set_playhead(1.5, &project); + let (_, samples) = renderer.render_frame_raw(1024, &project).unwrap(); + assert!((samples[0] - expected(8000)).abs() < 0.001); + } + + #[test] + fn one_x_clip_mute_cuts_and_resumes_inside_a_single_request() { + let (_dir, mut renderer, mut project) = single_clip_fixture( + &[4000, 8000, 12000], + vec![ + segment(0, 0.0, 1.0, 1.0), + segment(0, 1.0, 2.0, 1.0), + segment(0, 2.0, 3.0, 1.0), + ], + ); + project.timeline.as_mut().unwrap().segments[1].speed_audio_mode = + Some(ClipSpeedAudioMode::Mute); + + for (playhead, before, after) in [(0.99, 4000, 0), (1.99, 0, 12000)] { + renderer.set_playhead(playhead, &project); + let (written, samples) = renderer.render_frame_raw(1920, &project).unwrap(); + assert_eq!(written, 1920); + let boundary = (0.01 * AudioData::SAMPLE_RATE as f64).round() as usize * 2; + assert!( + samples[..boundary] + .iter() + .all(|sample| (*sample - expected(before)).abs() < 0.001) + ); + assert!( + samples[boundary..] + .iter() + .all(|sample| (*sample - expected(after)).abs() < 0.001) + ); + } } /// One clip per second `section_values`, on a timeline made of `segments`. @@ -2321,6 +2414,22 @@ mod tests { assert!((midpoint - expected_midpoint).abs() < 0.01); } + #[test] + fn one_x_clip_mute_preserves_the_other_side_of_a_transition() { + for muted_index in [0, 1] { + let (_dir, mut renderer, mut project) = + transition_fixture(ClipTransitionType::CrossFade); + project.timeline.as_mut().unwrap().segments[muted_index].speed_audio_mode = + Some(ClipSpeedAudioMode::Mute); + let stream = render_export_audio(&mut renderer, &project, 30, 45); + let audible_value = if muted_index == 0 { 16000 } else { 8000 }; + let midpoint = expected(audible_value) * std::f32::consts::FRAC_1_SQRT_2; + assert!((left_at_time(&stream, 0.75) - midpoint).abs() < 0.001); + let muted_time = if muted_index == 0 { 0.25 } else { 1.25 }; + assert_eq!(left_at_time(&stream, muted_time), 0.0); + } + } + #[test] fn fade_through_black_audio_reaches_silence_at_midpoint() { let (_dir, mut renderer, project) = @@ -2625,6 +2734,28 @@ mod tests { assert!((left_at_time(&stream, 0.5) - expected(8000)).abs() < 0.02); } + #[test] + fn one_x_clip_mute_keeps_timeline_music_audible() { + let (dir, mut renderer, mut project) = + single_clip_fixture(&[16000], vec![segment(0, 0.0, 1.0, 1.0)]); + let music_path = dir.path().join("music.wav"); + write_step_wav(&music_path, &[4000]); + let mut music = MusicTracks::new(); + music.insert( + "music.wav".to_string(), + Arc::new(AudioData::from_file(&music_path).unwrap()), + ); + renderer = renderer.with_music(music); + let timeline = project.timeline.as_mut().unwrap(); + timeline.segments[0].speed_audio_mode = Some(ClipSpeedAudioMode::Mute); + timeline + .audio_segments + .push(music_track_segment("music.wav", 0.0, 1.0, 0.0, 0.0)); + + let stream = render_export_audio(&mut renderer, &project, 30, 30); + assert!((left_at_time(&stream, 0.5) - expected(4000)).abs() < 0.001); + } + // A timeline-positioned music clip only sounds inside its [start, end) window. #[test] fn timeline_music_respects_start_offset() { @@ -2678,4 +2809,80 @@ mod tests { assert!((left_at_second(&stream, 1) - full * 0.75).abs() < 0.03); assert!((left_at_second(&stream, 2) - full).abs() < 0.03); } + + #[test] + fn bounded_timeline_music_preserves_trimmed_source_and_cache_coverage() { + let _ = ffmpeg::init(); + let dir = tempfile::tempdir().unwrap(); + let music_path = dir.path().join("music.wav"); + let values = [2_000, 4_000, 6_000, 8_000, 10_000, 12_000]; + write_step_wav(&music_path, &values); + + let mut segment = music_track_segment("music.wav", 0.0, 2.0, 0.0, 0.0); + segment.trim_start = 3.0; + let mut project = music_project(vec![segment]); + let mut cache = MusicTracks::new(); + let music = crate::load_music_tracks(&project, dir.path(), &mut cache); + let original = music.get("music.wav").unwrap(); + + assert_eq!( + original.source_start_sample(), + AudioData::SAMPLE_RATE as usize * 3 + ); + assert_eq!(original.sample_count(), AudioData::SAMPLE_RATE as usize * 2); + + let repeated = crate::load_music_tracks(&project, dir.path(), &mut cache); + assert!(Arc::ptr_eq(original, repeated.get("music.wav").unwrap())); + + let mut renderer = AudioRenderer::new(vec![]).with_music(music.clone()); + let stream = render_export_audio(&mut renderer, &project, 30, 2 * 30); + assert!((left_at_second(&stream, 0) - expected(values[3])).abs() < 0.02); + assert!((left_at_second(&stream, 1) - expected(values[4])).abs() < 0.02); + + project.timeline.as_mut().unwrap().audio_segments[0].trim_start = 1.0; + let updated = crate::load_music_tracks(&project, dir.path(), &mut cache); + let replacement = updated.get("music.wav").unwrap(); + + assert!(!Arc::ptr_eq(original, replacement)); + assert_eq!( + replacement.source_start_sample(), + AudioData::SAMPLE_RATE as usize + ); + + let mut renderer = AudioRenderer::new(vec![]).with_music(updated); + let stream = render_export_audio(&mut renderer, &project, 30, 2 * 30); + assert!((left_at_second(&stream, 0) - expected(values[1])).abs() < 0.02); + assert!((left_at_second(&stream, 1) - expected(values[2])).abs() < 0.02); + } + + #[test] + fn bounded_timeline_music_unions_segments_and_skips_inaudible_tracks() { + let _ = ffmpeg::init(); + let dir = tempfile::tempdir().unwrap(); + let music_path = dir.path().join("music.wav"); + write_step_wav(&music_path, &[2_000, 4_000, 6_000, 8_000, 10_000, 12_000]); + + let mut early = music_track_segment("music.wav", 0.0, 1.0, 0.0, 0.0); + early.trim_start = 1.0; + let mut late = music_track_segment("music.wav", 1.0, 2.0, 0.0, 0.0); + late.trim_start = 4.0; + let mut disabled = music_track_segment("missing-disabled.wav", 0.0, 1.0, 0.0, 0.0); + disabled.enabled = false; + let mut muted = music_track_segment("missing-muted.wav", 0.0, 1.0, 0.0, 0.0); + muted.volume_db = -60.0; + + let project = music_project(vec![early, late, disabled, muted]); + let mut cache = MusicTracks::new(); + let tracks = crate::load_music_tracks(&project, dir.path(), &mut cache); + let music = tracks.get("music.wav").unwrap(); + + assert_eq!(tracks.len(), 1); + assert_eq!(music.source_start_sample(), AudioData::SAMPLE_RATE as usize); + assert_eq!(music.sample_count(), AudioData::SAMPLE_RATE as usize * 4); + + let mut renderer = AudioRenderer::new(vec![]).with_music(tracks); + let stream = render_export_audio(&mut renderer, &project, 30, 2 * 30); + assert!((left_at_second(&stream, 0) - expected(4_000)).abs() < 0.02); + assert!((left_at_second(&stream, 1) - expected(10_000)).abs() < 0.02); + } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 4c3d2a936e9..60e094e7d82 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -85,7 +85,7 @@ pub fn start_renderer_layers_creation( let constants = render_constants.clone(); let use_svg = project.cursor.use_svg; let cursor_type = project.cursor.cursor_type().clone(); - std::thread::Builder::new() + if let Err(error) = std::thread::Builder::new() .name("renderer-layers-init".into()) .spawn(move || { let mut layers = RendererLayers::new_with_options( @@ -96,7 +96,9 @@ pub fn start_renderer_layers_creation( layers.preload_cursor_assets(&constants, use_svg, &cursor_type); let _ = layers_tx.send(layers); }) - .expect("failed to spawn renderer layers init thread"); + { + tracing::warn!(%error, "renderer layer initialization thread unavailable; initializing inline"); + } layers_rx } @@ -193,7 +195,7 @@ impl Renderer { let mut layers = match layers_rx.await { Ok(layers) => layers, Err(_) => { - tracing::error!("Failed to receive pre-created renderer layers, creating inline"); + tracing::warn!("Failed to receive pre-created renderer layers, creating inline"); let mut layers = RendererLayers::new_with_options( &render_constants.device, &render_constants.queue, diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 9980e1597a5..e75b7cd4e9a 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -176,6 +176,48 @@ impl EditorInstance { shared_device: Option, frame_format: editor::EditorFrameFormat, audio_output: Arc, + ) -> Result, String> { + Self::new_inner( + project_path, + on_state_change, + frame_cb, + shared_device, + frame_format, + audio_output, + None, + ) + .await + } + + pub async fn new_with_preloaded_recordings( + project_path: PathBuf, + on_state_change: impl Fn(&EditorState) + Send + Sync + 'static, + frame_cb: editor::EditorFrameCallback, + shared_device: Option, + frame_format: editor::EditorFrameFormat, + audio_output: Arc, + recordings: Arc, + ) -> Result, String> { + Self::new_inner( + project_path, + on_state_change, + frame_cb, + shared_device, + frame_format, + audio_output, + Some(recordings), + ) + .await + } + + async fn new_inner( + project_path: PathBuf, + on_state_change: impl Fn(&EditorState) + Send + Sync + 'static, + frame_cb: editor::EditorFrameCallback, + shared_device: Option, + frame_format: editor::EditorFrameFormat, + audio_output: Arc, + preloaded_recordings: Option>, ) -> Result, String> { if !project_path.exists() { return Err(format!("Video path {} not found!", project_path.display())); @@ -347,10 +389,30 @@ impl EditorInstance { audio_output.prewarm(); } - let recordings = Arc::new(ProjectRecordingsMeta::new( - &recording_meta.project_path, - meta.as_ref(), - )?); + let music_cache = Arc::new(std::sync::Mutex::new(crate::MusicTracks::new())); + if has_music { + let project = project.clone(); + let project_path = project_path.clone(); + let cache = Arc::clone(&music_cache); + tokio::task::spawn_blocking(move || { + let mut cache = cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + drop(crate::load_music_tracks( + &project, + &project_path, + &mut cache, + )); + }); + } + + let recordings = match preloaded_recordings { + Some(recordings) => recordings, + None => Arc::new(ProjectRecordingsMeta::new( + &recording_meta.project_path, + meta.as_ref(), + )?), + }; let render_constants = if let Some(shared) = shared_device { let rc = RenderVideoConstants::new_with_device( @@ -403,7 +465,7 @@ impl EditorInstance { preview_tx, project_config: watch::channel(project), segment_medias: Arc::new(segments), - music_cache: Arc::new(std::sync::Mutex::new(crate::MusicTracks::new())), + music_cache, meta: recording_meta, playback_active: playback_active_tx, playback_active_rx, diff --git a/crates/editor/src/segments.rs b/crates/editor/src/segments.rs index a89eb29800f..7d68551147e 100644 --- a/crates/editor/src/segments.rs +++ b/crates/editor/src/segments.rs @@ -1,4 +1,4 @@ -use std::{path::Path, sync::Arc}; +use std::{collections::HashMap, path::Path, sync::Arc}; use cap_audio::AudioData; use cap_project::ProjectConfiguration; @@ -6,7 +6,7 @@ use tracing::warn; use crate::{ SegmentMedia, - audio::{AudioSegment, AudioSegmentTrack, MusicTracks}, + audio::{AudioSegment, AudioSegmentTrack, MUSIC_SILENCE_DB, MusicTracks}, }; fn resolve_music_path(project_path: &Path, path: &str) -> std::path::PathBuf { @@ -34,22 +34,47 @@ pub fn load_music_tracks( return result; }; + let mut ranges: HashMap<&str, (usize, usize)> = HashMap::new(); + let sample_rate = AudioData::SAMPLE_RATE as f64; + for segment in &timeline.audio_segments { - if result.contains_key(&segment.path) { + if !segment.enabled || segment.end <= segment.start || segment.volume_db <= MUSIC_SILENCE_DB + { + continue; + } + + let trim_start = (segment.trim_start.max(0.0) * sample_rate).round() as usize; + let start = (segment.start * sample_rate).round() as i64; + let end = (segment.end * sample_rate).round() as i64; + let duration = end.saturating_sub(start).max(0) as usize; + if duration == 0 { continue; } - if let Some(data) = cache.get(&segment.path) { - result.insert(segment.path.clone(), Arc::clone(data)); + let trim_end = trim_start.saturating_add(duration); + ranges + .entry(segment.path.as_str()) + .and_modify(|(source_start, source_end)| { + *source_start = (*source_start).min(trim_start); + *source_end = (*source_end).max(trim_end); + }) + .or_insert((trim_start, trim_end)); + } + + for (path, (source_start, source_end)) in ranges { + if let Some(data) = cache.get(path) + && data.covers_source_range(source_start, source_end) + { + result.insert(path.to_string(), Arc::clone(data)); continue; } - let resolved = resolve_music_path(project_path, &segment.path); - match AudioData::from_file(&resolved) { + let resolved = resolve_music_path(project_path, path); + match AudioData::from_file_range(&resolved, source_start, source_end) { Ok(data) => { let data = Arc::new(data); - cache.insert(segment.path.clone(), Arc::clone(&data)); - result.insert(segment.path.clone(), data); + cache.insert(path.to_string(), Arc::clone(&data)); + result.insert(path.to_string(), data); } Err(error) => { warn!( diff --git a/crates/enc-ffmpeg/src/video/h264.rs b/crates/enc-ffmpeg/src/video/h264.rs index a06b6f9186e..fd2cb1abd89 100644 --- a/crates/enc-ffmpeg/src/video/h264.rs +++ b/crates/enc-ffmpeg/src/video/h264.rs @@ -1077,6 +1077,15 @@ fn requires_software_encoder(config: &VideoInfo, preset: H264Preset, is_export: false } +#[cfg(target_os = "linux")] +fn linux_encoder_priority(nvidia_device_available: bool) -> &'static [&'static str] { + if nvidia_device_available { + &["h264_nvenc", "libx264"] + } else { + &["libx264"] + } +} + fn get_default_encoder_priority(_config: &VideoInfo) -> &'static [&'static str] { #[cfg(target_os = "macos")] { @@ -1108,7 +1117,12 @@ fn get_default_encoder_priority(_config: &VideoInfo) -> &'static [&'static str] } } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(target_os = "linux")] + { + linux_encoder_priority(std::path::Path::new("/dev/nvidiactl").exists()) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { &["libx264"] } @@ -1313,14 +1327,14 @@ fn get_codec_and_options( /// candidate is rejected and encoder selection falls through to the next one /// (terminating at libx264, which never takes this path). /// -/// Windows-only: that is where the multi-vendor encoder/driver matrix -/// (nvenc/amf/qsv/mf) lives and where zeroed-output reports come from. -/// VideoToolbox is a single-vendor OS stack, and measured session creation -/// alone costs ~1.6s — too much to add to recording start for a failure mode -/// never observed there. Results are cached per everything that shapes the -/// encode path (encoder, resolution, input pixel format, frame rate, bitrate -/// inputs, and the full option set) so the cost is one-time per process, and -/// `CAP_DISABLE_ENCODER_SELF_TEST=1` bypasses the check as an escape hatch. +/// The Windows multi-vendor encoder/driver matrix (nvenc/amf/qsv/mf) and +/// Linux NVIDIA encoders are preflight-tested. VideoToolbox is a single-vendor +/// OS stack, and measured session creation alone costs ~1.6s — too much to +/// add to recording start for a failure mode never observed there. Results +/// are cached per everything that shapes the encode path (encoder, resolution, +/// input pixel format, frame rate, bitrate inputs, and the full option set) +/// so the cost is one-time per process, and `CAP_DISABLE_ENCODER_SELF_TEST=1` +/// bypasses the check as an escape hatch. fn cached_hardware_self_test( codec: Codec, encoder_options: &Dictionary<'static>, @@ -1336,7 +1350,8 @@ fn cached_hardware_self_test( sync::{Mutex, OnceLock}, }; - if !cfg!(target_os = "windows") { + if !(cfg!(target_os = "windows") || (cfg!(target_os = "linux") && codec.name() == "h264_nvenc")) + { return Ok(()); } @@ -1631,4 +1646,77 @@ mod self_test_tests { hardware_encoder_self_test(codec, options, &config, 160, 120, 0.3, None) .expect("healthy encoder passes the round trip"); } + + #[cfg(target_os = "linux")] + #[test] + fn linux_nvidia_device_prefers_nvenc_and_keeps_software_fallback() { + assert_eq!(linux_encoder_priority(true), &["h264_nvenc", "libx264"]); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_without_nvidia_preserves_software_encoder() { + assert_eq!(linux_encoder_priority(false), &["libx264"]); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_high_throughput_preserves_software_encoder() { + let config = VideoInfo::from_raw(cap_media_info::RawVideoFormat::Bgra, 160, 120, 30); + + assert_eq!( + get_encoder_priority_with_override( + &config, + H264Preset::HighThroughput, + Some(linux_encoder_priority(true)), + false, + ), + &["libx264"], + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_crf_preserves_software_encoder() { + ffmpeg::init().ok(); + let config = VideoInfo::from_raw(cap_media_info::RawVideoFormat::Bgra, 160, 120, 30); + let codecs = get_codec_and_options( + &config, + H264Preset::Ultrafast, + Some(linux_encoder_priority(true)), + true, + Some(23), + ); + + assert_eq!(codecs.len(), 1); + assert_eq!(codecs[0].0.name(), "libx264"); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_nvenc_self_test_decodes_real_gray_frames_when_available() { + if !std::path::Path::new("/dev/nvidiactl").exists() { + return; + } + + ffmpeg::init().ok(); + let Some(codec) = encoder::find_by_name("h264_nvenc") else { + return; + }; + let config = VideoInfo::from_raw(cap_media_info::RawVideoFormat::Bgra, 160, 120, 30); + let options = get_codec_and_options( + &config, + H264Preset::Ultrafast, + Some(&["h264_nvenc", "libx264"]), + false, + None, + ) + .into_iter() + .find(|(candidate, _)| candidate.name() == "h264_nvenc") + .expect("available NVENC encoder is configured") + .1; + + cached_hardware_self_test(codec, &options, &config, 160, 120, 0.3, None) + .expect("real NVIDIA hardware preserves neutral gray through encode and decode"); + } } diff --git a/crates/frame-converter/build.rs b/crates/frame-converter/build.rs index 2f12dbceec9..843655dd54e 100644 --- a/crates/frame-converter/build.rs +++ b/crates/frame-converter/build.rs @@ -1,6 +1,5 @@ fn main() { - #[cfg(target_os = "macos")] - { + if std::env::var("CARGO_CFG_TARGET_OS").is_ok_and(|target| target == "macos") { println!("cargo:rustc-link-lib=framework=VideoToolbox"); println!("cargo:rustc-link-lib=framework=CoreVideo"); println!("cargo:rustc-link-lib=framework=CoreFoundation"); diff --git a/crates/recording/src/capture_pipeline.rs b/crates/recording/src/capture_pipeline.rs index 9355b084f57..35151b2c66a 100644 --- a/crates/recording/src/capture_pipeline.rs +++ b/crates/recording/src/capture_pipeline.rs @@ -386,6 +386,7 @@ impl MakeCapturePipeline for screen_capture::X11Capture { }, output_size, shared_pause_state, + segment_tx: None, }) .await } @@ -395,7 +396,7 @@ impl MakeCapturePipeline for screen_capture::X11Capture { segments_dir: PathBuf, output_size: (u32, u32), start_time: Timestamps, - _segment_tx: Option>, + segment_tx: Option>, ) -> anyhow::Result { OutputPipeline::builder(segments_dir) .with_video::(screen_capture) @@ -405,6 +406,7 @@ impl MakeCapturePipeline for screen_capture::X11Capture { preset: H264Preset::Ultrafast, output_size: Some(output_size), shared_pause_state: None, + segment_tx, }) .await } diff --git a/crates/recording/src/feeds/microphone.rs b/crates/recording/src/feeds/microphone.rs index 6144e50df09..88af297b302 100644 --- a/crates/recording/src/feeds/microphone.rs +++ b/crates/recording/src/feeds/microphone.rs @@ -984,7 +984,11 @@ fn stream_config_with_latency( device_name: Option<&str>, ) -> (cpal::StreamConfig, Option) { let mut stream_config: cpal::StreamConfig = config.clone().into(); - let buffer_size_frames = desired_buffer_size_frames(config, device_name); + let buffer_size_frames = if uses_default_microphone_buffer(device_name) { + None + } else { + desired_buffer_size_frames(config, device_name) + }; if let Some(frames) = buffer_size_frames { stream_config.buffer_size = BufferSize::Fixed(frames); @@ -993,6 +997,15 @@ fn stream_config_with_latency( (stream_config, buffer_size_frames) } +fn uses_default_microphone_buffer(device_name: Option<&str>) -> bool { + cfg!(target_os = "linux") + && device_name.is_some_and(|name| { + ["default", "pulse", "pipewire"] + .iter() + .any(|backend| name.eq_ignore_ascii_case(backend)) + }) +} + fn desired_buffer_size_frames( config: &SupportedStreamConfig, device_name: Option<&str>, @@ -1689,6 +1702,17 @@ mod tests { ); } + #[test] + fn virtual_linux_microphones_use_the_backend_buffer_size() { + let linux = cfg!(target_os = "linux"); + + assert_eq!(uses_default_microphone_buffer(Some("default")), linux); + assert_eq!(uses_default_microphone_buffer(Some("PULSE")), linux); + assert_eq!(uses_default_microphone_buffer(Some("PipeWire")), linux); + assert!(!uses_default_microphone_buffer(Some("USB Microphone"))); + assert!(!uses_default_microphone_buffer(None)); + } + #[test] fn sample_rate_observation_keeps_configured_rate_for_small_jitter() { assert_eq!( diff --git a/crates/recording/src/output_pipeline/core.rs b/crates/recording/src/output_pipeline/core.rs index 91355fbf657..b02ec139641 100644 --- a/crates/recording/src/output_pipeline/core.rs +++ b/crates/recording/src/output_pipeline/core.rs @@ -2048,6 +2048,15 @@ fn estimate_video_frame_duration_ns(video_info: &VideoInfo) -> u64 { 1_000_000_000 / fps as u64 } +fn static_video_tail_timestamp( + last_timestamp: Duration, + stopped_at: Duration, + frame_duration: Duration, +) -> Option { + let final_timestamp = stopped_at.saturating_sub(frame_duration); + (final_timestamp > last_timestamp.saturating_add(frame_duration)).then_some(final_timestamp) +} + /// Span of the video timestamps actually sent to the muxer, used to report /// the real encoded media duration. Capture is VFR (static screens, dropped /// frames), so `frame_count / fps` under-reports the duration by the length @@ -2131,6 +2140,7 @@ fn spawn_video_encoder, TVideo: V let mut drift_tracker = VideoDriftTracker::new(); let mut source_clock = SourceClockState::new("video"); let mut dropped_during_pause: u64 = 0; + let mut last_frame = None; let res = stop_token .run_until_cancelled(async { @@ -2224,9 +2234,11 @@ fn spawn_video_encoder, TVideo: V ); } + let duplicate = frame.duplicate(); if let Err(e) = muxer.lock().await.send_video_frame(frame, duration) { return Err(video_mux_send_error(frame_count, e)); } + last_frame = duplicate; } info!("mux-video stream ended (rx closed)"); @@ -2235,6 +2247,10 @@ fn spawn_video_encoder, TVideo: V .await; let was_cancelled = res.is_none(); + let stopped_at = timestamps + .instant() + .elapsed() + .saturating_sub(shared_pause.total_pause_duration()); if was_cancelled { info!("mux-video cancelled, draining remaining frames from channel"); @@ -2309,8 +2325,9 @@ fn spawn_video_encoder, TVideo: V drift_tracker.calculate_timestamp(raw_duration, wall_clock_elapsed); timestamp_span.record(duration); + let duplicate = frame.duplicate(); match muxer.lock().await.send_video_frame(frame, duration) { - Ok(()) => {} + Ok(()) => last_frame = duplicate, Err(e) => { warn!("Error processing drained frame: {e}"); skipped += 1; @@ -2343,6 +2360,39 @@ fn spawn_video_encoder, TVideo: V let final_pause_duration = shared_pause.total_pause_duration(); + if was_cancelled + && !shared_pause.check().0 + && let Some(mut frame) = last_frame + && let Some((_, last_timestamp)) = timestamp_span.get() + && let Some(final_timestamp) = static_video_tail_timestamp( + last_timestamp, + stopped_at, + Duration::from_nanos(frame_duration_ns), + ) + { + let penultimate_timestamp = + final_timestamp.saturating_sub(Duration::from_nanos(frame_duration_ns)); + if penultimate_timestamp > last_timestamp + && let Some(final_frame) = frame.duplicate() + { + muxer + .lock() + .await + .send_video_frame(frame, penultimate_timestamp) + .map_err(|error| video_mux_send_error(frame_count + 1, error))?; + timestamp_span.record(penultimate_timestamp); + frame_count += 1; + frame = final_frame; + } + muxer + .lock() + .await + .send_video_frame(frame, final_timestamp) + .map_err(|error| video_mux_send_error(frame_count + 1, error))?; + timestamp_span.record(final_timestamp); + frame_count += 1; + } + if dropped_during_pause > 0 { debug!( dropped_during_pause, @@ -3383,6 +3433,13 @@ pub trait AudioSource: Send + 'static { pub trait VideoFrame: Send + 'static { fn timestamp(&self) -> Timestamp; + + fn duplicate(&self) -> Option + where + Self: Sized, + { + None + } } pub trait Muxer: Send + 'static { @@ -5044,6 +5101,107 @@ mod tests { } } + mod static_video_finalization { + use super::*; + + #[derive(Clone, Copy)] + struct StaticFrame { + timestamp: Timestamp, + } + + impl VideoFrame for StaticFrame { + fn timestamp(&self) -> Timestamp { + self.timestamp + } + + fn duplicate(&self) -> Option { + Some(*self) + } + } + + struct ObservedMuxer { + timestamps: Arc>>, + } + + impl Muxer for ObservedMuxer { + type Config = Arc>>; + + async fn setup( + timestamps: Self::Config, + _output_path: PathBuf, + _video_config: Option, + _audio_config: Option, + _pause_flag: Arc, + _tasks: &mut TaskPool, + ) -> anyhow::Result { + Ok(Self { timestamps }) + } + + fn finish(&mut self, _timestamp: Duration) -> anyhow::Result> { + Ok(Ok(())) + } + } + + impl VideoMuxer for ObservedMuxer { + type VideoFrame = StaticFrame; + + fn send_video_frame( + &mut self, + _frame: Self::VideoFrame, + timestamp: Duration, + ) -> anyhow::Result<()> { + self.timestamps.lock().unwrap().push(timestamp); + Ok(()) + } + } + + impl AudioMuxer for ObservedMuxer { + fn send_audio_frame( + &mut self, + _frame: AudioFrame, + _timestamp: Duration, + ) -> anyhow::Result<()> { + Ok(()) + } + } + + #[tokio::test] + async fn static_capture_finishes_with_two_nominally_spaced_frames() { + let temp_dir = tempfile::tempdir().unwrap(); + let clock = Timestamps::now(); + 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")) + .with_video::>(ChannelVideoSourceConfig::new( + VideoInfo::from_raw(cap_media_info::RawVideoFormat::Bgra, 16, 16, 30), + receiver, + )) + .with_timestamps(clock) + .build::(sent.clone()) + .await + .unwrap(); + + sender + .send_async(StaticFrame { + timestamp: Timestamp::Instant(clock.instant()), + }) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(180)).await; + pipeline.stop().await.unwrap(); + + let timestamps = sent.lock().unwrap().clone(); + assert_eq!(timestamps.len(), 3); + let final_frame = timestamps[timestamps.len() - 1]; + let penultimate_frame = timestamps[timestamps.len() - 2]; + assert_eq!( + final_frame.saturating_sub(penultimate_frame), + Duration::from_nanos(33_333_333) + ); + assert!(final_frame > Duration::from_millis(100)); + } + } + mod blocking_thread_finish { use super::*; @@ -6205,5 +6363,41 @@ mod tests { assert_eq!(first, Duration::from_millis(100)); assert_eq!(last, Duration::from_millis(4000)); } + + #[test] + fn static_video_tail_covers_the_final_capture_gap() { + assert_eq!( + static_video_tail_timestamp( + Duration::from_millis(80), + Duration::from_secs(3), + Duration::from_millis(16), + ), + Some(Duration::from_millis(2984)) + ); + } + + #[test] + fn active_video_does_not_gain_a_redundant_tail_frame() { + assert_eq!( + static_video_tail_timestamp( + Duration::from_millis(2980), + Duration::from_secs(3), + Duration::from_millis(16), + ), + None + ); + } + + #[test] + fn video_tail_never_precedes_the_last_frame() { + assert_eq!( + static_video_tail_timestamp( + Duration::from_millis(100), + Duration::from_millis(10), + Duration::from_millis(16), + ), + None + ); + } } } diff --git a/crates/recording/src/output_pipeline/ffmpeg.rs b/crates/recording/src/output_pipeline/ffmpeg.rs index 00d176d5a71..f2527586efc 100644 --- a/crates/recording/src/output_pipeline/ffmpeg.rs +++ b/crates/recording/src/output_pipeline/ffmpeg.rs @@ -39,6 +39,15 @@ impl VideoFrame for FFmpegVideoFrame { fn timestamp(&self) -> Timestamp { self.timestamp } + + fn duplicate(&self) -> Option { + let mut inner = ffmpeg::frame::Video::empty(); + let status = unsafe { ffmpeg::ffi::av_frame_ref(inner.as_mut_ptr(), self.inner.as_ptr()) }; + (status >= 0).then_some(Self { + inner, + timestamp: self.timestamp, + }) + } } pub struct Mp4Muxer { @@ -414,6 +423,7 @@ pub struct SegmentedVideoMuxer { segment_duration: Duration, preset: H264Preset, output_size: Option<(u32, u32)>, + segment_tx: Option>, state: Option, pause: SharedPauseState, frame_drops: FrameDropTracker, @@ -425,6 +435,7 @@ pub struct SegmentedVideoMuxerConfig { pub preset: H264Preset, pub output_size: Option<(u32, u32)>, pub shared_pause_state: Option, + pub segment_tx: Option>, } impl Default for SegmentedVideoMuxerConfig { @@ -434,6 +445,7 @@ impl Default for SegmentedVideoMuxerConfig { preset: H264Preset::Ultrafast, output_size: None, shared_pause_state: None, + segment_tx: None, } } } @@ -468,6 +480,7 @@ impl Muxer for SegmentedVideoMuxer { segment_duration: config.segment_duration, preset: config.preset, output_size: config.output_size, + segment_tx: config.segment_tx, state: None, pause, frame_drops: FrameDropTracker::new(), @@ -584,8 +597,11 @@ impl SegmentedVideoMuxer { }; let slow_threshold_ms = frame_timing_log_threshold_ms(&self.video_config); - let encoder = + let mut encoder = SegmentedVideoEncoder::init(self.base_path.clone(), self.video_config, encoder_config)?; + if let Some(tx) = &self.segment_tx { + encoder.set_segment_callback(tx.clone()); + } let encoder = Arc::new(Mutex::new(encoder)); let encoder_clone = encoder.clone(); @@ -804,3 +820,43 @@ impl AudioMuxer for DashSegmentedAudioMuxer { .map_err(|e| anyhow!("Failed to queue audio frame: {e}")) } } + +#[cfg(test)] +mod tests { + use super::{FFmpegVideoFrame, VideoFrame}; + use cap_timestamp::Timestamp; + use std::time::Instant; + + #[test] + fn duplicated_video_frame_reuses_reference_counted_pixel_storage() { + let timestamp = Instant::now(); + let mut inner = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, 16, 12); + inner.set_pts(Some(417)); + inner.data_mut(0)[..4].copy_from_slice(&[11, 22, 33, 44]); + let original = FFmpegVideoFrame { + inner, + timestamp: Timestamp::Instant(timestamp), + }; + + let duplicate = original.duplicate().expect("reference-counted video frame"); + + assert_eq!( + duplicate.inner.data(0).as_ptr(), + original.inner.data(0).as_ptr() + ); + assert_eq!(duplicate.inner.format(), original.inner.format()); + assert_eq!(duplicate.inner.width(), original.inner.width()); + assert_eq!(duplicate.inner.height(), original.inner.height()); + assert_eq!(duplicate.inner.pts(), Some(417)); + assert!(matches!(duplicate.timestamp, Timestamp::Instant(value) if value == timestamp)); + let buffer = unsafe { (*original.inner.as_ptr()).buf[0] }; + assert!(!buffer.is_null()); + assert_eq!(unsafe { ffmpeg::ffi::av_buffer_get_ref_count(buffer) }, 2); + + drop(original); + + assert_eq!(&duplicate.inner.data(0)[..4], &[11, 22, 33, 44]); + let retained = unsafe { (*duplicate.inner.as_ptr()).buf[0] }; + assert_eq!(unsafe { ffmpeg::ffi::av_buffer_get_ref_count(retained) }, 1); + } +} diff --git a/crates/recording/src/output_pipeline/oop_fragmented_m4s_win.rs b/crates/recording/src/output_pipeline/oop_fragmented_m4s_win.rs index 46aa90a0f4b..50c5eaa4451 100644 --- a/crates/recording/src/output_pipeline/oop_fragmented_m4s_win.rs +++ b/crates/recording/src/output_pipeline/oop_fragmented_m4s_win.rs @@ -6,6 +6,7 @@ use super::oop_muxer::{ MuxerSubprocessConfig, MuxerSubprocessError, RespawningMuxerSubprocess, VideoStreamInit, resolve_muxer_binary, }; +use super::win::reference_video_frame; use crate::{ AudioFrame, AudioMuxer, Muxer, SharedPauseState, TaskPool, VideoMuxer, screen_capture, }; @@ -465,7 +466,7 @@ impl WindowsOOPFragmentedM4SMuxer { let (ffmpeg_frame, timestamp) = match video_rx.recv_timeout(frame_interval) { Ok(Some((frame, ts))) => match frame.as_ffmpeg() { Ok(f) => { - last_ffmpeg_frame = Some(f.clone()); + last_ffmpeg_frame = Some(reference_video_frame(&f)); last_timestamp = Some(ts); (Some(f), ts) } @@ -476,7 +477,7 @@ impl WindowsOOPFragmentedM4SMuxer { let new_ts = last_ts.saturating_add(frame_interval); last_timestamp = Some(new_ts); duplicated_frames += 1; - (Some(f.clone()), new_ts) + (Some(reference_video_frame(f)), new_ts) } _ => (None, Duration::ZERO), } @@ -524,7 +525,7 @@ impl WindowsOOPFragmentedM4SMuxer { let new_ts = last_ts.saturating_add(frame_interval); last_timestamp = Some(new_ts); duplicated_frames += 1; - (Some(f.clone()), new_ts) + (Some(reference_video_frame(f)), new_ts) } _ => continue, } @@ -714,3 +715,28 @@ impl AudioMuxer for WindowsOOPFragmentedM4SMuxer { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::reference_video_frame; + + #[test] + fn out_of_process_fragment_frames_reuse_reference_counted_pixels() { + let mut original = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, 16, 12); + original.set_pts(Some(173)); + original.data_mut(0)[0] = 84; + let retained = reference_video_frame(&original); + let replay = reference_video_frame(&retained); + + assert_eq!(retained.data(0).as_ptr(), original.data(0).as_ptr()); + assert_eq!(replay.data(0).as_ptr(), original.data(0).as_ptr()); + assert_eq!(replay.pts(), Some(173)); + let buffer = unsafe { (*original.as_ptr()).buf[0] }; + assert_eq!(unsafe { ffmpeg::ffi::av_buffer_get_ref_count(buffer) }, 3); + + drop(original); + drop(retained); + + assert_eq!(replay.data(0)[0], 84); + } +} diff --git a/crates/recording/src/output_pipeline/win.rs b/crates/recording/src/output_pipeline/win.rs index d7c7831c98b..f291851c3b4 100644 --- a/crates/recording/src/output_pipeline/win.rs +++ b/crates/recording/src/output_pipeline/win.rs @@ -28,6 +28,17 @@ fn get_muxer_buffer_size() -> usize { .unwrap_or(DEFAULT_MUXER_BUFFER_SIZE) } +pub(super) fn reference_video_frame(frame: &ffmpeg::frame::Video) -> ffmpeg::frame::Video { + let mut reference = ffmpeg::frame::Video::empty(); + let status = unsafe { ffmpeg::ffi::av_frame_ref(reference.as_mut_ptr(), frame.as_ptr()) }; + + if status >= 0 { + reference + } else { + frame.clone() + } +} + struct FrameDropTracker { drops_in_window: u32, frames_in_window: u32, @@ -185,13 +196,13 @@ impl Muxer for WindowsMuxer { let encoder = (|| { let fallback = |reason: Option| { - use tracing::{error, info}; + use tracing::{info, warn}; encoder_preferences.force_software_only(); if let Some(reason) = reason.as_ref() { - error!("Falling back to software H264 encoder: {reason}"); + warn!(%reason, "Media Foundation H264 unavailable; using FFmpeg"); } else { - info!("Falling back to software H264 encoder"); + info!("Using FFmpeg H264 encoder"); } let fallback_width = if output_size.Width > 0 { @@ -222,7 +233,12 @@ impl Muxer for WindowsMuxer { .map_err(|e| anyhow!("ScreenSoftwareEncoder/{e}")) }; - if encoder_preferences.should_force_software() { + if encoder_preferences.should_force_software() + || matches!( + cap_frame_converter::detect_primary_gpu().map(|gpu| gpu.vendor), + Some(cap_frame_converter::GpuVendor::Amd) + ) + { return fallback(None); } @@ -421,7 +437,7 @@ impl Muxer for WindowsMuxer { } } either::Right(mut encoder) => { - trace!("Running software encoder with frame pacing"); + trace!("Running FFmpeg encoder with frame pacing"); let frame_interval = Duration::from_secs_f64(1.0 / config.frame_rate as f64); let mut last_ffmpeg_frame: Option = None; let mut first_timestamp: Option = None; @@ -433,12 +449,17 @@ impl Muxer for WindowsMuxer { last_timestamp = Some(timestamp); match frame.as_ffmpeg() { Ok(f) => { - last_ffmpeg_frame = Some(f.clone()); + last_ffmpeg_frame = Some(reference_video_frame(&f)); (Some(f), timestamp) } Err(e) => { warn!("Failed to convert frame: {e:?}"); - (last_ffmpeg_frame.clone(), timestamp) + ( + last_ffmpeg_frame + .as_ref() + .map(reference_video_frame), + timestamp, + ) } } } @@ -451,7 +472,12 @@ impl Muxer for WindowsMuxer { if let Some(last_ts) = last_timestamp { let new_ts = last_ts.saturating_add(frame_interval); last_timestamp = Some(new_ts); - (last_ffmpeg_frame.clone(), new_ts) + ( + last_ffmpeg_frame + .as_ref() + .map(reference_video_frame), + new_ts, + ) } else { continue; } @@ -531,6 +557,53 @@ impl Muxer for WindowsMuxer { } } +#[cfg(test)] +mod tests { + use super::reference_video_frame; + + #[test] + fn retained_software_frame_shares_pixels_and_survives_original() { + let mut original = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, 16, 12); + original.set_pts(Some(417)); + original.data_mut(0)[..4].copy_from_slice(&[11, 22, 33, 44]); + + let retained = reference_video_frame(&original); + + assert_eq!(retained.data(0).as_ptr(), original.data(0).as_ptr()); + assert_eq!(retained.format(), original.format()); + assert_eq!(retained.width(), original.width()); + assert_eq!(retained.height(), original.height()); + assert_eq!(retained.pts(), Some(417)); + let buffer = unsafe { (*original.as_ptr()).buf[0] }; + assert_eq!(unsafe { ffmpeg::ffi::av_buffer_get_ref_count(buffer) }, 2); + + drop(original); + + assert_eq!(&retained.data(0)[..4], &[11, 22, 33, 44]); + let retained_buffer = unsafe { (*retained.as_ptr()).buf[0] }; + assert_eq!( + unsafe { ffmpeg::ffi::av_buffer_get_ref_count(retained_buffer) }, + 1 + ); + } + + #[test] + fn repeated_static_software_frames_reuse_retained_pixels() { + let original = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, 16, 12); + let retained = reference_video_frame(&original); + let pointer = retained.data(0).as_ptr(); + let buffer = unsafe { (*retained.as_ptr()).buf[0] }; + + for _ in 0..8 { + let replay = reference_video_frame(&retained); + assert_eq!(replay.data(0).as_ptr(), pointer); + assert_eq!(unsafe { ffmpeg::ffi::av_buffer_get_ref_count(buffer) }, 3); + drop(replay); + assert_eq!(unsafe { ffmpeg::ffi::av_buffer_get_ref_count(buffer) }, 2); + } + } +} + impl VideoMuxer for WindowsMuxer { type VideoFrame = screen_capture::VideoFrame; @@ -739,11 +812,9 @@ impl Muxer for WindowsCameraMuxer { let fallback = |reason: Option| { encoder_preferences.force_software_only(); if let Some(reason) = reason.as_ref() { - error!( - "Falling back to software H264 encoder for camera: {reason}" - ); + warn!(%reason, "Media Foundation camera H264 unavailable; using FFmpeg"); } else { - info!("Using software H264 encoder for camera"); + info!("Using FFmpeg H264 encoder for camera"); } let mut output_guard = match output.lock() { @@ -763,7 +834,12 @@ impl Muxer for WindowsCameraMuxer { .map_err(|e| anyhow!("CameraSoftwareEncoder/{e}")) }; - if encoder_preferences.should_force_software() { + if encoder_preferences.should_force_software() + || matches!( + cap_frame_converter::detect_primary_gpu().map(|gpu| gpu.vendor), + Some(cap_frame_converter::GpuVendor::Amd) + ) + { return fallback(None); } @@ -963,7 +1039,7 @@ impl Muxer for WindowsCameraMuxer { } either::Right(mut encoder) => { info!( - "Windows camera encoder started (software) with frame pacing: {}x{} -> {}x{} @ {}fps", + "Windows camera encoder started (FFmpeg) with frame pacing: {}x{} -> {}x{} @ {}fps", video_config.width, video_config.height, output_width, @@ -1037,14 +1113,14 @@ impl Muxer for WindowsCameraMuxer { frame_count += 1; if frame_count.is_multiple_of(30) { debug!( - "Windows camera encoder (software): processed {} frames", + "Windows camera encoder (FFmpeg): processed {} frames", frame_count ); } } info!( - "Windows camera encoder finished (software): {} frames encoded", + "Windows camera encoder finished (FFmpeg): {} frames encoded", frame_count ); Ok(()) diff --git a/crates/recording/src/output_pipeline/win_fragmented_m4s.rs b/crates/recording/src/output_pipeline/win_fragmented_m4s.rs index 4fec3ba9f56..208ab9f4ea1 100644 --- a/crates/recording/src/output_pipeline/win_fragmented_m4s.rs +++ b/crates/recording/src/output_pipeline/win_fragmented_m4s.rs @@ -2,6 +2,7 @@ use super::core::{ BlockingThreadFinish, DiskSpaceMonitor, HealthSender, PipelineHealthEvent, SharedHealthSender, combine_finish_errors, wait_for_blocking_thread_finish, }; +use super::win::reference_video_frame; use crate::{ AudioFrame, AudioMuxer, Muxer, SharedPauseState, TaskPool, VideoMuxer, output_pipeline::{NativeCameraFrame, camera_frame_to_ffmpeg}, @@ -438,7 +439,7 @@ impl WindowsFragmentedM4SMuxer { let (ffmpeg_frame, timestamp) = match video_rx.recv_timeout(frame_interval) { Ok(Some((frame, ts))) => match frame.as_ffmpeg() { Ok(f) => { - last_ffmpeg_frame = Some(f.clone()); + last_ffmpeg_frame = Some(reference_video_frame(&f)); last_timestamp = Some(ts); (Some(f), ts) } @@ -449,7 +450,7 @@ impl WindowsFragmentedM4SMuxer { let new_ts = last_ts.saturating_add(frame_interval); last_timestamp = Some(new_ts); duplicated_frames += 1; - (Some(f.clone()), new_ts) + (Some(reference_video_frame(f)), new_ts) } _ => (None, Duration::ZERO), } @@ -500,7 +501,7 @@ impl WindowsFragmentedM4SMuxer { let new_ts = last_ts.saturating_add(frame_interval); last_timestamp = Some(new_ts); duplicated_frames += 1; - (Some(f.clone()), new_ts) + (Some(reference_video_frame(f)), new_ts) } _ => continue, } @@ -1088,3 +1089,28 @@ impl AudioMuxer for WindowsFragmentedM4SCameraMuxer { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::reference_video_frame; + + #[test] + fn instant_fragment_frames_reuse_reference_counted_pixels() { + let mut original = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, 16, 12); + original.set_pts(Some(91)); + original.data_mut(0)[0] = 63; + let retained = reference_video_frame(&original); + let replay = reference_video_frame(&retained); + + assert_eq!(retained.data(0).as_ptr(), original.data(0).as_ptr()); + assert_eq!(replay.data(0).as_ptr(), original.data(0).as_ptr()); + assert_eq!(replay.pts(), Some(91)); + let buffer = unsafe { (*original.as_ptr()).buf[0] }; + assert_eq!(unsafe { ffmpeg::ffi::av_buffer_get_ref_count(buffer) }, 3); + + drop(original); + drop(retained); + + assert_eq!(replay.data(0)[0], 63); + } +} diff --git a/crates/recording/src/recovery.rs b/crates/recording/src/recovery.rs index b754bac0188..13dfc6734d2 100644 --- a/crates/recording/src/recovery.rs +++ b/crates/recording/src/recovery.rs @@ -1383,9 +1383,7 @@ impl RecoveryManager { None }, mic: { - let mic_size = std::fs::metadata(&mic_path).map(|m| m.len()).unwrap_or(0); - const MIN_VALID_AUDIO_SIZE: u64 = 500; - if mic_path.exists() && mic_size > MIN_VALID_AUDIO_SIZE { + if valid_recovered_audio(&mic_path) { Some(AudioMeta { path: RelativePathBuf::from(format!( "{segment_base}/audio-input.ogg" @@ -1405,11 +1403,7 @@ impl RecoveryManager { } }, system_audio: { - let file_size = std::fs::metadata(&system_audio_path) - .map(|m| m.len()) - .unwrap_or(0); - const MIN_VALID_AUDIO_SIZE: u64 = 500; - if system_audio_path.exists() && file_size > MIN_VALID_AUDIO_SIZE { + if valid_recovered_audio(&system_audio_path) { Some(AudioMeta { path: RelativePathBuf::from(format!( "{segment_base}/system_audio.ogg" @@ -1658,6 +1652,10 @@ fn start_time_or_display_fallback( original_time.or(display_start_time) } +fn valid_recovered_audio(path: &Path) -> bool { + path.is_file() && probe_media_valid(path) +} + fn replace_file(src: &Path, dst: &Path) -> Result<(), RecoveryError> { if dst.exists() { std::fs::remove_file(dst).map_err(RecoveryError::Io)?; @@ -1668,7 +1666,7 @@ fn replace_file(src: &Path, dst: &Path) -> Result<(), RecoveryError> { #[cfg(test)] mod tests { - use super::{replace_file, start_time_or_display_fallback}; + use super::{replace_file, start_time_or_display_fallback, valid_recovered_audio}; use std::fs; use tempfile::tempdir; @@ -1687,6 +1685,40 @@ mod tests { assert!(!src.exists()); } + #[test] + fn recovered_audio_keeps_valid_media_smaller_than_legacy_threshold() { + let dir = tempdir().unwrap(); + let path = dir.path().join("quiet.wav"); + let samples = [0u8; 32]; + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&(36 + samples.len() as u32).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16u32.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&8000u32.to_le_bytes()); + bytes.extend_from_slice(&16000u32.to_le_bytes()); + bytes.extend_from_slice(&2u16.to_le_bytes()); + bytes.extend_from_slice(&16u16.to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&(samples.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&samples); + fs::write(&path, &bytes).unwrap(); + + assert!(bytes.len() < 500); + assert!(valid_recovered_audio(&path)); + } + + #[test] + fn recovered_audio_rejects_large_corrupt_media() { + let dir = tempdir().unwrap(); + let path = dir.path().join("corrupt.ogg"); + fs::write(&path, [0u8; 1024]).unwrap(); + + assert!(!valid_recovered_audio(&path)); + } + #[test] fn start_time_fallback_prefers_original_value() { let original = Some(0.8); diff --git a/crates/recording/src/screenshot.rs b/crates/recording/src/screenshot.rs index 01a4787e670..11f1bfb9b56 100644 --- a/crates/recording/src/screenshot.rs +++ b/crates/recording/src/screenshot.rs @@ -819,10 +819,215 @@ 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() { + let image = capture_screenshot_wayland(&target).await?; + return Ok(finalize_screenshot(image, &target)); + } + let image = capture_screenshot_x11(&target).await?; Ok(finalize_screenshot(image, &target)) } +#[cfg(target_os = "linux")] +pub fn is_pure_wayland_session() -> 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() +} + +#[cfg(target_os = "linux")] +async fn capture_screenshot_wayland(target: &ScreenCaptureTarget) -> anyhow::Result { + let display_id = wayland_screenshot_display_id(target)?; + + let displays = scap_targets::Display::list(); + let [display] = displays.as_slice() else { + return Err(anyhow!( + "Wayland screenshot portal cannot safely isolate the selected display" + )); + }; + + if display.id() != *display_id { + return Err(anyhow!( + "Wayland screenshot portal cannot safely isolate the selected display" + )); + } + + let display_size = display + .physical_size() + .ok_or_else(|| anyhow!("Selected Wayland display size unavailable"))?; + let (width, height) = + checked_wayland_display_size(display_size.width(), display_size.height())?; + let crop = checked_wayland_screenshot_crop(target, width, height)?; + + let screenshot = ashpd::desktop::screenshot::Screenshot::request() + .interactive(false) + .send() + .await + .context("Request Wayland screenshot from desktop portal")? + .response() + .context("Wayland screenshot portal request was cancelled or rejected")?; + + let uri = screenshot.uri(); + if !is_local_wayland_screenshot_uri(uri.scheme(), uri.host_str()) { + return Err(anyhow!( + "Wayland screenshot portal returned a non-local file URI" + )); + } + + let path = uri + .to_file_path() + .map_err(|()| anyhow!("Wayland screenshot portal returned a non-local file URI"))?; + + let metadata = tokio::fs::symlink_metadata(&path) + .await + .context("Read Wayland screenshot portal file metadata")?; + if !metadata.file_type().is_file() { + return Err(anyhow!( + "Wayland screenshot portal returned a non-regular file" + )); + } + + let image = tokio::task::spawn_blocking(move || { + use std::os::unix::fs::MetadataExt; + + let file = std::fs::File::open(&path).context("Open Wayland screenshot portal image")?; + let opened_metadata = file + .metadata() + .context("Read opened Wayland screenshot portal file metadata")?; + if !opened_metadata.file_type().is_file() + || opened_metadata.dev() != metadata.dev() + || opened_metadata.ino() != metadata.ino() + { + return Err(anyhow!( + "Wayland screenshot portal image changed before it could be read" + )); + } + + image::ImageReader::new(std::io::BufReader::new(file)) + .with_guessed_format() + .context("Identify Wayland screenshot portal image format")? + .decode() + .map(DynamicImage::into_rgb8) + .context("Decode Wayland screenshot portal image") + }) + .await + .context("Wayland screenshot image task failed")??; + + if !wayland_screenshot_matches_display(image.width(), image.height(), width, height) { + return Err(anyhow!( + "Wayland screenshot contains content outside the selected display" + )); + } + + match crop { + Some((x, y, width, height)) => { + Ok(image::imageops::crop_imm(&image, x, y, width, height).to_image()) + } + None => Ok(image), + } +} + +#[cfg(target_os = "linux")] +fn wayland_screenshot_display_id( + target: &ScreenCaptureTarget, +) -> anyhow::Result<&scap_targets::DisplayId> { + match target { + ScreenCaptureTarget::Display { id } => Ok(id), + ScreenCaptureTarget::Area { screen, .. } => Ok(screen), + ScreenCaptureTarget::Window { .. } => Err(anyhow!( + "Wayland screenshot portal cannot safely isolate the selected window" + )), + ScreenCaptureTarget::CameraOnly => { + Err(anyhow!("Camera-only not supported for screenshots")) + } + } +} + +#[cfg(target_os = "linux")] +fn is_local_wayland_screenshot_uri(scheme: &str, host: Option<&str>) -> bool { + scheme == "file" && (host.is_none() || host == Some("localhost")) +} + +#[cfg(target_os = "linux")] +fn wayland_screenshot_matches_display( + image_width: u32, + image_height: u32, + display_width: u32, + display_height: u32, +) -> bool { + image_width == display_width && image_height == display_height +} + +#[cfg(target_os = "linux")] +fn checked_wayland_display_size(width: f64, height: f64) -> anyhow::Result<(u32, u32)> { + if !width.is_finite() + || !height.is_finite() + || width <= 0.0 + || height <= 0.0 + || width.fract() != 0.0 + || height.fract() != 0.0 + || width > f64::from(u32::MAX) + || height > f64::from(u32::MAX) + { + return Err(anyhow!("Selected Wayland display size is invalid")); + } + + Ok((width as u32, height as u32)) +} + +#[cfg(target_os = "linux")] +fn checked_wayland_screenshot_crop( + target: &ScreenCaptureTarget, + image_width: u32, + image_height: u32, +) -> anyhow::Result> { + let ScreenCaptureTarget::Area { bounds, .. } = target else { + return Ok(None); + }; + + let x = bounds.position().x(); + let y = bounds.position().y(); + let width = bounds.size().width(); + let height = bounds.size().height(); + let right = x + width; + let bottom = y + height; + + if ![x, y, width, height, right, bottom] + .iter() + .all(|value| value.is_finite()) + || x < 0.0 + || y < 0.0 + || width <= 0.0 + || height <= 0.0 + || right > f64::from(image_width) + || bottom > f64::from(image_height) + { + return Err(anyhow!( + "Selected Wayland screenshot area exceeds the selected display" + )); + } + + let x = x.ceil() as u32; + let y = y.ceil() as u32; + let right = right.floor() as u32; + let bottom = bottom.floor() as u32; + + if right <= x || bottom <= y { + return Err(anyhow!("Selected Wayland screenshot area is empty")); + } + + Ok(Some((x, y, right - x, bottom - y))) +} + #[cfg(not(target_os = "linux"))] pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result { #[cfg(target_os = "macos")] @@ -988,7 +1193,7 @@ pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result { let window = scap_targets::Window::from_id(&id) @@ -996,7 +1201,7 @@ pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result { let display = scap_targets::Display::from_id(&screen) @@ -1004,13 +1209,22 @@ pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result { return Err(anyhow!("Camera-only not supported for screenshots")); } }; + let item = match item { + Ok(item) => item, + Err(error) => { + let fallback_image = gdi_or_error(&target, error)?; + return crop_area_if_needed(fallback_image, &target, false) + .map(|image| finalize_screenshot(image, &target)); + } + }; + let (settings, cropped) = windows_capture_settings(&target)?; cropped_in_capture = cropped; @@ -1322,3 +1536,122 @@ fn linux_capture_geometry( } } } + +#[cfg(all(test, target_os = "linux"))] +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 { + screen: "0".parse().expect("valid display id"), + bounds: LogicalBounds::new(LogicalPosition::new(x, y), LogicalSize::new(width, height)), + } + } + + #[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)); + assert!(is_local_wayland_screenshot_uri("file", Some("localhost"))); + assert!(!is_local_wayland_screenshot_uri("https", None)); + assert!(!is_local_wayland_screenshot_uri("https", Some("localhost"))); + assert!(!is_local_wayland_screenshot_uri("file", Some("remote"))); + } + + #[test] + fn valid_wayland_display_dimensions_must_be_exact_positive_pixels() { + assert_eq!( + checked_wayland_display_size(1920.0, 1080.0).expect("valid display"), + (1920, 1080) + ); + + for (width, height) in [ + (0.0, 1080.0), + (1920.0, -1.0), + (1920.5, 1080.0), + (f64::NAN, 1080.0), + (1920.0, f64::INFINITY), + (f64::from(u32::MAX) + 1.0, 1080.0), + ] { + assert!(checked_wayland_display_size(width, height).is_err()); + } + } + + #[test] + fn screenshot_must_match_the_selected_display_exactly() { + assert!(wayland_screenshot_matches_display(1920, 1080, 1920, 1080)); + assert!(!wayland_screenshot_matches_display(3840, 1080, 1920, 1080)); + assert!(!wayland_screenshot_matches_display(1920, 2160, 1920, 1080)); + assert!(!wayland_screenshot_matches_display(1280, 720, 1920, 1080)); + } + + #[test] + fn selected_wayland_area_is_cropped_exactly() { + let target = area_target(100.0, 120.0, 640.0, 360.0); + assert_eq!( + checked_wayland_screenshot_crop(&target, 1920, 1080).expect("valid crop"), + Some((100, 120, 640, 360)) + ); + } + + #[test] + fn fractional_wayland_areas_never_include_pixels_outside_selection() { + let target = area_target(10.25, 20.25, 30.75, 40.75); + assert_eq!( + checked_wayland_screenshot_crop(&target, 1920, 1080).expect("valid crop"), + Some((11, 21, 30, 40)) + ); + } + + #[test] + fn out_of_bounds_or_non_finite_wayland_areas_are_rejected() { + for (x, y, width, height) in [ + (-1.0, 0.0, 10.0, 10.0), + (0.0, -1.0, 10.0, 10.0), + (1915.0, 0.0, 10.0, 10.0), + (0.0, 1075.0, 10.0, 10.0), + (0.0, 0.0, 0.0, 10.0), + (0.0, 0.0, 10.0, -1.0), + (f64::NAN, 0.0, 10.0, 10.0), + (0.0, 0.0, f64::INFINITY, 10.0), + (f64::MAX, 0.0, f64::MAX, 10.0), + ] { + let target = area_target(x, y, width, height); + assert!(checked_wayland_screenshot_crop(&target, 1920, 1080).is_err()); + } + } + + #[test] + fn too_small_fractional_wayland_areas_are_rejected() { + let target = area_target(10.25, 20.25, 0.25, 0.25); + assert!(checked_wayland_screenshot_crop(&target, 1920, 1080).is_err()); + } + + #[test] + fn wayland_window_screenshots_are_rejected_before_portal_capture() { + let target = ScreenCaptureTarget::Window { + id: "0".parse().expect("valid window id"), + }; + assert!(wayland_screenshot_display_id(&target).is_err()); + } + + #[test] + fn wayland_camera_only_screenshots_are_rejected_before_portal_capture() { + assert!(wayland_screenshot_display_id(&ScreenCaptureTarget::CameraOnly).is_err()); + } +} diff --git a/crates/recording/src/sources/screen_capture/linux.rs b/crates/recording/src/sources/screen_capture/linux.rs index c141b08bf37..bf9291e4c74 100644 --- a/crates/recording/src/sources/screen_capture/linux.rs +++ b/crates/recording/src/sources/screen_capture/linux.rs @@ -586,19 +586,7 @@ fn process_pipewire_frame( else { return Ok(Some(StallSendOutcome::StalledAndDropped { waited_ms: 0 })); }; - if state.scaler.is_none() { - state.scaler = Some(FrameScaler::new( - raw_frame.format(), - raw_frame.width(), - raw_frame.height(), - state.video_info, - )?); - } - let frame = state - .scaler - .as_mut() - .expect("PipeWire frame scaler initialized") - .scale(&raw_frame, state.video_info)?; + let frame = prepare_pipewire_frame(raw_frame, &mut state.scaler, state.video_info)?; let timestamp = Timestamp::Instant(Instant::now()); Ok(Some(send_with_stall_budget_futures( @@ -612,6 +600,33 @@ fn process_pipewire_frame( ))) } +fn prepare_pipewire_frame( + frame: ffmpeg::frame::Video, + scaler: &mut Option, + output: VideoInfo, +) -> anyhow::Result { + if frame.format() == output.pixel_format + && frame.width() == output.width + && frame.height() == output.height + { + return Ok(frame); + } + + if scaler.is_none() { + *scaler = Some(FrameScaler::new( + frame.format(), + frame.width(), + frame.height(), + output, + )?); + } + + scaler + .as_mut() + .expect("PipeWire frame scaler initialized") + .scale(&frame, output) +} + fn frame_from_pipewire_data( data: &mut spa::buffer::Data, format: spa::param::video::VideoInfoRaw, @@ -820,12 +835,20 @@ fn pipewire_format_param(fps: u32) -> anyhow::Result> { pub struct SystemAudioSourceConfig { feed_lock: Arc, device_name: String, - restore_source: Option, + monitor_route: Option, } pub struct SystemAudioSource { inner: crate::sources::Microphone, - restore_source: Option, +} + +struct PactlMonitorRoute { + monitor_source: String, + monitor_source_index: u32, + default_source: Option, + default_source_index: Option, + source_output: u32, + previous_process_source_outputs: Vec, } impl AudioSource for SystemAudioSource { @@ -840,17 +863,17 @@ impl AudioSource for SystemAudioSource { Self: Sized, { let device_name = config.device_name.clone(); - let restore_source = config.restore_source; let setup = ::setup(config.feed_lock, tx, ctx); async move { let inner = setup .await .with_context(|| format!("set up Linux system audio source '{device_name}'"))?; - Ok(Self { - inner, - restore_source, - }) + if let Some(route) = config.monitor_route { + apply_pactl_monitor_route(&route)?; + } + + Ok(Self { inner }) } } @@ -859,15 +882,7 @@ impl AudioSource for SystemAudioSource { } fn stop(&mut self) -> impl std::future::Future> + Send { - let restore_source = self.restore_source.take(); - let stop = self.inner.stop(); - async move { - let result = stop.await; - if let Some(source) = restore_source { - restore_pactl_default_source(&source); - } - result - } + self.inner.stop() } } @@ -897,43 +912,110 @@ async fn create_system_audio_source_config() -> anyhow::Result, + monitor_source_index: Option, + default_source: Option, + default_source_index: Option, + previous_process_source_outputs: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +struct PactlSourceOutput { + id: u32, + source: u32, +} + +fn apply_pactl_monitor_route(route: &PactlMonitorRoute) -> anyhow::Result<()> { + let mut current_source_outputs = current_process_source_outputs()?; + let current_system_source = current_source_outputs + .iter() + .find(|source_output| source_output.id == route.source_output) + .ok_or_else(|| anyhow!("PulseAudio/PipeWire system-audio stream is no longer active"))?; + + if source_output_needs_move( + current_system_source.source, + Some(route.monitor_source_index), + ) { + move_pactl_source_output(route.source_output, &route.monitor_source)?; + current_source_outputs = current_process_source_outputs()?; + } + + for previous_output in &route.previous_process_source_outputs { + if let Some(current_output) = current_source_outputs + .iter() + .find(|current_output| current_output.id == previous_output.id) + { + let destination = previous_source_destination( + previous_output.source, + route.monitor_source_index, + &route.monitor_source, + route.default_source.as_deref(), + ); + let destination_index = if destination == previous_output.source.to_string() { + Some(previous_output.source) + } else { + route.default_source_index + }; + + if source_output_needs_move(current_output.source, destination_index) { + move_pactl_source_output(previous_output.id, &destination)?; + } } } + + Ok(()) } -struct SelectedSystemAudioInput { - device_name: String, - restore_source: Option, +fn source_output_needs_move(current_source: u32, target_source: Option) -> bool { + target_source != Some(current_source) } fn select_system_audio_monitor() -> anyhow::Result { let devices = MicrophoneFeed::list(); let available = devices.keys().cloned().collect::>(); - let mut candidates = devices - .iter() - .filter_map(|(name, device)| { - system_audio_device_rank(&name).map(|rank| (rank, name, device)) - }) - .collect::>(); - - candidates.sort_by_key(|(rank, name, _)| (*rank, name.to_ascii_lowercase())); - - if let Some((_, name, _)) = candidates.into_iter().next() { + if let Some(name) = preferred_system_audio_device(&available, false) { return Ok(SelectedSystemAudioInput { device_name: name.to_string(), - restore_source: None, + monitor_source: None, + monitor_source_index: None, + default_source: None, + default_source_index: None, + previous_process_source_outputs: Vec::new(), }); } @@ -941,12 +1023,43 @@ fn select_system_audio_monitor() -> anyhow::Result { return Ok(selected); } + if let Some(name) = preferred_system_audio_device(&available, true) { + return Ok(SelectedSystemAudioInput { + device_name: name.to_string(), + monitor_source: None, + monitor_source_index: None, + default_source: None, + default_source_index: None, + previous_process_source_outputs: Vec::new(), + }); + } + Err(anyhow!( "No PulseAudio/PipeWire monitor input was found for Linux system audio. \ Available input devices: {available:?}. Select a monitor source with --mic, or enable a monitor source in your audio server." )) } +fn preferred_system_audio_device( + available_devices: &[String], + include_ambiguous: bool, +) -> Option<&str> { + available_devices + .iter() + .filter_map(|name| { + let rank = system_audio_device_rank(name)?; + (include_ambiguous || rank < 2).then_some((rank, name.as_str())) + }) + .min_by(|(left_rank, left_name), (right_rank, right_name)| { + left_rank.cmp(right_rank).then_with(|| { + left_name + .to_ascii_lowercase() + .cmp(&right_name.to_ascii_lowercase()) + }) + }) + .map(|(_, name)| name) +} + fn system_audio_device_rank(name: &str) -> Option { let name = name.to_ascii_lowercase(); if name.contains("monitor") { @@ -963,44 +1076,166 @@ fn system_audio_device_rank(name: &str) -> Option { fn select_pactl_monitor_source( available_devices: &[String], ) -> anyhow::Result> { - let Some(device_name) = pulse_cpal_device_name(available_devices) else { - return Ok(None); - }; - let output = match Command::new("pactl") .args(["list", "short", "sources"]) .output() { Ok(output) if output.status.success() => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(anyhow!( + "Linux system audio capture needs `pactl` to discover monitor sources, \ + but it was not found on PATH. Install it with `apt install pulseaudio-utils`, \ + `dnf install pulseaudio-utils`, or `pacman -S libpulse`, then try again. \ + Available input devices: {available_devices:?}." + )); + } _ => return Ok(None), }; + let Some(device_name) = pulse_cpal_device_name(available_devices) else { + return Ok(None); + }; + let sources = String::from_utf8_lossy(&output.stdout); + let default_sink = pactl_default_sink(); let mut monitor_sources = sources .lines() - .filter_map(|line| line.split_whitespace().nth(1)) - .filter_map(|name| pactl_monitor_rank(name).map(|rank| (rank, name.to_string()))) + .filter_map(|line| { + let mut fields = line.split_whitespace(); + let index = fields.next()?.parse::().ok()?; + let name = fields.next()?; + + pactl_monitor_preference(name, default_sink.as_deref()) + .map(|rank| (rank, name.to_string(), index)) + }) .collect::>(); - monitor_sources.sort_by_key(|(rank, name)| (*rank, name.to_ascii_lowercase())); + monitor_sources.sort_by_key(|(rank, name, _)| (*rank, name.to_ascii_lowercase())); - let Some((_, source)) = monitor_sources.into_iter().next() else { + let Some((_, source, source_index)) = monitor_sources.into_iter().next() else { return Ok(None); }; - let previous_source = pactl_default_source(); - let restore_source = if previous_source.as_deref() == Some(source.as_str()) { - None - } else { - set_pactl_default_source(&source)?; - previous_source - }; + let previous_process_source_outputs = current_process_source_outputs()?; + let default_source = pactl_default_source(); + let default_source_index = default_source + .as_deref() + .and_then(|source| pactl_source_index(&sources, source)); Ok(Some(SelectedSystemAudioInput { device_name, - restore_source, + monitor_source: Some(source), + monitor_source_index: Some(source_index), + default_source, + default_source_index, + previous_process_source_outputs, })) } +fn pactl_source_index(sources: &str, name: &str) -> Option { + sources.lines().find_map(|line| { + let mut fields = line.split_whitespace(); + let index = fields.next()?.parse::().ok()?; + + (fields.next()? == name).then_some(index) + }) +} + +fn previous_source_destination( + previous_source: u32, + monitor_source: u32, + monitor_name: &str, + default_source: Option<&str>, +) -> String { + if previous_source == monitor_source + && let Some(default_source) = default_source + && default_source != monitor_name + { + default_source.to_string() + } else { + previous_source.to_string() + } +} + +fn current_process_source_outputs() -> anyhow::Result> { + let output = Command::new("pactl") + .args(["-f", "json", "list", "source-outputs"]) + .output() + .context("list PulseAudio/PipeWire source outputs")?; + + if !output.status.success() { + bail!("Could not inspect PulseAudio/PipeWire source outputs for isolated system audio"); + } + + let outputs: Vec = serde_json::from_slice(&output.stdout) + .context("parse PulseAudio/PipeWire source outputs")?; + process_source_output_ids(&outputs, &std::process::id().to_string()) +} + +fn process_source_output_ids( + outputs: &[serde_json::Value], + process_id: &str, +) -> anyhow::Result> { + outputs + .iter() + .filter(|output| { + output["properties"]["application.process.id"] + .as_str() + .is_some_and(|id| id == process_id) + }) + .map(|output| { + let id = output["index"] + .as_u64() + .and_then(|index| u32::try_from(index).ok()) + .ok_or_else(|| anyhow!("PulseAudio/PipeWire source output has an invalid index"))?; + let source = output["source"] + .as_u64() + .and_then(|source| u32::try_from(source).ok()) + .ok_or_else(|| { + anyhow!("PulseAudio/PipeWire source output has an invalid source") + })?; + + Ok(PactlSourceOutput { id, source }) + }) + .collect() +} + +fn newly_created_source_output( + previous: &[PactlSourceOutput], + current: &[PactlSourceOutput], +) -> anyhow::Result { + let mut created = current + .iter() + .filter(|source_output| { + !previous + .iter() + .any(|previous_output| previous_output.id == source_output.id) + }) + .map(|source_output| source_output.id); + + match (created.next(), created.next()) { + (Some(source_output), None) => Ok(source_output), + (None, _) => bail!("Could not identify the PulseAudio/PipeWire system-audio stream"), + (Some(_), Some(_)) => { + bail!("Multiple PulseAudio/PipeWire system-audio streams started simultaneously") + } + } +} + +fn move_pactl_source_output(source_output: u32, source: &str) -> anyhow::Result<()> { + let status = Command::new("pactl") + .args(["move-source-output", &source_output.to_string(), source]) + .status() + .context("move PulseAudio/PipeWire system-audio stream")?; + + if status.success() { + Ok(()) + } else { + bail!( + "Could not route PulseAudio/PipeWire system-audio stream {source_output} to '{source}'" + ) + } +} + fn pulse_cpal_device_name(available_devices: &[String]) -> Option { available_devices .iter() @@ -1029,39 +1264,32 @@ fn pactl_monitor_rank(name: &str) -> Option { } } -fn pactl_default_source() -> Option { - let output = Command::new("pactl") - .arg("get-default-source") - .output() - .ok()?; +fn pactl_monitor_preference(name: &str, default_sink: Option<&str>) -> Option<(u8, u8)> { + let rank = pactl_monitor_rank(name)?; + let is_default_sink = default_sink.is_some_and(|sink| { + name.strip_suffix(".monitor") + .is_some_and(|monitor_sink| monitor_sink == sink) + }); - output - .status - .success() - .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) - .filter(|source| !source.is_empty()) + Some((u8::from(!is_default_sink), rank)) } -fn set_pactl_default_source(source: &str) -> anyhow::Result<()> { - let status = Command::new("pactl") - .args(["set-default-source", source]) - .status() - .context("run pactl set-default-source")?; +fn pactl_default_sink() -> Option { + pactl_default_device("get-default-sink") +} - status - .success() - .then_some(()) - .ok_or_else(|| anyhow!("pactl set-default-source '{source}' failed")) +fn pactl_default_source() -> Option { + pactl_default_device("get-default-source") } -fn restore_pactl_default_source(source: &str) { - if let Err(error) = set_pactl_default_source(source) { - tracing::warn!( - source, - error = %error, - "Failed to restore PulseAudio/PipeWire default source after Linux system audio capture" - ); - } +fn pactl_default_device(command: &str) -> Option { + let output = Command::new("pactl").arg(command).output().ok()?; + + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|sink| !sink.is_empty()) } struct FrameScaler { @@ -1469,3 +1697,178 @@ fn x11_source_pixel( _ => bail!("Unsupported X11 channel order: b={blue} g={green} r={red}"), }) } + +#[cfg(test)] +mod system_audio_tests { + use super::{ + PactlSourceOutput, newly_created_source_output, pactl_monitor_preference, + pactl_source_index, preferred_system_audio_device, previous_source_destination, + process_source_output_ids, source_output_needs_move, + }; + + #[test] + fn ambiguous_loopback_waits_for_verified_pulse_monitor() { + let devices = ["Loopback", "pulse", "default"].map(str::to_string); + + assert_eq!(preferred_system_audio_device(&devices, false), None); + assert_eq!( + preferred_system_audio_device(&devices, true), + Some("Loopback") + ); + } + + #[test] + fn explicit_monitor_and_stereo_mix_remain_direct_inputs() { + let devices = ["Loopback", "Stereo Mix", "Output Monitor"].map(str::to_string); + + assert_eq!( + preferred_system_audio_device(&devices, false), + Some("Output Monitor") + ); + + let devices = ["Loopback", "Stereo Mix"].map(str::to_string); + + assert_eq!( + preferred_system_audio_device(&devices, false), + Some("Stereo Mix") + ); + } + + #[test] + fn default_sink_monitor_precedes_other_monitor_sources() { + let default = + pactl_monitor_preference("cap_validation_sink.monitor", Some("cap_validation_sink")); + let suspended = pactl_monitor_preference( + "alsa_output.platform-snd_aloop.monitor", + Some("cap_validation_sink"), + ); + + assert_eq!(default, Some((0, 0))); + assert_eq!(suspended, Some((1, 0))); + assert!(default < suspended); + assert_eq!( + pactl_monitor_preference("cap_validation_sink.monitor", None), + Some((1, 0)) + ); + } + + #[test] + fn source_outputs_only_include_the_current_process() { + let outputs = serde_json::json!([ + { "index": 11, "source": 5, "properties": { "application.process.id": "41" } }, + { "index": 12, "source": 6, "properties": { "application.process.id": "42" } }, + { "index": 13, "source": 7, "properties": { "application.process.id": "42" } } + ]); + + assert_eq!( + process_source_output_ids(outputs.as_array().unwrap(), "42").unwrap(), + vec![ + PactlSourceOutput { id: 12, source: 6 }, + PactlSourceOutput { id: 13, source: 7 } + ] + ); + } + + #[test] + fn only_one_new_system_audio_stream_is_accepted() { + let previous = [PactlSourceOutput { id: 11, source: 3 }]; + let current = [ + PactlSourceOutput { id: 11, source: 2 }, + PactlSourceOutput { id: 12, source: 2 }, + ]; + + assert_eq!( + newly_created_source_output(&previous, ¤t).unwrap(), + 12 + ); + assert!(newly_created_source_output(&previous, &previous).is_err()); + + let ambiguous = [ + PactlSourceOutput { id: 11, source: 2 }, + PactlSourceOutput { id: 12, source: 2 }, + PactlSourceOutput { id: 13, source: 2 }, + ]; + + assert!(newly_created_source_output(&previous, &ambiguous).is_err()); + } + + #[test] + fn remembered_monitor_input_returns_to_selected_microphone() { + assert_eq!( + previous_source_destination(2, 2, "desktop.monitor", Some("microphone.monitor")), + "microphone.monitor" + ); + assert_eq!( + previous_source_destination(7, 2, "desktop.monitor", Some("microphone.monitor")), + "7" + ); + assert_eq!( + previous_source_destination(2, 2, "desktop.monitor", Some("desktop.monitor")), + "2" + ); + assert_eq!( + previous_source_destination(2, 2, "desktop.monitor", None), + "2" + ); + } + + #[test] + fn correctly_routed_streams_are_not_interrupted() { + assert!(!source_output_needs_move(2, Some(2))); + assert!(source_output_needs_move(2, Some(3))); + assert!(source_output_needs_move(2, None)); + + let sources = "2 desktop.monitor module-null-sink\n3 microphone.monitor module-null-sink"; + assert_eq!(pactl_source_index(sources, "microphone.monitor"), Some(3)); + assert_eq!(pactl_source_index(sources, "missing.monitor"), None); + } +} + +#[cfg(test)] +mod pipewire_frame_tests { + use super::{FrameScaler, VideoInfo, prepare_pipewire_frame}; + + #[test] + fn matching_pipewire_frames_reuse_owned_pixel_storage_without_a_scaler() { + let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRZ, 16, 12); + frame.set_pts(Some(73)); + let source = frame.data(0).as_ptr(); + let output = VideoInfo::from_raw_ffmpeg(ffmpeg::format::Pixel::BGRZ, 16, 12, 60); + let mut scaler: Option = None; + + let prepared = prepare_pipewire_frame(frame, &mut scaler, output).unwrap(); + + assert_eq!(prepared.data(0).as_ptr(), source); + assert_eq!(prepared.format(), ffmpeg::format::Pixel::BGRZ); + assert_eq!(prepared.pts(), Some(73)); + assert!(scaler.is_none()); + } + + #[test] + fn mismatched_pipewire_pixel_formats_are_converted() { + let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBZ, 16, 12); + frame.set_pts(Some(31)); + let output = VideoInfo::from_raw_ffmpeg(ffmpeg::format::Pixel::BGRZ, 16, 12, 60); + let mut scaler: Option = None; + + let prepared = prepare_pipewire_frame(frame, &mut scaler, output).unwrap(); + + assert_eq!(prepared.format(), ffmpeg::format::Pixel::BGRZ); + assert_eq!((prepared.width(), prepared.height()), (16, 12)); + assert_eq!(prepared.pts(), Some(31)); + assert!(scaler.is_some()); + } + + #[test] + fn mismatched_pipewire_dimensions_are_scaled() { + let frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRZ, 16, 12); + let output = VideoInfo::from_raw_ffmpeg(ffmpeg::format::Pixel::BGRZ, 8, 6, 60); + let mut scaler: Option = None; + + let prepared = prepare_pipewire_frame(frame, &mut scaler, output).unwrap(); + + assert_eq!(prepared.format(), ffmpeg::format::Pixel::BGRZ); + assert_eq!((prepared.width(), prepared.height()), (8, 6)); + assert!(scaler.is_some()); + } +} diff --git a/crates/recording/src/sources/screen_capture/macos.rs b/crates/recording/src/sources/screen_capture/macos.rs index 620e70b14ce..462b83a5840 100644 --- a/crates/recording/src/sources/screen_capture/macos.rs +++ b/crates/recording/src/sources/screen_capture/macos.rs @@ -150,6 +150,13 @@ impl output_pipeline::VideoFrame for VideoFrame { fn timestamp(&self) -> Timestamp { self.timestamp } + + fn duplicate(&self) -> Option { + Some(Self { + sample_buf: self.sample_buf.clone(), + timestamp: self.timestamp, + }) + } } impl ScreenCaptureConfig { diff --git a/crates/recording/tests/sync_matrix.rs b/crates/recording/tests/sync_matrix.rs index 882ee3ade8a..8df7dc0ef01 100644 --- a/crates/recording/tests/sync_matrix.rs +++ b/crates/recording/tests/sync_matrix.rs @@ -29,6 +29,7 @@ use cap_timestamp::{Timestamp, Timestamps}; use serde::{Deserialize, Serialize}; const CONTENT_SECS: f64 = 4.0; +const MAX_PRODUCTION_CAPTURE_FPS: u32 = 240; /// Absolute tolerance for a muxed pts vs the sent capture timestamp, /// measured from each side's own origin (first sent frame vs first muxed /// pts). Covers warmup anchoring, emission jitter and encoder rounding, @@ -241,6 +242,11 @@ impl VideoCase { } } +fn minimum_overload_coverage(delivered_fps: u32) -> f64 { + 0.9 * f64::from(MAX_PRODUCTION_CAPTURE_FPS) + / f64::from(delivered_fps.max(MAX_PRODUCTION_CAPTURE_FPS)) +} + async fn run_video_case(case: VideoCase) -> Result { let temp = tempfile::tempdir().map_err(|e| format!("tempdir: {e}"))?; let out_path = if case.fragmented { @@ -277,7 +283,7 @@ async fn run_video_case(case: VideoCase) -> Result { // exist to verify drop handling, not consumer throughput: a weak // runner saturated by the firehose proves nothing, so send blocking // still counts as falling behind there and earns a loud skip. - let overload_case = case.delivered_fps > 240; + let overload_case = case.delivered_fps > MAX_PRODUCTION_CAPTURE_FPS; tokio::spawn(async move { let mut max_late = 0.0f64; let mut last_send_end: Option = None; @@ -391,9 +397,10 @@ async fn run_video_case(case: VideoCase) -> Result { // cannot real-time-encode several hundred fps of worst-case content. // Timestamp correctness is still enforced below on every frame that // was muxed; extra frames or heavy loss always fail. - let overload_case = case.delivered_fps > 240; + let overload_case = case.delivered_fps > MAX_PRODUCTION_CAPTURE_FPS; let coverage = pts.len() as f64 / sent.len() as f64; - if !overload_case || coverage < 0.9 || pts.len() > sent.len() { + let minimum_coverage = minimum_overload_coverage(case.delivered_fps); + if !overload_case || coverage < minimum_coverage || pts.len() > sent.len() { return Err(format!( "frame count mismatch: sent {} frames, container has {} \ (missing sent indices: {})", @@ -1433,6 +1440,15 @@ fn report_chunk_ms_only_trusts_a_narrow_buffer_range() { assert_eq!(report_chunk_ms(0, Some((480, 960))), 20.0); } +#[test] +fn overload_coverage_preserves_supported_capture_throughput() { + assert!((minimum_overload_coverage(240) - 0.9).abs() < f64::EPSILON); + assert!((minimum_overload_coverage(480) - 0.45).abs() < f64::EPSILON); + assert!((minimum_overload_coverage(1_000) - 0.216).abs() < f64::EPSILON); + assert!(1620.0 / 3298.0 >= minimum_overload_coverage(938)); + assert!(700.0 / 3298.0 < minimum_overload_coverage(938)); +} + fn cases_from_report(path: &Path) -> Result, String> { let raw = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; let envelope: ReportEnvelope = @@ -1734,6 +1750,58 @@ async fn run_video_case_with_cold_retry(case: VideoCase) -> Result(8); + let (segment_tx, segment_rx) = std::sync::mpsc::channel(); + let timestamps = Timestamps::now(); + let base = timestamps.instant(); + let mut rng = Rng(7); + + let pipeline = OutputPipeline::builder(temp.path().join("progressive-video")) + .with_video::>(ChannelVideoSourceConfig::new( + info, frame_rx, + )) + .with_timestamps(timestamps) + .build::(SegmentedVideoMuxerConfig { + segment_duration: Duration::from_millis(250), + segment_tx: Some(segment_tx), + ..Default::default() + }) + .await + .expect("segmented video pipeline"); + + for index in 0..90u64 { + frame_tx + .send_async(FFmpegVideoFrame { + inner: make_video_frame(160, 120, index, Content::Motion, &mut rng), + timestamp: Timestamp::Instant(base + Duration::from_millis(index * 33)), + }) + .await + .expect("synthetic video frame"); + } + drop(frame_tx); + + pipeline.stop().await.expect("finalized video pipeline"); + + let events = segment_rx.try_iter().collect::>(); + assert!( + events.iter().any(|event| event.is_init), + "segmented video pipeline did not forward its initialization segment" + ); + assert!( + events.iter().any(|event| !event.is_init), + "segmented video pipeline did not forward a completed media segment" + ); + assert!(events.iter().all(|event| { + event.media_type == cap_enc_ffmpeg::segmented_stream::SegmentMediaType::Video + && event.file_size > 0 + && event.path.is_file() + })); +} + #[tokio::test(flavor = "multi_thread")] async fn synthetic_device_matrix_preserves_sync() { // Silent without RUST_LOG; with it, pipeline drop/stall warnings become diff --git a/crates/rendering/src/decoder/avassetreader.rs b/crates/rendering/src/decoder/avassetreader.rs index fac9e4c0dd2..900b80d073b 100644 --- a/crates/rendering/src/decoder/avassetreader.rs +++ b/crates/rendering/src/decoder/avassetreader.rs @@ -559,13 +559,14 @@ impl AVAssetReaderDecoder { ready_tx: oneshot::Sender>, tokio_handle: tokio::runtime::Handle, ) { - let mut this = match AVAssetReaderDecoder::new(path, tokio_handle) { - Ok(v) => v, - Err(e) => { - ready_tx.send(Err(e)).ok(); - return; - } - }; + let mut this = + match objc2::rc::autoreleasepool(|_| AVAssetReaderDecoder::new(path, tokio_handle)) { + Ok(v) => v, + Err(e) => { + ready_tx.send(Err(e)).ok(); + return; + } + }; let video_width = this.decoders[0].inner.width(); let video_height = this.decoders[0].inner.height(); @@ -606,6 +607,7 @@ impl AVAssetReaderDecoder { let mut deferred_requests = VecDeque::::new(); loop { + let _autorelease_pool = cidre::objc::AutoreleasePoolPage::push(); let mut pending_requests: Vec = Vec::with_capacity(8); let processing_deferred = !deferred_requests.is_empty(); diff --git a/crates/rendering/src/decoder/ffmpeg.rs b/crates/rendering/src/decoder/ffmpeg.rs index f0c0d1b849f..92d27133702 100644 --- a/crates/rendering/src/decoder/ffmpeg.rs +++ b/crates/rendering/src/decoder/ffmpeg.rs @@ -70,6 +70,7 @@ struct PendingRequest { } const MAX_FRAME_LOOKBACK_TOLERANCE: u32 = 2; +const MAX_FRAME_CACHE_BYTES: usize = 128 * 1024 * 1024; fn extract_yuv_planes(frame: &frame::Video) -> Option<(Vec, PixelFormat, u32, u32)> { let height = frame.height(); @@ -201,6 +202,76 @@ enum CachedFrame { }, } +impl CachedFrame { + fn estimated_bytes(&self) -> usize { + match self { + Self::Raw { frame, .. } => { + let height = frame.height() as usize; + match frame.format() { + format::Pixel::YUV420P => { + frame.stride(0).saturating_mul(height).saturating_add( + frame + .stride(1) + .saturating_add(frame.stride(2)) + .saturating_mul(height / 2), + ) + } + format::Pixel::NV12 => frame + .stride(0) + .saturating_mul(height) + .saturating_add(frame.stride(1).saturating_mul(height / 2)), + _ => (frame.width() as usize) + .saturating_mul(height) + .saturating_mul(4), + } + } + Self::Processed(frame) => frame.data.len(), + #[cfg(target_os = "windows")] + Self::Gpu { frame, .. } => (frame.width() as usize) + .saturating_mul(frame.height() as usize) + .saturating_mul(3), + } + } +} + +fn insert_cached_frame( + cache: &mut BTreeMap, + number: u32, + frame: CachedFrame, + requested_frame: u32, + last_active_frame: Option, + max_bytes: usize, +) { + let frame_bytes = frame.estimated_bytes(); + let mut cache_bytes = cache + .values() + .map(CachedFrame::estimated_bytes) + .fold(0usize, usize::saturating_add); + + while !cache.is_empty() + && (cache.len() >= FRAME_CACHE_SIZE || cache_bytes.saturating_add(frame_bytes) > max_bytes) + { + let Some(last_active_frame) = last_active_frame else { + cache.clear(); + break; + }; + let first = *cache.keys().next().unwrap(); + let last = *cache.keys().next_back().unwrap(); + let evicted = if requested_frame > last_active_frame { + first + } else if requested_frame < last_active_frame || number <= last { + last + } else { + first + }; + if let Some(frame) = cache.remove(&evicted) { + cache_bytes = cache_bytes.saturating_sub(frame.estimated_bytes()); + } + } + + cache.insert(number, frame); +} + pub struct FfmpegDecoder; impl FfmpegDecoder { @@ -571,24 +642,14 @@ impl FfmpegDecoder { { cache_frame.produce(&mut sw_converter); - if sw_cache.len() >= FRAME_CACHE_SIZE { - if let Some(last_active_frame) = &sw_last_active_frame { - let frame = if requested_frame > *last_active_frame { - *sw_cache.keys().next().unwrap() - } else if requested_frame < *last_active_frame { - *sw_cache.keys().next_back().unwrap() - } else { - let min = *sw_cache.keys().min().unwrap(); - let max = *sw_cache.keys().max().unwrap(); - if current_frame > max { min } else { max } - }; - sw_cache.remove(&frame); - } else { - sw_cache.clear() - } - } - - sw_cache.insert(current_frame, cache_frame); + insert_cached_frame( + &mut sw_cache, + current_frame, + cache_frame, + requested_frame, + sw_last_active_frame, + MAX_FRAME_CACHE_BYTES, + ); // Serve exact matches from the cache so // sequentially played frames stay available as @@ -1032,26 +1093,14 @@ impl FfmpegDecoder { { cache_frame.produce(&mut converter); - if cache.len() >= FRAME_CACHE_SIZE { - if let Some(last_active_frame) = &last_active_frame { - let frame = if requested_frame > *last_active_frame { - *cache.keys().next().unwrap() - } else if requested_frame < *last_active_frame { - *cache.keys().next_back().unwrap() - } else { - let min = *cache.keys().min().unwrap(); - let max = *cache.keys().max().unwrap(); - - if current_frame > max { min } else { max } - }; - - cache.remove(&frame); - } else { - cache.clear() - } - } - - cache.insert(current_frame, cache_frame); + insert_cached_frame( + &mut cache, + current_frame, + cache_frame, + requested_frame, + last_active_frame, + MAX_FRAME_CACHE_BYTES, + ); // Serve exact matches from the cache so // sequentially played frames stay available as @@ -1216,6 +1265,69 @@ impl FfmpegDecoder { } } +#[cfg(test)] +mod cache_tests { + use super::*; + + fn frame(number: u32, bytes: usize) -> CachedFrame { + CachedFrame::Processed(ProcessedFrame { + number, + data: Arc::new(vec![0; bytes]), + width: 2, + height: 2, + format: PixelFormat::Nv12, + y_stride: 2, + uv_stride: 2, + }) + } + + #[test] + fn forward_playback_keeps_recent_frames_within_byte_budget() { + let mut cache = BTreeMap::new(); + + for number in 0..5 { + insert_cached_frame(&mut cache, number, frame(number, 6), 5, Some(4), 13); + } + + assert_eq!(cache.keys().copied().collect::>(), vec![3, 4]); + assert_eq!( + cache + .values() + .map(CachedFrame::estimated_bytes) + .sum::(), + 12 + ); + } + + #[test] + fn backward_seek_keeps_earliest_frames_within_byte_budget() { + let mut cache = BTreeMap::new(); + + for number in [5, 4, 3, 2] { + insert_cached_frame(&mut cache, number, frame(number, 5), 2, Some(6), 11); + } + + assert_eq!(cache.keys().copied().collect::>(), vec![2, 3]); + assert_eq!( + cache + .values() + .map(CachedFrame::estimated_bytes) + .sum::(), + 10 + ); + } + + #[test] + fn oversize_frame_remains_available_without_retaining_older_frames() { + let mut cache = BTreeMap::new(); + insert_cached_frame(&mut cache, 1, frame(1, 4), 1, Some(1), 8); + insert_cached_frame(&mut cache, 2, frame(2, 12), 2, Some(1), 8); + + assert_eq!(cache.keys().copied().collect::>(), vec![2]); + assert_eq!(cache[&2].estimated_bytes(), 12); + } +} + // pub fn find_decoder( // s: &format::context::Input, // st: &format::stream::Stream, diff --git a/crates/rendering/src/decoder/media_foundation.rs b/crates/rendering/src/decoder/media_foundation.rs index 263dbf8b041..e98262d962d 100644 --- a/crates/rendering/src/decoder/media_foundation.rs +++ b/crates/rendering/src/decoder/media_foundation.rs @@ -12,6 +12,8 @@ use windows::Win32::{Foundation::HANDLE, Graphics::Direct3D11::ID3D11Texture2D}; use super::{DecodedFrame, DecoderInitResult, DecoderType, FRAME_CACHE_SIZE, VideoDecoderMessage}; +const MAX_FRAME_CACHE_BYTES: usize = 128 * 1024 * 1024; + struct DecoderHealthMonitor { consecutive_errors: u32, consecutive_texture_read_failures: u32, @@ -123,7 +125,7 @@ impl DecoderHealthMonitor { #[derive(Clone)] struct CachedFrame { number: u32, - _texture: ID3D11Texture2D, + _texture: Option, _shared_handle: Option, _y_handle: Option, _uv_handle: Option, @@ -133,15 +135,26 @@ struct CachedFrame { } impl CachedFrame { + fn estimated_bytes(&self) -> usize { + let texture_bytes = self._texture.as_ref().map_or(0, |_| { + (self.width as usize) + .saturating_mul(self.height as usize) + .saturating_mul(3) + }); + texture_bytes.saturating_add(self.nv12_data.as_ref().map_or(0, |data| data.data.len())) + } + fn to_decoded_frame(&self) -> DecodedFrame { let null_ptr = std::ptr::null_mut(); let y_handle = self._y_handle.filter(|h| h.0 != null_ptr); let uv_handle = self._uv_handle.filter(|h| h.0 != null_ptr); - if let (Some(y_handle), Some(uv_handle)) = (y_handle, uv_handle) { + if let (Some(texture), Some(y_handle), Some(uv_handle)) = + (&self._texture, y_handle, uv_handle) + { return DecodedFrame::new_nv12_with_d3d11_texture_and_yuv_handles( self.width, self.height, - self._texture.clone(), + texture.clone(), self._shared_handle, Some(y_handle), Some(uv_handle), @@ -149,8 +162,8 @@ impl CachedFrame { } if let Some(nv12_data) = &self.nv12_data { - DecodedFrame::new_nv12( - nv12_data.data.clone(), + DecodedFrame::new_nv12_with_arc( + Arc::clone(&nv12_data.data), self.width, self.height, nv12_data.y_stride, @@ -218,6 +231,7 @@ impl MFDecoder { let video_height = decoder.height(); let mut cache = BTreeMap::::new(); + let mut cache_bytes = 0usize; let mut last_decoded_frame: Option = None; let mut health = DecoderHealthMonitor::new(); @@ -324,6 +338,7 @@ impl MFDecoder { warn!("MediaFoundation seek failed: {e}"); } cache.clear(); + cache_bytes = 0; last_decoded_frame = None; } @@ -393,27 +408,47 @@ impl MFDecoder { let cached = CachedFrame { number: frame_number, - _texture: mf_frame.textures.nv12.texture.clone(), - _shared_handle: Some(mf_frame.textures.nv12.handle), - _y_handle: Some(mf_frame.textures.y.handle), - _uv_handle: Some(mf_frame.textures.uv.handle), + _texture: has_valid_zero_copy_handles + .then(|| mf_frame.textures.nv12.texture.clone()), + _shared_handle: has_valid_zero_copy_handles + .then_some(mf_frame.textures.nv12.handle), + _y_handle: has_valid_zero_copy_handles + .then_some(mf_frame.textures.y.handle), + _uv_handle: has_valid_zero_copy_handles + .then_some(mf_frame.textures.uv.handle), nv12_data, width: mf_frame.width, height: mf_frame.height, }; + if !has_valid_zero_copy_handles { + decoder.recycle_textures(mf_frame.textures); + } + last_decoded_frame = Some(frame_number); if frame_number >= cache_min && frame_number <= cache_max { - if cache.len() >= FRAME_CACHE_SIZE { + let frame_bytes = cached.estimated_bytes(); + while !cache.is_empty() + && (cache.len() >= FRAME_CACHE_SIZE + || cache_bytes.saturating_add(frame_bytes) + > MAX_FRAME_CACHE_BYTES) + { let key_to_remove = if frame_number > requested_frame { *cache.keys().next().unwrap() } else { *cache.keys().next_back().unwrap() }; - cache.remove(&key_to_remove); + if let Some(evicted) = cache.remove(&key_to_remove) { + cache_bytes = + cache_bytes.saturating_sub(evicted.estimated_bytes()); + } + } + if let Some(replaced) = cache.insert(frame_number, cached.clone()) { + cache_bytes = + cache_bytes.saturating_sub(replaced.estimated_bytes()); } - cache.insert(frame_number, cached.clone()); + cache_bytes = cache_bytes.saturating_add(frame_bytes); } if frame_number <= requested_frame { diff --git a/crates/rendering/src/frame_pipeline.rs b/crates/rendering/src/frame_pipeline.rs index 53cda698eb9..46e6b03c5e6 100644 --- a/crates/rendering/src/frame_pipeline.rs +++ b/crates/rendering/src/frame_pipeline.rs @@ -1340,6 +1340,16 @@ pub struct PendingReadback { frame_rate: u32, } +fn active_readback_byte_len( + padded_bytes_per_row: usize, + height: usize, + mapped_bytes: usize, +) -> Option { + padded_bytes_per_row + .checked_mul(height) + .filter(|&active_bytes| active_bytes > 0 && active_bytes <= mapped_bytes) +} + impl PendingReadback { fn cancel(&self) -> RenderingError { self.buffer.unmap(); @@ -1396,9 +1406,23 @@ impl PendingReadback { } } - let buffer_slice = self.buffer.slice(..); + let Some(active_bytes) = + usize::try_from(self.buffer.size()) + .ok() + .and_then(|buffer_bytes| { + active_readback_byte_len( + self.padded_bytes_per_row as usize, + self.height as usize, + buffer_bytes, + ) + }) + else { + self.buffer.unmap(); + return Err(RenderingError::BufferMapWaitingFailed); + }; + let buffer_slice = self.buffer.slice(..active_bytes as u64); let data = buffer_slice.get_mapped_range(); - let mut data_vec = Vec::with_capacity(data.len() + 24); + let mut data_vec = Vec::with_capacity(active_bytes + 24); data_vec.extend_from_slice(&data); drop(data); @@ -1516,7 +1540,8 @@ impl PipelinedGpuReadback { mut render_encoder: wgpu::CommandEncoder, ) -> Result<(), RenderingError> { let padded_bytes_per_row = padded_bytes_per_row(uniforms.output_size); - let output_buffer_size = (padded_bytes_per_row * uniforms.output_size.1) as u64; + let output_buffer_size = + u64::from(padded_bytes_per_row) * u64::from(uniforms.output_size.1); self.ensure_size(device, output_buffer_size); let buffer = self.next_buffer(); @@ -1549,7 +1574,7 @@ impl PipelinedGpuReadback { let (tx, rx) = oneshot::channel(); buffer - .slice(..) + .slice(..output_buffer_size) .map_async(wgpu::MapMode::Read, move |result| { if let Err(e) = tx.send(result) { tracing::error!("Failed to send map_async result: {:?}", e); @@ -1899,6 +1924,57 @@ pub async fn flush_pending_readback( } } +#[cfg(test)] +mod readback_output_tests { + use super::active_readback_byte_len; + + #[test] + fn oversized_readback_buffers_only_include_active_rows() { + assert_eq!( + active_readback_byte_len(2_048, 270, 3_594_240), + Some(552_960) + ); + } + + #[test] + fn exact_readback_buffers_include_every_row() { + assert_eq!( + active_readback_byte_len(5_120, 702, 3_594_240), + Some(3_594_240) + ); + } + + #[test] + fn undersized_readback_buffers_are_rejected() { + assert_eq!(active_readback_byte_len(2_048, 270, 552_959), None); + } + + #[test] + fn overflowing_readback_dimensions_are_rejected() { + assert_eq!(active_readback_byte_len(usize::MAX, 2, usize::MAX), None); + } + + #[test] + fn pooled_readback_buffers_preserve_grow_shrink_grow_frame_lengths() { + let buffer_bytes = 3_594_240; + let frame_lengths = [(5_120, 702), (2_048, 270), (5_120, 702)] + .into_iter() + .map(|(stride, height)| active_readback_byte_len(stride, height, buffer_bytes)) + .collect::>(); + + assert_eq!( + frame_lengths, + vec![Some(3_594_240), Some(552_960), Some(3_594_240)] + ); + } + + #[test] + fn empty_readback_dimensions_do_not_access_the_buffer() { + assert_eq!(active_readback_byte_len(2_048, 0, 3_594_240), None); + assert_eq!(active_readback_byte_len(0, 270, 3_594_240), None); + } +} + #[cfg(all(test, target_os = "macos"))] mod surface_output_tests { use super::*; diff --git a/crates/rendering/src/iosurface_texture.rs b/crates/rendering/src/iosurface_texture.rs index bd27f6ddea7..8860e5f0390 100644 --- a/crates/rendering/src/iosurface_texture.rs +++ b/crates/rendering/src/iosurface_texture.rs @@ -60,18 +60,13 @@ impl IOSurfaceTextureCache { height: u32, usage: mtl::TextureUsage, ) -> Result, IOSurfaceTextureError> { - let mut desc = mtl::TextureDesc::new_2d( + self.create_surface_texture( + io_surface, + (width, height), mtl::PixelFormat::R8UNorm, - width as usize, - height as usize, - false, - ); - desc.set_storage_mode(mtl::StorageMode::Shared); - desc.set_usage(usage); - - self.metal_device - .new_texture_with_surf(&desc, io_surface, 0) - .ok_or(IOSurfaceTextureError::TextureCreationFailed) + 0, + usage, + ) } pub fn create_uv_texture( @@ -90,18 +85,13 @@ impl IOSurfaceTextureCache { height: u32, usage: mtl::TextureUsage, ) -> Result, IOSurfaceTextureError> { - let mut desc = mtl::TextureDesc::new_2d( + self.create_surface_texture( + io_surface, + (width / 2, height / 2), mtl::PixelFormat::Rg8UNorm, - (width / 2) as usize, - (height / 2) as usize, - false, - ); - desc.set_storage_mode(mtl::StorageMode::Shared); - desc.set_usage(usage); - - self.metal_device - .new_texture_with_surf(&desc, io_surface, 1) - .ok_or(IOSurfaceTextureError::TextureCreationFailed) + 1, + usage, + ) } pub fn create_bgra_texture( @@ -125,18 +115,13 @@ impl IOSurfaceTextureCache { height: u32, usage: mtl::TextureUsage, ) -> Result, IOSurfaceTextureError> { - let mut desc = mtl::TextureDesc::new_2d( + self.create_surface_texture( + io_surface, + (width, height), mtl::PixelFormat::Bgra8UNorm, - width as usize, - height as usize, - false, - ); - desc.set_storage_mode(mtl::StorageMode::Shared); - desc.set_usage(usage); - - self.metal_device - .new_texture_with_surf(&desc, io_surface, 0) - .ok_or(IOSurfaceTextureError::TextureCreationFailed) + 0, + usage, + ) } pub fn create_rgba_texture( @@ -145,18 +130,33 @@ impl IOSurfaceTextureCache { width: u32, height: u32, ) -> Result, IOSurfaceTextureError> { - let mut desc = mtl::TextureDesc::new_2d( + self.create_surface_texture( + io_surface, + (width, height), mtl::PixelFormat::Rgba8UNorm, - width as usize, - height as usize, - false, - ); - desc.set_storage_mode(mtl::StorageMode::Shared); - desc.set_usage(mtl::TextureUsage::SHADER_READ); + 0, + mtl::TextureUsage::SHADER_READ, + ) + } + + fn create_surface_texture( + &self, + io_surface: &io::Surf, + (width, height): (u32, u32), + pixel_format: mtl::PixelFormat, + plane: usize, + usage: mtl::TextureUsage, + ) -> Result, IOSurfaceTextureError> { + objc2::rc::autoreleasepool(|_| { + let mut descriptor = + mtl::TextureDesc::new_2d(pixel_format, width as usize, height as usize, false); + descriptor.set_storage_mode(mtl::StorageMode::Shared); + descriptor.set_usage(usage); - self.metal_device - .new_texture_with_surf(&desc, io_surface, 0) - .ok_or(IOSurfaceTextureError::TextureCreationFailed) + self.metal_device + .new_texture_with_surf(&descriptor, io_surface, plane) + .ok_or(IOSurfaceTextureError::TextureCreationFailed) + }) } } diff --git a/crates/scap-targets/src/platform/macos.rs b/crates/scap-targets/src/platform/macos.rs index df961872eb2..699ad57ca17 100644 --- a/crates/scap-targets/src/platform/macos.rs +++ b/crates/scap-targets/src/platform/macos.rs @@ -510,7 +510,7 @@ impl WindowImpl { pub fn app_icon(&self) -> Option> { use cocoa::base::{id, nil}; - use cocoa::foundation::{NSArray, NSAutoreleasePool, NSString}; + use cocoa::foundation::{NSArray, NSAutoreleasePool, NSPoint, NSRect, NSSize, NSString}; use objc::{class, msg_send, sel, sel_impl}; let owner_name = self.owner_name()?; @@ -549,16 +549,24 @@ impl WindowImpl { return None; } - let tiff_data: id = msg_send![icon, TIFFRepresentation]; - if tiff_data.is_null() { + let mut bounds = NSRect::new(NSPoint::new(0., 0.), NSSize::new(128., 128.)); + let image: *mut std::ffi::c_void = msg_send![ + icon, + CGImageForProposedRect: &mut bounds + context: nil + hints: nil + ]; + if image.is_null() { return None; } let bitmap_rep_class = class!(NSBitmapImageRep); - let bitmap_rep: id = msg_send![bitmap_rep_class, imageRepWithData: tiff_data]; + let bitmap_rep: id = msg_send![bitmap_rep_class, alloc]; + let bitmap_rep: id = msg_send![bitmap_rep, initWithCGImage: image]; if bitmap_rep.is_null() { return None; } + let bitmap_rep: id = msg_send![bitmap_rep, autorelease]; let png_data: id = msg_send![ bitmap_rep, diff --git a/crates/video-decode/src/media_foundation.rs b/crates/video-decode/src/media_foundation.rs index 83bb3a86dc4..9e41c2d2a1a 100644 --- a/crates/video-decode/src/media_foundation.rs +++ b/crates/video-decode/src/media_foundation.rs @@ -144,7 +144,7 @@ pub struct MFDecodedFrame { } pub struct NV12Data { - pub data: Vec, + pub data: Arc>, pub y_stride: u32, pub uv_stride: u32, } @@ -731,7 +731,7 @@ impl MediaFoundationDecoder { } Ok(NV12Data { - data, + data: Arc::new(data), y_stride, uv_stride: y_stride, }) @@ -825,12 +825,14 @@ impl MediaFoundationDecoder { None, ); - self.plane_converter.convert( - &frame_textures.nv12.texture, - &frame_textures, - self.width, - self.height, - )?; + if !frame_textures.y.handle.0.is_null() && !frame_textures.uv.handle.0.is_null() { + self.plane_converter.convert( + &frame_textures.nv12.texture, + &frame_textures, + self.width, + self.height, + )?; + } } let plane_time = plane_start.elapsed(); @@ -1031,11 +1033,7 @@ unsafe fn create_source_reader( .map_err(|e| format!("SetUINT32 ENABLE_ADVANCED_VIDEO_PROCESSING failed: {e:?}"))?; } - let path_wide: Vec = path - .to_string_lossy() - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); + let path_wide = media_foundation_path(path); let source_reader = unsafe { MFCreateSourceReaderFromURL(PCWSTR(path_wide.as_ptr()), &attributes) @@ -1045,6 +1043,23 @@ unsafe fn create_source_reader( Ok(source_reader) } +fn media_foundation_path(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + + let mut path: Vec = path.as_os_str().encode_wide().collect(); + let extended_unc = [92, 92, 63, 92, 85, 78, 67, 92]; + let extended_drive = [92, 92, 63, 92]; + + if path.starts_with(&extended_unc) { + path.splice(..extended_unc.len(), [92, 92]); + } else if path.starts_with(&extended_drive) { + path.drain(..extended_drive.len()); + } + + path.push(0); + path +} + unsafe fn configure_output_type(source_reader: &IMFSourceReader) -> Result<(), String> { let media_type = unsafe { MFCreateMediaType().map_err(|e| format!("MFCreateMediaType failed: {e:?}"))? }; @@ -1099,3 +1114,35 @@ unsafe fn get_video_info(source_reader: &IMFSourceReader) -> Result<(u32, u32, u } unsafe impl Send for MediaFoundationDecoder {} + +#[cfg(test)] +mod tests { + use super::*; + + fn normalized(path: &str) -> String { + let encoded = media_foundation_path(Path::new(path)); + assert_eq!(encoded.last(), Some(&0)); + String::from_utf16(&encoded[..encoded.len().saturating_sub(1)]).unwrap() + } + + #[test] + fn removes_extended_drive_prefix_for_media_foundation() { + assert_eq!( + normalized(r"\\?\C:\Recordings\clip.mp4"), + r"C:\Recordings\clip.mp4" + ); + } + + #[test] + fn restores_unc_prefix_for_media_foundation() { + assert_eq!( + normalized(r"\\?\UNC\server\recordings\clip.mp4"), + r"\\server\recordings\clip.mp4" + ); + } + + #[test] + fn preserves_regular_unicode_paths() { + assert_eq!(normalized(r"C:\Vidéos\录制.mp4"), r"C:\Vidéos\录制.mp4"); + } +} diff --git a/package.json b/package.json index 55c4e1879e7..1ed467b9730 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "docker:up": "turbo run docker:up", "format": "pnpm exec biome check --write", "lint": "pnpm exec biome lint", - "tauri:build": "node scripts/build-desktop-binaries.mjs && dotenv -e .env -- pnpm --dir apps/desktop run preparescript && dotenv -e .env -- pnpm --dir apps/desktop tauri build --verbose", + "tauri:build": "dotenv -e .env -- pnpm --dir apps/desktop run build:tauri --config src-tauri/tauri.prod.conf.json --verbose", "typecheck": "pnpm --dir apps/web exec next typegen && pnpm tsc -b", "test": "turbo run test", "test:web": "pnpm --filter=@cap/web test", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b18a2739cc..6cc74db422f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,8 +302,8 @@ importers: specifier: ^0.5.9 version: 0.5.20(tailwindcss@3.4.19(yaml@2.9.0)) '@tauri-apps/cli': - specifier: '>=2.1.0' - version: 2.11.4 + specifier: 2.8.4 + version: 2.8.4 '@total-typescript/ts-reset': specifier: ^0.6.1 version: 0.6.1 @@ -7662,8 +7662,8 @@ packages: cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-arm64@2.11.4': - resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} + '@tauri-apps/cli-darwin-arm64@2.8.4': + resolution: {integrity: sha512-BKu8HRkYV01SMTa7r4fLx+wjgtRK8Vep7lmBdHDioP6b8XH3q2KgsAyPWfEZaZIkZ2LY4SqqGARaE9oilNe0oA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -7674,8 +7674,8 @@ packages: cpu: [x64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.11.4': - resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} + '@tauri-apps/cli-darwin-x64@2.8.4': + resolution: {integrity: sha512-imb9PfSd/7G6VAO7v1bQ2A3ZH4NOCbhGJFLchxzepGcXf9NKkfun157JH9mko29K6sqAwuJ88qtzbKCbWJTH9g==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -7686,8 +7686,8 @@ packages: cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': - resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.8.4': + resolution: {integrity: sha512-Ml215UnDdl7/fpOrF1CNovym/KjtUbCuPgrcZ4IhqUCnhZdXuphud/JT3E8X97Y03TZ40Sjz8raXYI2ET0exzw==} engines: {node: '>= 10'} cpu: [arm] os: [linux] @@ -7698,8 +7698,8 @@ packages: cpu: [arm64] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.11.4': - resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} + '@tauri-apps/cli-linux-arm64-gnu@2.8.4': + resolution: {integrity: sha512-pbcgBpMyI90C83CxE5REZ9ODyIlmmAPkkJXtV398X3SgZEIYy5TACYqlyyv2z5yKgD8F8WH4/2fek7+jH+ZXAw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -7710,14 +7710,14 @@ packages: cpu: [arm64] os: [linux] - '@tauri-apps/cli-linux-arm64-musl@2.11.4': - resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} + '@tauri-apps/cli-linux-arm64-musl@2.8.4': + resolution: {integrity: sha512-zumFeaU1Ws5Ay872FTyIm7z8kfzEHu8NcIn8M6TxbJs0a7GRV21KBdpW1zNj2qy7HynnpQCqjAYXTUUmm9JAOw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': - resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} + '@tauri-apps/cli-linux-riscv64-gnu@2.8.4': + resolution: {integrity: sha512-qiqbB3Zz6IyO201f+1ojxLj65WYj8mixL5cOMo63nlg8CIzsP23cPYUrx1YaDPsCLszKZo7tVs14pc7BWf+/aQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] @@ -7728,8 +7728,8 @@ packages: cpu: [x64] os: [linux] - '@tauri-apps/cli-linux-x64-gnu@2.11.4': - resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} + '@tauri-apps/cli-linux-x64-gnu@2.8.4': + resolution: {integrity: sha512-TaqaDd9Oy6k45Hotx3pOf+pkbsxLaApv4rGd9mLuRM1k6YS/aw81YrsMryYPThrxrScEIUcmNIHaHsLiU4GMkw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -7740,8 +7740,8 @@ packages: cpu: [x64] os: [linux] - '@tauri-apps/cli-linux-x64-musl@2.11.4': - resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} + '@tauri-apps/cli-linux-x64-musl@2.8.4': + resolution: {integrity: sha512-ot9STAwyezN8w+bBHZ+bqSQIJ0qPZFlz/AyscpGqB/JnJQVDFQcRDmUPFEaAtt2UUHSWzN3GoTJ5ypqLBp2WQA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -7752,8 +7752,8 @@ packages: cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-arm64-msvc@2.11.4': - resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} + '@tauri-apps/cli-win32-arm64-msvc@2.8.4': + resolution: {integrity: sha512-+2aJ/g90dhLiOLFSD1PbElXX3SoMdpO7HFPAZB+xot3CWlAZD1tReUFy7xe0L5GAR16ZmrxpIDM9v9gn5xRy/w==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -7764,8 +7764,8 @@ packages: cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.11.4': - resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} + '@tauri-apps/cli-win32-ia32-msvc@2.8.4': + resolution: {integrity: sha512-yj7WDxkL1t9Uzr2gufQ1Hl7hrHuFKTNEOyascbc109EoiAqCp0tgZ2IykQqOZmZOHU884UAWI1pVMqBhS/BfhA==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] @@ -7776,8 +7776,8 @@ packages: cpu: [x64] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.11.4': - resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} + '@tauri-apps/cli-win32-x64-msvc@2.8.4': + resolution: {integrity: sha512-XuvGB4ehBdd7QhMZ9qbj/8icGEatDuBNxyYHbLKsTYh90ggUlPa/AtaqcC1Fo69lGkTmq9BOKrs1aWSi7xDonA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -7787,8 +7787,8 @@ packages: engines: {node: '>= 10'} hasBin: true - '@tauri-apps/cli@2.11.4': - resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} + '@tauri-apps/cli@2.8.4': + resolution: {integrity: sha512-ejUZBzuQRcjFV+v/gdj/DcbyX/6T4unZQjMSBZwLzP/CymEjKcc2+Fc8xTORThebHDUvqoXMdsCZt8r+hyN15g==} engines: {node: '>= 10'} hasBin: true @@ -23356,64 +23356,64 @@ snapshots: '@tauri-apps/cli-darwin-arm64@1.6.3': optional: true - '@tauri-apps/cli-darwin-arm64@2.11.4': + '@tauri-apps/cli-darwin-arm64@2.8.4': optional: true '@tauri-apps/cli-darwin-x64@1.6.3': optional: true - '@tauri-apps/cli-darwin-x64@2.11.4': + '@tauri-apps/cli-darwin-x64@2.8.4': optional: true '@tauri-apps/cli-linux-arm-gnueabihf@1.6.3': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + '@tauri-apps/cli-linux-arm-gnueabihf@2.8.4': optional: true '@tauri-apps/cli-linux-arm64-gnu@1.6.3': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + '@tauri-apps/cli-linux-arm64-gnu@2.8.4': optional: true '@tauri-apps/cli-linux-arm64-musl@1.6.3': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.11.4': + '@tauri-apps/cli-linux-arm64-musl@2.8.4': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + '@tauri-apps/cli-linux-riscv64-gnu@2.8.4': optional: true '@tauri-apps/cli-linux-x64-gnu@1.6.3': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.11.4': + '@tauri-apps/cli-linux-x64-gnu@2.8.4': optional: true '@tauri-apps/cli-linux-x64-musl@1.6.3': optional: true - '@tauri-apps/cli-linux-x64-musl@2.11.4': + '@tauri-apps/cli-linux-x64-musl@2.8.4': optional: true '@tauri-apps/cli-win32-arm64-msvc@1.6.3': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + '@tauri-apps/cli-win32-arm64-msvc@2.8.4': optional: true '@tauri-apps/cli-win32-ia32-msvc@1.6.3': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + '@tauri-apps/cli-win32-ia32-msvc@2.8.4': optional: true '@tauri-apps/cli-win32-x64-msvc@1.6.3': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.11.4': + '@tauri-apps/cli-win32-x64-msvc@2.8.4': optional: true '@tauri-apps/cli@1.6.3': @@ -23431,19 +23431,19 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 1.6.3 '@tauri-apps/cli-win32-x64-msvc': 1.6.3 - '@tauri-apps/cli@2.11.4': + '@tauri-apps/cli@2.8.4': optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.11.4 - '@tauri-apps/cli-darwin-x64': 2.11.4 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 - '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 - '@tauri-apps/cli-linux-arm64-musl': 2.11.4 - '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 - '@tauri-apps/cli-linux-x64-gnu': 2.11.4 - '@tauri-apps/cli-linux-x64-musl': 2.11.4 - '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 - '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 - '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + '@tauri-apps/cli-darwin-arm64': 2.8.4 + '@tauri-apps/cli-darwin-x64': 2.8.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.8.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.8.4 + '@tauri-apps/cli-linux-arm64-musl': 2.8.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.8.4 + '@tauri-apps/cli-linux-x64-gnu': 2.8.4 + '@tauri-apps/cli-linux-x64-musl': 2.8.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.8.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.8.4 + '@tauri-apps/cli-win32-x64-msvc': 2.8.4 '@tauri-apps/plugin-clipboard-manager@2.3.2': dependencies: diff --git a/scripts/build-desktop-binaries-cache.mjs b/scripts/build-desktop-binaries-cache.mjs new file mode 100644 index 00000000000..4131b41c530 --- /dev/null +++ b/scripts/build-desktop-binaries-cache.mjs @@ -0,0 +1,79 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +async function newestMtimeMs(targetPath) { + let newest = 0; + const pending = [targetPath]; + while (pending.length > 0) { + const current = pending.pop(); + const stat = await fs.stat(current).catch(() => null); + if (!stat) continue; + if (stat.isFile()) { + newest = Math.max(newest, stat.mtimeMs); + continue; + } + if (stat.isDirectory()) { + const entries = await fs.readdir(current); + for (const name of entries) pending.push(path.join(current, name)); + } + } + return newest; +} + +async function sha256(filePath) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(filePath)) hash.update(chunk); + return hash.digest("hex"); +} + +export async function releaseBinaryMatchesDebugBinary( + releaseBinary, + debugBinaries, +) { + const releaseStat = await fs.stat(releaseBinary).catch(() => null); + if (!releaseStat?.isFile()) return false; + + let releaseHash; + for (const debugBinary of debugBinaries) { + const debugStat = await fs.stat(debugBinary).catch(() => null); + if (!debugStat?.isFile() || debugStat.size !== releaseStat.size) continue; + releaseHash ??= await sha256(releaseBinary); + if ((await sha256(debugBinary)) === releaseHash) return true; + } + return false; +} + +export async function stagedBinariesAreCurrent( + releaseBinary, + stagedBinaries, + watchPaths, + debugBinaries = [], +) { + const releaseStat = await fs.stat(releaseBinary).catch(() => null); + if (!releaseStat?.isFile()) return false; + if (await releaseBinaryMatchesDebugBinary(releaseBinary, debugBinaries)) + return false; + + const newestSource = Math.max( + 0, + ...(await Promise.all(watchPaths.map(newestMtimeMs))), + ); + if (newestSource > releaseStat.mtimeMs) return false; + + const releaseHash = await sha256(releaseBinary); + const stagedMatches = await Promise.all( + stagedBinaries.map(async (stagedBinary) => { + const stat = await fs.stat(stagedBinary).catch(() => null); + if ( + !stat?.isFile() || + stat.mtimeMs < newestSource || + stat.size !== releaseStat.size + ) + return false; + return (await sha256(stagedBinary)) === releaseHash; + }), + ); + return stagedMatches.every(Boolean); +} diff --git a/scripts/build-desktop-binaries-cache.test.mjs b/scripts/build-desktop-binaries-cache.test.mjs new file mode 100644 index 00000000000..bea865d0cbc --- /dev/null +++ b/scripts/build-desktop-binaries-cache.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test from "node:test"; + +import { + releaseBinaryMatchesDebugBinary, + stagedBinariesAreCurrent, +} from "./build-desktop-binaries-cache.mjs"; + +async function fixture(context) { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), "cap-sidecar-cache-"), + ); + context.after(async () => fs.rm(directory, { recursive: true, force: true })); + const watched = path.join(directory, "source.rs"); + const release = path.join(directory, "release.exe"); + const cli = path.join(directory, "cap-cli.exe"); + const exporter = path.join(directory, "cap-exporter.exe"); + await fs.writeFile(watched, "source"); + await fs.writeFile(release, "optimized"); + await fs.writeFile(cli, "optimized"); + await fs.writeFile(exporter, "optimized"); + const sourceTime = new Date("2025-01-01T00:00:00.000Z"); + const binaryTime = new Date("2025-01-02T00:00:00.000Z"); + await fs.utimes(watched, sourceTime, sourceTime); + for (const file of [release, cli, exporter]) + await fs.utimes(file, binaryTime, binaryTime); + return { watched, release, cli, exporter, binaryTime }; +} + +test("rejects a debug binary copied into the release artifact path", async (context) => { + const { watched, release, cli, exporter } = await fixture(context); + const debug = path.join(path.dirname(release), "debug.exe"); + await fs.writeFile(debug, "optimized"); + + assert.equal(await releaseBinaryMatchesDebugBinary(release, [debug]), true); + assert.equal( + await stagedBinariesAreCurrent( + release, + [cli, exporter], + [watched], + [debug], + ), + false, + ); +}); + +test("accepts an optimized release artifact when a different debug binary exists", async (context) => { + const { watched, release, cli, exporter } = await fixture(context); + const debug = path.join(path.dirname(release), "debug.exe"); + await fs.writeFile(debug, "debug-one"); + + assert.equal(await releaseBinaryMatchesDebugBinary(release, [debug]), false); + assert.equal( + await stagedBinariesAreCurrent( + release, + [cli, exporter], + [watched], + [debug], + ), + true, + ); +}); + +test("accepts current release binary and byte-identical staged sidecars", async (context) => { + const { watched, release, cli, exporter } = await fixture(context); + assert.equal( + await stagedBinariesAreCurrent(release, [cli, exporter], [watched]), + true, + ); +}); + +test("rejects staged sidecars when the release binary does not exist", async (context) => { + const { watched, release, cli, exporter } = await fixture(context); + await fs.rm(release); + assert.equal( + await stagedBinariesAreCurrent(release, [cli, exporter], [watched]), + false, + ); +}); + +test("rejects same-sized debug content even when staged files are newer", async (context) => { + const { watched, release, cli, exporter, binaryTime } = + await fixture(context); + await fs.writeFile(cli, "debug-old"); + await fs.utimes(cli, binaryTime, binaryTime); + assert.equal( + await stagedBinariesAreCurrent(release, [cli, exporter], [watched]), + false, + ); +}); + +test("rejects a release binary older than watched source", async (context) => { + const { watched, release, cli, exporter } = await fixture(context); + const newerSource = new Date("2025-01-03T00:00:00.000Z"); + await fs.utimes(watched, newerSource, newerSource); + assert.equal( + await stagedBinariesAreCurrent(release, [cli, exporter], [watched]), + false, + ); +}); + +test("rejects any missing or mismatched secondary destination", async (context) => { + const { watched, release, cli, exporter, binaryTime } = + await fixture(context); + await fs.writeFile(exporter, "wrong-one"); + await fs.utimes(exporter, binaryTime, binaryTime); + assert.equal( + await stagedBinariesAreCurrent(release, [cli, exporter], [watched]), + false, + ); + await fs.rm(exporter); + assert.equal( + await stagedBinariesAreCurrent(release, [cli, exporter], [watched]), + false, + ); +}); diff --git a/scripts/build-desktop-binaries.mjs b/scripts/build-desktop-binaries.mjs index 66af8651a33..fdda8350917 100644 --- a/scripts/build-desktop-binaries.mjs +++ b/scripts/build-desktop-binaries.mjs @@ -3,6 +3,11 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import { + releaseBinaryMatchesDebugBinary, + stagedBinariesAreCurrent, +} from "./build-desktop-binaries-cache.mjs"; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, ".."); const binariesDir = path.join( @@ -32,35 +37,6 @@ async function fileExists(p) { .catch(() => false); } -async function newestMtimeMs(targetPath) { - let max = 0; - const stack = [targetPath]; - while (stack.length > 0) { - const current = stack.pop(); - const stat = await fs.stat(current).catch(() => null); - if (!stat) continue; - if (stat.isFile()) { - if (stat.mtimeMs > max) max = stat.mtimeMs; - continue; - } - if (stat.isDirectory()) { - const entries = await fs.readdir(current); - for (const name of entries) stack.push(path.join(current, name)); - } - } - return max; -} - -async function isUpToDate(destPath, sourcePaths) { - if (!(await fileExists(destPath))) return false; - const destStat = await fs.stat(destPath); - for (const src of sourcePaths) { - const newest = await newestMtimeMs(src); - if (newest > destStat.mtimeMs) return false; - } - return true; -} - async function main() { const target = process.argv[2] || process.env.RUST_TARGET_TRIPLE || detectHostTriple(); @@ -112,13 +88,31 @@ async function buildSidecar(sidecar, target, ext) { const dests = sidecar.destBinaries.map((destBinary) => path.join(binariesDir, `${destBinary}-${target}${ext}`), ); + const debugBinaries = [ + path.join( + repoRoot, + "target", + target, + "debug", + `${sidecar.sourceBinary}${ext}`, + ), + path.join(repoRoot, "target", "debug", `${sidecar.sourceBinary}${ext}`), + ]; + + if (await releaseBinaryMatchesDebugBinary(src, debugBinaries)) { + console.warn( + `Discarding debug binary found in release artifact path: ${src}`, + ); + await fs.rm(src); + } if ( - ( - await Promise.all( - dests.map((dest) => isUpToDate(dest, sidecar.watchPaths)), - ) - ).every(Boolean) + await stagedBinariesAreCurrent( + src, + dests, + sidecar.watchPaths, + debugBinaries, + ) ) { console.log( `${sidecar.destBinaries.join(", ")} desktop binaries up to date`, diff --git a/scripts/build-gpui-binary.sh b/scripts/build-gpui-binary.sh new file mode 100755 index 00000000000..e8e276e224e --- /dev/null +++ b/scripts/build-gpui-binary.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +profile="${1:-release}" +requested_target="${2:-${RUST_TARGET_TRIPLE:-}}" +toolchain="${CAP_GPUI_RUST_TOOLCHAIN:-1.95.0}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +gpui_dir="$repo_root/apps/desktop-gpui" +binaries_dir="$repo_root/apps/desktop/src-tauri/binaries" + +case "$profile" in + debug) + profile_dir="debug" + ;; + release) + profile_dir="release" + ;; + *) + echo "error: profile must be debug or release" >&2 + exit 1 + ;; +esac + +if ! rustup run "$toolchain" rustc --version >/dev/null 2>&1; then + rustup toolchain install "$toolchain" --profile minimal +fi + +if [[ -n "$requested_target" ]]; then + target="$requested_target" + artifact_dir="$gpui_dir/target/$target/$profile_dir" + rustup target add "$target" --toolchain "$toolchain" +else + target="$(cd "$gpui_dir" && rustc +"$toolchain" -vV | sed -n 's|host: ||p')" + artifact_dir="$gpui_dir/target/$profile_dir" +fi + +extension="" +if [[ "$target" == *windows* ]]; then + extension=".exe" +fi + +node "$repo_root/scripts/sync-desktop-versions.mjs" +"$repo_root/scripts/prepare-gpui-dependency.sh" + +( + cd "$gpui_dir" + if [[ "$profile" == "release" && -n "$requested_target" ]]; then + cargo +"$toolchain" build --release --target "$target" + elif [[ "$profile" == "release" ]]; then + cargo +"$toolchain" build --release + elif [[ -n "$requested_target" ]]; then + cargo +"$toolchain" build --target "$target" + else + cargo +"$toolchain" build + fi +) + +source_binary="$artifact_dir/cap-gpui$extension" +staged_binary="$binaries_dir/cap-gpui-$target$extension" +if [[ ! -f "$source_binary" ]]; then + echo "error: built GPUI binary not found at $source_binary" >&2 + exit 1 +fi + +if [[ "$target" == *linux* ]]; then + patchelf --set-rpath '$ORIGIN:$ORIGIN/../lib/cap' "$source_binary" +fi + +mkdir -p "$binaries_dir" +cp "$source_binary" "$staged_binary" +if [[ "$extension" != ".exe" ]]; then + chmod +x "$staged_binary" +fi +echo "Staged $source_binary -> $staged_binary" diff --git a/scripts/check-tauri-plugin-versions.js b/scripts/check-tauri-plugin-versions.js index 80fae557904..46b85d831eb 100755 --- a/scripts/check-tauri-plugin-versions.js +++ b/scripts/check-tauri-plugin-versions.js @@ -30,6 +30,75 @@ function parseVersion(version) { }; } +export function parseDesktopTauriVersions(pnpmLock, cargoLock) { + const versions = {}; + let insideDesktop = false; + let currentPackage = null; + + for (const line of pnpmLock.split("\n")) { + if (/^ {2}\S/.test(line)) { + if (insideDesktop) break; + insideDesktop = /^ {2}apps\/desktop:\s*$/.test(line); + currentPackage = null; + continue; + } + if (!insideDesktop) continue; + + const packageMatch = line.match( + /^ {6}['"]?(@tauri-apps\/(?:api|cli))['"]?:\s*$/, + ); + if (packageMatch) { + currentPackage = packageMatch[1]; + continue; + } + if (!currentPackage) continue; + + const versionMatch = line.match(/^ {8}version:\s*['"]?([^'"\s(]+)/); + if (versionMatch) { + versions[currentPackage] = versionMatch[1]; + currentPackage = null; + } + } + + const rustVersion = cargoLock.match( + /^name = "tauri"\r?\nversion = "([^"]+)"/m, + )?.[1]; + for (const packageName of ["@tauri-apps/api", "@tauri-apps/cli"]) { + if (!versions[packageName]) { + throw new Error(`Missing ${packageName} in apps/desktop pnpm lockfile`); + } + } + if (!rustVersion) throw new Error('Missing "tauri" package in Cargo.lock'); + + return { + api: versions["@tauri-apps/api"], + cli: versions["@tauri-apps/cli"], + rust: rustVersion, + }; +} + +export function compareTauriRuntimeVersions(versions) { + return [ + ["@tauri-apps/api", versions.api], + ["@tauri-apps/cli", versions.cli], + ].map(([jsName, jsVersion]) => { + const jsParsed = parseVersion(jsVersion); + const rustParsed = parseVersion(versions.rust); + const jsMajorMinor = `${jsParsed.major}.${jsParsed.minor}`; + const rustMajorMinor = `${rustParsed.major}.${rustParsed.minor}`; + return { + jsName, + rustName: "tauri", + jsVersion, + rustVersion: versions.rust, + jsMajorMinor, + rustMajorMinor, + majorMinor: jsMajorMinor, + matching: jsMajorMinor === rustMajorMinor, + }; + }); +} + // Parse pnpm-lock.yaml to extract Tauri plugin versions function parsePnpmLock(lockfilePath) { const content = fs.readFileSync(lockfilePath, "utf8"); @@ -220,6 +289,10 @@ function main() { // Parse both lockfiles const jsPlugins = parsePnpmLock(pnpmLockPath); const rustPlugins = parseCargoLock(cargoLockPath); + const runtimeVersions = parseDesktopTauriVersions( + fs.readFileSync(pnpmLockPath, "utf8"), + fs.readFileSync(cargoLockPath, "utf8"), + ); console.log(`Found ${Object.keys(jsPlugins).length} JS Tauri plugins`); console.log( @@ -240,6 +313,9 @@ function main() { // Compare versions const results = compareVersions(jsPlugins, rustPlugins); + for (const runtime of compareTauriRuntimeVersions(runtimeVersions)) { + results[runtime.matching ? "matching" : "mismatched"].push(runtime); + } // Report results if (results.matching.length > 0) { @@ -334,4 +410,4 @@ Exit codes: process.exit(0); } -main(); +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) main(); diff --git a/scripts/check-tauri-plugin-versions.test.mjs b/scripts/check-tauri-plugin-versions.test.mjs new file mode 100644 index 00000000000..6f5c2031686 --- /dev/null +++ b/scripts/check-tauri-plugin-versions.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + compareTauriRuntimeVersions, + parseDesktopTauriVersions, +} from "./check-tauri-plugin-versions.js"; + +function pnpmLock(cli, api = "2.8.0") { + return `importers: + + apps/desktop: + dependencies: + '@tauri-apps/api': + specifier: ${api} + version: ${api} + devDependencies: + '@tauri-apps/cli': + specifier: ${cli} + version: ${cli} + + apps/legacy: + devDependencies: + '@tauri-apps/cli': + specifier: 1.6.3 + version: 1.6.3 +`; +} + +function cargoLock(version = "2.8.5") { + return `[[package]] +name = "tauri" +version = "${version}" +`; +} + +test("matching Tauri runtime versions allow independent patch releases", () => { + const versions = parseDesktopTauriVersions(pnpmLock("2.8.4"), cargoLock()); + assert.deepEqual(versions, { api: "2.8.0", cli: "2.8.4", rust: "2.8.5" }); + assert.ok( + compareTauriRuntimeVersions(versions).every((entry) => entry.matching), + ); +}); + +test("newer CLI minors cannot silently mismatch the locked Rust runtime", () => { + const versions = parseDesktopTauriVersions(pnpmLock("2.11.4"), cargoLock()); + const results = compareTauriRuntimeVersions(versions); + assert.equal( + results.find((entry) => entry.jsName.endsWith("api"))?.matching, + true, + ); + assert.equal( + results.find((entry) => entry.jsName.endsWith("cli"))?.matching, + false, + ); +}); + +test("a mismatched JavaScript API minor is rejected independently", () => { + const versions = parseDesktopTauriVersions( + pnpmLock("2.8.4", "2.9.0"), + cargoLock(), + ); + const result = compareTauriRuntimeVersions(versions).find((entry) => + entry.jsName.endsWith("api"), + ); + assert.equal(result?.matching, false); +}); + +test("the desktop importer is isolated from legacy Tauri applications", () => { + const versions = parseDesktopTauriVersions(pnpmLock("2.8.4"), cargoLock()); + assert.equal(versions.cli, "2.8.4"); +}); + +test("missing desktop runtime dependencies fail closed", () => { + assert.throws( + () => + parseDesktopTauriVersions("importers:\n apps/desktop:\n", cargoLock()), + /Missing @tauri-apps\/api/, + ); + assert.throws( + () => parseDesktopTauriVersions(pnpmLock("2.8.4"), ""), + /Missing "tauri" package/, + ); +}); diff --git a/scripts/prepare-gpui-dependency.sh b/scripts/prepare-gpui-dependency.sh new file mode 100755 index 00000000000..445b5fa36f0 --- /dev/null +++ b/scripts/prepare-gpui-dependency.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +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" +base_revision="5d1f83d9f27a19bec1fb241dc33b42238af9cf8d" +remote="https://github.com/wingleeio/zed.git" + +verify_checkout() { + if ! git -C "$zed_dir" merge-base --is-ancestor "$base_revision" HEAD; then + 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 +} + +if [[ -e "$zed_dir/.git" ]]; then + verify_checkout + exit 0 +fi + +if [[ -e "$zed_dir" ]]; then + echo "error: $zed_dir exists but is not a Git checkout" >&2 + exit 1 +fi + +temporary_dir="$(mktemp -d "$zed_dir.tmp.XXXXXX")" +cleanup() { + rm -rf "$temporary_dir" +} +trap cleanup EXIT + +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" +mv "$temporary_dir" "$zed_dir" +trap - EXIT +verify_checkout diff --git a/scripts/run-gpui-build.mjs b/scripts/run-gpui-build.mjs new file mode 100644 index 00000000000..ac751cc659b --- /dev/null +++ b/scripts/run-gpui-build.mjs @@ -0,0 +1,91 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +export function shouldBuildGpui( + platform, + environment, + profile, + developmentWorkspaceAvailable = true, +) { + if (!["darwin", "win32", "linux"].includes(platform)) return false; + if (profile === "release") return true; + return ( + platform === "darwin" && + environment.CAP_GPUI_DEV !== "0" && + developmentWorkspaceAvailable + ); +} + +export function shouldBundleGpui( + platform, + environment, + profile, + stagedSidecarAvailable, + developmentWorkspaceAvailable = true, +) { + return ( + shouldBuildGpui( + platform, + environment, + profile, + developmentWorkspaceAvailable, + ) && + (profile === "release" || stagedSidecarAvailable) + ); +} + +function main() { + const profile = process.argv[2]; + if (profile !== "debug" && profile !== "release") { + console.error("The Cap GPUI build profile must be debug or release."); + process.exitCode = 1; + return; + } + + const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); + const gpuiDevScript = path.join( + scriptsDir, + "..", + "apps", + "desktop-gpui", + "dev.sh", + ); + + if ( + !shouldBuildGpui( + process.platform, + process.env, + profile, + profile !== "debug" || existsSync(gpuiDevScript), + ) + ) { + console.log( + `Skipping Cap GPUI ${profile} build on ${process.platform}${ + process.env.CAP_GPUI_DEV === "0" ? " because CAP_GPUI_DEV=0" : "" + }.`, + ); + return; + } + + const result = spawnSync( + "bash", + [path.join(scriptsDir, "build-gpui-binary.sh"), profile], + { stdio: "inherit" }, + ); + + if (result.error) { + console.error(`Failed to build Cap GPUI: ${result.error.message}`); + process.exitCode = 1; + } else if (result.status !== 0) { + process.exitCode = result.status ?? 1; + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); +} diff --git a/scripts/run-gpui-build.test.mjs b/scripts/run-gpui-build.test.mjs new file mode 100644 index 00000000000..2121a395d94 --- /dev/null +++ b/scripts/run-gpui-build.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { shouldBuildGpui, shouldBundleGpui } from "./run-gpui-build.mjs"; + +test("GPUI release builds run on every supported desktop platform", () => { + assert.equal(shouldBuildGpui("darwin", {}, "release"), true); + assert.equal(shouldBuildGpui("win32", {}, "release"), true); + assert.equal(shouldBuildGpui("linux", {}, "release"), true); + assert.equal(shouldBuildGpui("freebsd", {}, "release"), false); +}); + +test("GPUI development builds honor their platform and opt-out guards", () => { + assert.equal(shouldBuildGpui("darwin", {}, "debug"), true); + assert.equal( + shouldBuildGpui("darwin", { CAP_GPUI_DEV: "0" }, "debug"), + false, + ); + assert.equal(shouldBuildGpui("darwin", {}, "debug", false), false); + assert.equal(shouldBuildGpui("win32", {}, "debug"), false); + assert.equal(shouldBuildGpui("linux", {}, "debug"), false); +}); + +test("the development opt-out never suppresses supported release builds", () => { + for (const platform of ["darwin", "win32", "linux"]) { + assert.equal( + shouldBuildGpui(platform, { CAP_GPUI_DEV: "0" }, "release"), + true, + ); + } +}); + +test("macOS development only bundles an enabled and available GPUI sidecar", () => { + assert.equal(shouldBundleGpui("darwin", {}, "debug", true), true); + assert.equal(shouldBundleGpui("darwin", {}, "debug", false), false); + assert.equal(shouldBundleGpui("darwin", {}, "debug", true, false), false); + assert.equal( + shouldBundleGpui("darwin", { CAP_GPUI_DEV: "0" }, "debug", true), + false, + ); +}); + +test("release bundling is mandatory on every supported desktop platform", () => { + for (const platform of ["darwin", "win32", "linux"]) { + assert.equal( + shouldBundleGpui(platform, { CAP_GPUI_DEV: "0" }, "release", false), + true, + ); + } + assert.equal(shouldBundleGpui("freebsd", {}, "release", true), false); +}); diff --git a/scripts/setup.js b/scripts/setup.js index 6592f444556..378944e9462 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -202,21 +202,50 @@ async function main() { path.relative(__root, onnxRuntimePath), )}" }\n`; - const { stdout: vcInstallDir } = await exec( - // biome-ignore lint/suspicious/noTemplateCurlyInString: PowerShell syntax, not JS template literal - '$(& "${env:ProgramFiles(x86)}/Microsoft Visual Studio/Installer/vswhere.exe" -latest -property installationPath)', - { shell: "powershell.exe" }, + const vswherePath = path.join( + process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", + "Microsoft Visual Studio", + "Installer", + "vswhere.exe", ); + const { stdout: vcInstallDir } = await execFile(vswherePath, [ + "-latest", + "-products", + "*", + "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "-property", + "installationPath", + ]); + if (!vcInstallDir.trim()) + throw new Error( + "Visual Studio C++ build tools installation was not found", + ); const libclangPath = path.join( vcInstallDir.trim(), "VC/Tools/LLVM/x64/bin/libclang.dll", ); + if (!(await fileExists(libclangPath))) + throw new Error( + `Visual Studio LLVM libclang was not found at ${libclangPath}`, + ); cargoConfigContents += `LIBCLANG_PATH = "${libclangPath.replaceAll( "\\", "/", )}"\n`; + + const cmakePath = path.join( + vcInstallDir.trim(), + "Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe", + ); + if (await fileExists(cmakePath)) + cargoConfigContents += `CMAKE = "${cargoConfigPath(cmakePath)}"\n`; + else if (!(await findExecutable("cmake"))) + throw new Error( + "CMake was not found. Install the Visual Studio C++ CMake tools component.", + ); } else if (process.platform === "linux") { const triple = process.env.RUST_TARGET_TRIPLE; if (triple) { @@ -259,6 +288,7 @@ async function main() { console.log("Extracted native-deps"); } else console.log("Using cached native-deps"); + const onnxRuntimePath = await setupLinuxOnnxRuntime(); const debLibDir = path.join(nativeDepsDir, "cap-deb-libs"); await fs.rm(debLibDir, { recursive: true, force: true }).catch(() => {}); await fs.mkdir(debLibDir, { recursive: true }); @@ -279,12 +309,30 @@ async function main() { for (const dir of profileDirs) await fs.copyFile(realPath, path.join(dir, name)); } + const onnxRuntimeName = path.basename(onnxRuntimePath); + const onnxRuntimeNames = [onnxRuntimeName, `${onnxRuntimeName}.1`]; + for (const name of onnxRuntimeNames) { + await fs.copyFile(onnxRuntimePath, path.join(debLibDir, name)); + for (const dir of profileDirs) + await fs.copyFile(onnxRuntimePath, path.join(dir, name)); + } + const bundledLibraries = [ + ...new Set([...sonameLibs, ...onnxRuntimeNames]), + ]; console.log( - `Staged ${sonameLibs.length} FFmpeg shared libraries for Linux bundling`, + `Staged ${bundledLibraries.length} shared libraries for Linux bundling`, ); - await writeLinuxTauriConfig(sonameLibs); + await writeLinuxTauriConfig(bundledLibraries); + cargoConfigContents += `ORT_DYLIB_PATH = { relative = true, force = true, value = "${cargoConfigPath( + path.relative(__root, onnxRuntimePath), + )}" }\n`; cargoConfigContents += `\n[target.${triple}]\nrustflags = ["-C", "link-arg=-Wl,-rpath,$ORIGIN", "-C", "link-arg=-Wl,-rpath,$ORIGIN/../lib/cap"]\n`; + } else { + const onnxRuntimePath = await setupLinuxOnnxRuntime(); + cargoConfigContents += `[env]\nORT_DYLIB_PATH = { relative = true, force = true, value = "${cargoConfigPath( + path.relative(__root, onnxRuntimePath), + )}" }\n`; } } @@ -490,6 +538,50 @@ async function setupWindowsOnnxRuntime() { return outputPath; } +async function setupLinuxOnnxRuntime() { + const version = "1.24.3"; + const platformArch = { x86_64: "x64", aarch64: "aarch64" }[arch]; + if (!platformArch) + throw new Error(`Unsupported Linux arch for ONNX Runtime: ${arch}`); + + const archiveName = `onnxruntime-linux-${platformArch}-${version}.tgz`; + const archivePath = path.join(targetDir, archiveName); + const extractDir = path.join(targetDir, archiveName.replace(/\.tgz$/, "")); + const outputDir = path.join(targetDir, "native-deps", "onnxruntime", "lib"); + const outputPath = path.join(outputDir, "libonnxruntime.so"); + const markerPath = path.join(outputDir, "asset.txt"); + const marker = await fs + .readFile(markerPath, "utf-8") + .then((value) => value.trim()) + .catch(() => null); + + if (!(await fileExists(archivePath))) { + const response = await fetch( + `https://github.com/microsoft/onnxruntime/releases/download/v${version}/${archiveName}`, + ); + if (!response.ok) + throw new Error( + `Failed to download ${archiveName}: HTTP ${response.status}`, + ); + await fs.writeFile(archivePath, Buffer.from(await response.arrayBuffer())); + console.log(`Downloaded ${archiveName}`); + } else console.log(`Using cached ${archiveName}`); + + if (!(await fileExists(outputPath)) || marker !== archiveName) { + await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {}); + await execFile("tar", ["xf", archivePath, "-C", targetDir]); + await fs.mkdir(outputDir, { recursive: true }); + await fs.copyFile( + path.join(extractDir, "lib", "libonnxruntime.so"), + outputPath, + ); + await fs.writeFile(markerPath, archiveName); + console.log("Prepared ONNX Runtime shared library"); + } else console.log("Using cached ONNX Runtime shared library"); + + return outputPath; +} + async function setupWindowsDxc() { const asset = { version: "1.9.2607.13", diff --git a/scripts/sync-desktop-versions.mjs b/scripts/sync-desktop-versions.mjs new file mode 100644 index 00000000000..a1f80a6bd2e --- /dev/null +++ b/scripts/sync-desktop-versions.mjs @@ -0,0 +1,60 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, ".."); +const tauriManifest = path.join( + repoRoot, + "apps", + "desktop", + "src-tauri", + "Cargo.toml", +); +const gpuiManifest = path.join(repoRoot, "apps", "desktop-gpui", "Cargo.toml"); + +function packageVersion(source, manifestPath) { + const packageStart = source.indexOf("[package]"); + const packageEnd = source.indexOf("\n[", packageStart + 1); + const section = source.slice( + packageStart, + packageEnd === -1 ? source.length : packageEnd, + ); + const match = /^version\s*=\s*"([^"]+)"$/m.exec(section); + if (!match) throw new Error(`package.version not found in ${manifestPath}`); + return match[1]; +} + +function replacePackageVersion(source, manifestPath, version) { + const packageStart = source.indexOf("[package]"); + const packageEnd = source.indexOf("\n[", packageStart + 1); + const end = packageEnd === -1 ? source.length : packageEnd; + const section = source.slice(packageStart, end); + const nextSection = section.replace( + /^version\s*=\s*"[^"]+"$/m, + `version = "${version}"`, + ); + if (nextSection === section) + throw new Error(`package.version not found in ${manifestPath}`); + return source.slice(0, packageStart) + nextSection + source.slice(end); +} + +const [tauriSource, gpuiSource] = await Promise.all([ + fs.readFile(tauriManifest, "utf8"), + fs.readFile(gpuiManifest, "utf8"), +]); +const version = packageVersion(tauriSource, tauriManifest); +const gpuiVersion = packageVersion(gpuiSource, gpuiManifest); + +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) + throw new Error(`Invalid desktop version: ${version}`); + +if (gpuiVersion !== version) { + await fs.writeFile( + gpuiManifest, + replacePackageVersion(gpuiSource, gpuiManifest, version), + ); + console.log(`Synchronized Cap GPUI ${gpuiVersion} -> ${version}`); +} else { + console.log(`Cap desktop versions match: ${version}`); +} diff --git a/scripts/verify-gpui-release-inputs.mjs b/scripts/verify-gpui-release-inputs.mjs new file mode 100644 index 00000000000..8db35f43223 --- /dev/null +++ b/scripts/verify-gpui-release-inputs.mjs @@ -0,0 +1,78 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +const configNames = { + darwin: "tauri.macos.conf.json", + win32: "tauri.windows.conf.json", + linux: "tauri.linux.conf.json", +}; +const configName = configNames[process.platform]; + +if (!configName) { + console.log(`Skipping Cap GPUI release validation on ${process.platform}.`); +} else { + const srcTauri = path.join(repoRoot, "apps/desktop/src-tauri"); + const configPath = path.join(srcTauri, configName); + const productionConfigPath = path.join(srcTauri, "tauri.prod.conf.json"); + const [config, productionConfig] = await Promise.all([ + fs.readFile(configPath, "utf8").then(JSON.parse), + fs.readFile(productionConfigPath, "utf8").then(JSON.parse), + ]); + const externalBin = + productionConfig.bundle?.externalBin ?? config.bundle?.externalBin; + if ( + !Array.isArray(externalBin) || + !externalBin.includes("binaries/cap-gpui") + ) { + throw new Error( + `The effective ${process.platform} release config does not bundle Cap GPUI.`, + ); + } + + const binariesDir = path.join(srcTauri, "binaries"); + const requestedTarget = process.env.RUST_TARGET_TRIPLE; + const host = execFileSync("rustc", ["-vV"], { encoding: "utf8" }).match( + /^host:\s*(.+)$/m, + )?.[1]; + const target = requestedTarget ?? host; + if (!target) throw new Error("Could not determine the Rust target triple"); + + const extension = process.platform === "win32" ? ".exe" : ""; + const fileName = `cap-gpui-${target}${extension}`; + const binaryPath = path.join(binariesDir, fileName); + const releaseBinaryPath = path.join( + repoRoot, + "apps", + "desktop-gpui", + "target", + ...(requestedTarget ? [target] : []), + "release", + `cap-gpui${extension}`, + ); + const [stagedBinary, releaseBinary] = await Promise.all([ + fs.readFile(binaryPath), + fs.readFile(releaseBinaryPath), + ]); + + if (stagedBinary.length === 0 || releaseBinary.length === 0) { + throw new Error("The staged Cap GPUI release binary is empty"); + } + + const sha256 = (contents) => + createHash("sha256").update(contents).digest("hex"); + if (sha256(stagedBinary) !== sha256(releaseBinary)) { + throw new Error(`${binaryPath} does not match ${releaseBinaryPath}`); + } + + console.log( + `Verified effective ${process.platform} release configuration and ${fileName} for GPUI packaging.`, + ); +}