diff --git a/.github/assets/capkit-banner.svg b/.github/assets/capkit-banner.svg new file mode 100644 index 0000000..a7136b7 --- /dev/null +++ b/.github/assets/capkit-banner.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +1 + + + +500 × 320 + + + + + + + + + +CapKit +The lightweight desktop toolkit to +capture, record, and showcase. + +WINDOWS 11 +LOCAL-FIRST +NO ACCOUNT + + + \ No newline at end of file diff --git a/.github/assets/capkit-showcase.png b/.github/assets/capkit-showcase.png new file mode 100644 index 0000000..e3d235d Binary files /dev/null and b/.github/assets/capkit-showcase.png differ diff --git a/.github/assets/screenshots/record.png b/.github/assets/screenshots/record.png new file mode 100644 index 0000000..6e91ec1 Binary files /dev/null and b/.github/assets/screenshots/record.png differ diff --git a/.github/assets/screenshots/screenshots.png b/.github/assets/screenshots/screenshots.png new file mode 100644 index 0000000..6358168 Binary files /dev/null and b/.github/assets/screenshots/screenshots.png differ diff --git a/.github/assets/screenshots/settings.png b/.github/assets/screenshots/settings.png new file mode 100644 index 0000000..5397040 Binary files /dev/null and b/.github/assets/screenshots/settings.png differ diff --git a/.github/assets/screenshots/showcase.png b/.github/assets/screenshots/showcase.png new file mode 100644 index 0000000..03a18c2 Binary files /dev/null and b/.github/assets/screenshots/showcase.png differ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 580536c..0c16e06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,9 +107,14 @@ jobs: $hash = (Get-FileHash -Algorithm SHA256 -Path $installer).Hash.ToLowerInvariant() $checksum = "$installer.sha256" Set-Content -LiteralPath $checksum -Value $hash -NoNewline - gh api "repos/${{ github.repository }}/releases/generate-notes" -f tag_name="v${{ env.VERSION }}" -f target_commitish="${{ github.sha }}" --jq .body > notes.md - Add-Content -Path notes.md -Value "" - Add-Content -Path notes.md -Value "Full changelog: CHANGELOG-PUBLIC.md" + # PowerShell 5.1 captures native output as UTF-16; force UTF-8 so + # non-ASCII notes (emoji in PR titles) survive, then write the file + # as UTF-8 without a BOM with LF endings (`>` would write UTF-16). + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) + $generated = gh api "repos/${{ github.repository }}/releases/generate-notes" -f tag_name="v${{ env.VERSION }}" -f target_commitish="${{ github.sha }}" --jq .body + if ($LASTEXITCODE -ne 0) { throw "Could not generate release notes" } + $notes = (@($generated) -join "`n") + "`n`nFull changelog: [CHANGELOG-PUBLIC.md](https://github.com/${{ github.repository }}/blob/v${{ env.VERSION }}/CHANGELOG-PUBLIC.md)`n" + [System.IO.File]::WriteAllText("$PWD/notes.md", $notes, [System.Text.UTF8Encoding]::new($false)) gh release create "v${{ env.VERSION }}" $installer $checksum --target "${{ github.sha }}" --title "CapKit ${{ env.VERSION }}" --notes-file notes.md - name: Upload installer artifact diff --git a/AGENTS.md b/AGENTS.md index 57ab701..81f4fe7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ Also verify affected components, multi-monitor/DPI-sensitive geometry, loading, ## Tech stack - Workspace: pnpm 11.14.0, TypeScript, strict mode -- Desktop: Tauri 2.11, Rust 2024, React 19, Vite 8, Tailwind CSS 4, Zod 4 +- Desktop: Tauri 2.11, Rust 2024, React 19, Vite 8, Tailwind CSS 4, Zod 4, webview2-com 0.38.2 + windows-core 0.61 (camera permission only, pinned to versions already in the tree) - Landing page: Next.js 16.2 App Router, React 19, TypeScript 5, Tailwind CSS 4, Framer Motion 12 - Tooling: ESLint 9, Vitest 4, Testing Library, Cargo - Targets: Windows 11 supported; macOS, Ubuntu, and Fedora provisional diff --git a/CHANGELOG-PUBLIC.md b/CHANGELOG-PUBLIC.md index 0572784..825089b 100644 --- a/CHANGELOG-PUBLIC.md +++ b/CHANGELOG-PUBLIC.md @@ -2,6 +2,21 @@ User-facing changes only, grouped by date under Added / Improved / Fixed. +## 0.2.1 — 2026-09-28 + +### Fixed + +- The recorder opens on the first click. +- The source previews are visible. +- The Screenshots page is full width. +- The cursor shows in recordings. +- Camera access can be retried. + +### Added + +- Draw an area to record. +- Choose the microphone and speaker. + ## 0.2.0 — 2026-09-26 ### Added diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..41cd8d0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +CapKit +Copyright (c) 2026 Nakul Srivastava. All rights reserved. + +This software and its source code, including the desktop application, the +landing page, documentation, logos, and other assets in this repository +(the "Software"), are proprietary and confidential. + +No license is granted to use, copy, modify, merge, publish, distribute, +sublicense, or sell copies of the Software, in whole or in part, except: + + 1. Official CapKit builds published by the copyright holder may be + installed and used under the terms that accompany those builds. + 2. Anyone the copyright holder has given access to this repository may + view the source and submit contributions to it. Contributions are + licensed to the copyright holder under these terms. + +Any other use requires prior written permission from the copyright holder. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-party components are licensed under their own terms, including the +Caveat font under the SIL Open Font License 1.1. diff --git a/README.md b/README.md index 41a3bb2..c2e2657 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,186 @@ -# CapKit +

+ CapKit — The lightweight desktop toolkit to capture, record, and showcase. +

-CapKit is the lightweight desktop toolkit to capture, record, and showcase. It is Windows-first, designed for cross-platform rollout, and built around one uninterrupted workflow: +

+ Download the latest release + Platform: Windows 11 + Built with Tauri 2 + License: proprietary +

-`Shortcut -> select on a frozen screen -> edit in place -> copy, save, copy and save, or pin -> return to work` +

+ Download for Windows +  ·  + Website +  ·  + What's new +

-The product is intentionally narrower and calmer than automation-heavy capture suites. Phase 1 focuses on fast capture, contextual quick editing, pinning, and scrolling capture without opening a conventional editor window. +

+ CapKit Showcase: a screenshot framed on a dark wallpaper with padding and a shadow, edited in the Showcase workspace +

-## Status +--- -- **Confirmed:** Windows-first reference implementation with Windows 11 as the first supported platform. -- **Confirmed:** macOS, Ubuntu, and Fedora remain architectural targets. -- **Confirmed:** Phase 1 is local-first and requires no account, cloud, telemetry, or library. -- **In progress:** The first Windows capture-to-export vertical slice, tray lifecycle, pinning, and settings dashboard are operational; Phase 1 still needs native hardening and completion. -- **Implemented, physical validation pending:** A configurable shortcut toggles a temporary on-screen presentation toolbar for drawing, spotlighting, magnification, pointer trails, and blur without entering screenshot capture. The desktop stays live by default; Settings can use a frozen frame when stable markup is preferable. -- **Provisional:** Pricing, quotas, update entitlement, final brand clearance, and distribution channels. +CapKit is a small desktop app that lives in your system tray. Press a shortcut, select part of the screen, mark it up right where it is, then copy, save, or pin it, and get back to work. No editor window, no account, no cloud. -See [Current implementation status](docs/current-status.md) for the verified working surface, partial implementations, and remaining gaps. -For a single user-facing inventory of current local capabilities, see [Local feature inventory](docs/local-features.md). +When you need more, the same app records your screen, turns recordings into polished clips, and dresses screenshots up for sharing. -## Documentation +```text +Shortcut → select on a frozen screen → edit in place → copy · save · pin → back to work +``` -- [Product brief](docs/product-brief.md) -- [Current implementation status](docs/current-status.md) -- [Local feature inventory](docs/local-features.md) -- [Feature roadmap](docs/feature-roadmap.md) -- [Capture UX specification](docs/capture-ux-spec.md) -- [Technical architecture](docs/technical-architecture.md) -- [Performance and quality](docs/performance-quality.md) -- [Business and cloud](docs/business-cloud.md) -- [Brand and marketing handoff](docs/brand-marketing-handoff.md) -- [Decision log](docs/decision-log.md) +## Screenshots + +

+ The Screenshots page showing a grid of saved captures +

+ + + + + + + + + + + + + + + + +
Showcase: a capture framed with a background, padding and shadowRecord: the recordings list with Start recording
Showcase: frame a capture for sharingRecord: record and edit your screen
Settings: shortcuts, colors and toolbar options
Settings: shortcuts, colors and toolbar options, stored on your device
+ +## Features + +### Capture + +- **One shortcut, frozen screen.** `Alt+Shift+S` freezes the display under your cursor so menus and tooltips stay put while you select. +- **Smart selection.** Drag a region, click a window, or click an individual button or panel inside an app. Resize and move the selection after drawing it. +- **Precision aids.** Crosshair, live dimensions, a magnifier and snapping, each of which you can turn off. +- **Scrolling capture.** Capture a whole long page automatically, or scroll by hand. CapKit stitches the frames and handles sticky headers. +- **Direct shortcuts.** Separate shortcuts for Capture & Copy and Capture & Save when you don't need to edit. + +### Edit in place + +The selected area becomes the editor. Tools appear right beside it. + +- Rectangle, ellipse, line, straight and curved arrows, highlighter, pencil, spotlight, numbered steps and text, including a handwritten style. +- **Redaction that sticks.** Blur, pixelate and black out. Protected areas are permanently flattened into the exported image. +- Select, move and delete anything you drew, with full undo and redo. +- Finish with **Copy** (`C`), **Copy & Save** (`A`), **Save** (`S`) or **Pin**. +- Arrange the toolbar your way: one row of tools, or grouped rows you order yourself. + +### Pin + +- Keep a capture floating above everything: move, resize, rotate, change the opacity, lock it, then copy or save it later. + +### Screen Draw + +- A separate shortcut puts a drawing layer over your live screen, for demos, calls and teaching. +- Pen, text, shapes, arrows, spotlight, magnifier, a laser pointer that fades, eraser and blur. +- Press `S` to save the whole screen with your drawings. + +### Record and edit + +- Record a display, a single window, or an area you draw. +- Choose your microphone and speaker. The recording controls stay out of the video. +- The cursor is recorded as its own track, so the editor can smooth it and zoom in automatically on clicks. +- Trim, add a webcam bubble, apply a Showcase look, and export to MP4 or GIF. + +### Showcase + +- Turn a plain screenshot into something worth posting: backgrounds, gradients and wallpapers; padding, rounded corners and shadows; browser and device frames; tilt and perspective; titles and notes. +- Save your favourite looks as presets. + +### Private by design + +- Everything stays on your device. No account, sign-in, telemetry or network access is needed to capture, edit, record or export. +- CapKit idles in the tray and loads its capture, editing and recording tools only when you use them. + +## Install + +1. Download `CapKit__x64-setup.exe` from the [latest release](https://github.com/imnakul/snaphub/releases/latest). +2. Run it. It installs for your user account only and doesn't need admin rights. +3. CapKit starts in the system tray. Press `Alt+Shift+S` to capture. + +> [!NOTE] +> The installer isn't code-signed yet, so Windows SmartScreen may show "Windows protected your PC". Choose **More info → Run anyway**. Each release includes a `.sha256` file so you can check the download. + +Requirements: Windows 11 (Windows 10 version 2004 or later for screen recording) with the WebView2 runtime, which comes with current Windows. macOS, Ubuntu and Fedora support is planned. ## Development -Prerequisites: +
+Prerequisites, commands and project layout + +### Prerequisites - Node.js 24+ -- pnpm 10+ +- pnpm 11+ - Rust 1.95+ -- Windows 11 SDK and WebView2 for the Windows reference application +- Windows 11 SDK and WebView2 -Commands: +### Commands ```powershell -pnpm.cmd install -pnpm.cmd dev +pnpm.cmd install # install dependencies +pnpm.cmd tauri dev # run the desktop app pnpm.cmd typecheck pnpm.cmd lint pnpm.cmd test -pnpm.cmd tauri dev +pnpm.cmd bundle:windows # build the NSIS setup +pnpm.cmd bundle:store # build the Microsoft Store MSIX package ``` -## Installable builds - -The current supported build is Windows 11. From a Windows development machine with the prerequisites above installed, create a normal per-user NSIS setup executable with: +Rust checks: ```powershell -pnpm.cmd install -pnpm.cmd bundle:windows -pnpm.cmd bundle:store +cargo fmt --manifest-path apps/desktop/src-tauri/Cargo.toml --check +cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml --all-targets -- -D warnings +cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml ``` -The installer is written to: +The setup is written to `apps/desktop/src-tauri/target/release/bundle/nsis/CapKit__x64-setup.exe`. Store packaging is described in [Microsoft Store release](docs/microsoft-store-release.md), and publishing a release in [Releasing CapKit](docs/releasing.md). + +### Project layout ```text -apps/desktop/src-tauri/target/release/bundle/nsis/CapKit_0.1.1_x64-setup.exe +apps/desktop/ React capture surface and Tauri host +apps/desktop/src-tauri/ Rust resident core and platform adapters +apps/landing-page/ Next.js marketing site +docs/ Product and engineering source of truth ``` -`pnpm.cmd bundle:store` creates the Store-ready x64 MSIX package and the recommended `.msixupload` submission artifact under `apps/desktop/src-tauri/target/store`. Its manifest uses the immutable Partner Center identity `JagatBandhu.SnapHub`; package versions use four parts and must end in `.0`. See [Microsoft Store release](docs/microsoft-store-release.md). - -Tauri uses the system WebView2 runtime on current Windows 10/11 installations, keeping the installer smaller. Production distribution should add code signing before public release. `pnpm.cmd bundle` builds the native bundle formats configured for the host operating system. +### Stack -### Platform status +Tauri 2 · Rust · React 19 · TypeScript · Vite · Tailwind CSS 4 · Zod · Vitest. The landing page uses Next.js 16. -- **Confirmed:** Windows 11 is the only currently supported and physically tested target. -- **Provisional:** macOS, Ubuntu, and Fedora are architectural targets. Shared capture/domain code exists, but native permissions, target detection, scrolling input, packaging, signing, and hardware acceptance are not complete. -- Native installers should be built and tested on their target OS: Windows for NSIS/MSI, macOS with Xcode for `.app`/`.dmg`, and Linux with WebKitGTK/system packaging dependencies for AppImage, Debian, or RPM packages. - -Rust checks: +
-```powershell -cargo fmt --manifest-path apps/desktop/src-tauri/Cargo.toml --check -cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml --all-targets -- -D warnings -cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml -``` +## Project status -## Source layout +- **Confirmed:** Windows 11 is the supported, tested platform. +- **Confirmed:** Capture, editing, Screen Draw, recording and export are local-first and need no account or cloud. +- **Provisional:** macOS, Ubuntu and Fedora are architectural targets. Native permissions, packaging and hardware testing are not done yet. +- **Provisional:** Pricing, paid features and distribution channels. -```text -apps/desktop/ React capture surface and Tauri host -apps/desktop/src-tauri Rust resident core and platform adapters -apps/landing-page/ Next.js marketing site (docs/brand-marketing-handoff.md) -docs/ Product and engineering source of truth -``` +## Documentation -## Landing page +- [Product brief](docs/product-brief.md) +- [Current implementation status](docs/current-status.md) +- [Local feature inventory](docs/local-features.md) +- [Feature roadmap](docs/feature-roadmap.md) +- [Capture UX specification](docs/capture-ux-spec.md) +- [Technical architecture](docs/technical-architecture.md) +- [Performance and quality](docs/performance-quality.md) +- [Business and cloud](docs/business-cloud.md) +- [Brand and marketing handoff](docs/brand-marketing-handoff.md) +- [Decision log](docs/decision-log.md) -The marketing site lives in `apps/landing-page` and is a standalone Next.js 16 (App Router) + TypeScript + Tailwind CSS v4 project, styled per [Brand and marketing handoff](docs/brand-marketing-handoff.md). It ships no product code and is not part of the desktop build. +## License -```powershell -pnpm.cmd --filter @snaphub/landing-page dev -pnpm.cmd --filter @snaphub/landing-page typecheck -pnpm.cmd --filter @snaphub/landing-page lint -pnpm.cmd --filter @snaphub/landing-page build -``` +Copyright © 2026 Nakul Srivastava. All rights reserved. CapKit is proprietary software; see [LICENSE](LICENSE). Bundled third-party components, such as the Caveat font (SIL Open Font License 1.1), keep their own licenses. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 32b25aa..d911cb2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@snaphub/desktop", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 110ea20..d62d37b 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -604,7 +604,7 @@ dependencies = [ [[package]] name = "capkit-desktop" -version = "0.2.0" +version = "0.2.1" dependencies = [ "ab_glyph", "arboard", @@ -621,7 +621,9 @@ dependencies = [ "tauri-plugin-global-shortcut", "thiserror 2.0.18", "uuid", + "webview2-com", "windows 0.62.2", + "windows-core 0.61.2", "xcap", ] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 769d431..1b7e251 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "capkit-desktop" -version = "0.2.0" +version = "0.2.1" description = "CapKit lightweight desktop toolkit to capture, record, and showcase" authors = ["CapKit"] edition = "2024" @@ -49,6 +49,15 @@ windows = { version = "0.62.2", features = [ # Cursor sampling and the shared QPC clock. "Win32_UI_Input", "Win32_System_Performance", "Win32_System_Threading", ] } +# Camera permission for the camera window's own webview only. Pinned to the +# version wry already brings in, so there is exactly one copy in the binary. +webview2-com = "=0.38.2" +# The 0.61 Interface trait and w! literals that webview2-com 0.38's types are +# bound to (our windows crate is 0.62). Pinned to the version already in the +# tree; adds no new code. +[target.'cfg(target_os = "windows")'.dependencies.windows-core-061] +package = "windows-core" +version = "=0.61.2" # Size is a product gate, not an afterthought: `docs/performance-quality.md` # budgets the installed app at under 60 MB, and the video recorder is being diff --git a/apps/desktop/src-tauri/src/domain.rs b/apps/desktop/src-tauri/src/domain.rs index ed2634e..563895a 100644 --- a/apps/desktop/src-tauri/src/domain.rs +++ b/apps/desktop/src-tauri/src/domain.rs @@ -295,6 +295,8 @@ pub struct RecordingRequestDto { pub microphone: bool, #[serde(default)] pub microphone_device_id: Option, + #[serde(default)] + pub system_audio_device_id: Option, } /// Live counters for the recorder dock, emitted at most once per second. @@ -371,6 +373,28 @@ pub struct CursorTrackDto { pub ys: Vec, pub events: Vec, pub shapes: Vec, + /// The recorded area in physical desktop px: the whole display, or the + /// crop origin with the video's size for Window and Region recordings. pub display_bounds: Rect, pub scale_factor: f64, } + +#[cfg(test)] +mod request_tests { + use super::RecordingRequestDto; + + #[test] + fn system_audio_device_id_defaults_to_none() { + let request = serde_json::from_value::(serde_json::json!({ + "displayId": "1", + "region": null, + "fps": 30, + "captureCursor": false, + "systemAudio": true, + "microphone": false, + "microphoneDeviceId": null + })) + .expect("a request without systemAudioDeviceId deserializes"); + assert_eq!(request.system_audio_device_id, None); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9137d93..c728f8a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -18,7 +18,7 @@ use domain::{ RecordingStatsDto, Rect, SavedCaptureDto, ScrollingCaptureRequest, ScrollingCaptureResult, }; use error::SnaphubError; -use platform::xcap_backend::XcapPlatformBackend; +use platform::xcap_backend::{XcapPlatformBackend, display_bounds}; use services::capture::CaptureService; use services::recording::RecordingService; use services::scrolling::stitch_vertical; @@ -1397,13 +1397,19 @@ async fn list_audio_devices( #[tauri::command] async fn start_recording(app: AppHandle, request: RecordingRequestDto) -> Result<(), SnaphubError> { let worker_app = app.clone(); - tauri::async_runtime::spawn_blocking(move || { + let audio_failures = tauri::async_runtime::spawn_blocking(move || { worker_app.state::().start(&request) }) .await .map_err(|error| SnaphubError::Record(format!("Recording worker failed: {error}"))) .and_then(|result| result)?; let _ = app.emit("snaphub://recording-started", ()); + for kind in audio_failures { + let _ = app.emit( + "snaphub://recording-audio-failed", + serde_json::json!({ "kind": kind }), + ); + } Ok(()) } @@ -1505,18 +1511,19 @@ async fn open_camera(app: AppHandle) -> Result<(), SnaphubError> { let _ = window.set_focus(); return Ok(()); } - let monitor = app - .primary_monitor() - .ok() - .flatten() - .ok_or_else(|| SnaphubError::Window("No display available for the camera".into()))?; - let bounds = monitor.size(); - let size = 260u32; + if app.primary_monitor().ok().flatten().is_none() { + return Err(SnaphubError::Window( + "No display available for the camera".into(), + )); + } // Builds a WebView window: never call from a synchronous command or an event handler on Windows (deadlock). Use an async command or spawn_blocking. - let window = WebviewWindowBuilder::new(&app, "camera", WebviewUrl::App("index.html".into())) + // The window stays hidden until the frontend attaches the stream + // (`camera_ready`), so there is nothing to place yet. + WebviewWindowBuilder::new(&app, "camera", WebviewUrl::App("index.html".into())) .title("CapKit Camera") - .inner_size(f64::from(size), f64::from(size)) + .inner_size(260.0, 260.0) + .center() .decorations(false) .transparent(true) .resizable(false) @@ -1526,10 +1533,6 @@ async fn open_camera(app: AppHandle) -> Result<(), SnaphubError> { .visible(false) .build() .map_err(|error| SnaphubError::Window(error.to_string()))?; - - let x = 48; - let y = bounds.height.saturating_sub(size + 220); - let _ = window.set_position(PhysicalPosition::new(x, y as i32)); Ok(()) } @@ -1537,13 +1540,38 @@ async fn open_camera(app: AppHandle) -> Result<(), SnaphubError> { fn camera_ready( app: AppHandle, service: tauri::State<'_, RecordingService>, + mode: String, ) -> Result<(), SnaphubError> { + let live = match mode.as_str() { + "live" => true, + "blocked" => false, + _ => { + return Err(SnaphubError::Window( + "Camera mode must be \"live\" or \"blocked\"".into(), + )); + } + }; let Some(window) = app.get_webview_window("camera") else { return Ok(()); }; if let Ok(handle) = window.hwnd() { let _ = service.set_capture_exclusion(handle.0 as isize, true); } + let (work_area, scale) = window_work_area(&app, &window) + .ok_or_else(|| SnaphubError::Window("No display available for the camera".into()))?; + let frame = camera_frame(work_area, scale, live); + window + .set_position(PhysicalPosition::new( + frame.x.round() as i32, + frame.y.round() as i32, + )) + .map_err(|error| SnaphubError::Window(error.to_string()))?; + window + .set_size(PhysicalSize::new( + frame.width.round() as u32, + frame.height.round() as u32, + )) + .map_err(|error| SnaphubError::Window(error.to_string()))?; let _ = window.unminimize(); window .show() @@ -1552,6 +1580,33 @@ fn camera_ready( Ok(()) } +#[tauri::command] +async fn prepare_camera_permission(app: AppHandle) -> Result<(), SnaphubError> { + #[cfg(target_os = "windows")] + { + platform::windows_webview::prepare_camera_permission(&app).await + } + #[cfg(not(target_os = "windows"))] + { + let _ = app; + Ok(()) + } +} + +#[tauri::command] +fn open_camera_privacy_settings() -> Result<(), SnaphubError> { + #[cfg(target_os = "windows")] + { + platform::windows_shell::open_camera_privacy_settings() + } + #[cfg(not(target_os = "windows"))] + { + Err(SnaphubError::Window( + "Camera settings are only available on Windows".into(), + )) + } +} + #[tauri::command] fn close_camera(app: AppHandle) -> Result<(), SnaphubError> { if let Some(window) = app.get_webview_window("camera") { @@ -1636,12 +1691,157 @@ fn close_recorder( service: tauri::State<'_, RecordingService>, ) -> Result<(), SnaphubError> { service.cancel()?; + if let Some(window) = app.get_webview_window("record-region") { + let _ = window.destroy(); + } if let Some(window) = app.get_webview_window("recorder") { let _ = window.destroy(); } Ok(()) } +/// Converts a logical box drawn in the region overlay to physical desktop px. +/// +/// Rounds to whole physical px, enforces a 32x32 minimum, and clamps inside +/// `display`. `origin` is the overlay window's physical outer position. +fn logical_box_to_desktop(origin: (i32, i32), scale: f64, area: Rect, display: Rect) -> Rect { + let width = (area.width * scale).round().max(32.0).min(display.width); + let height = (area.height * scale).round().max(32.0).min(display.height); + let x = (f64::from(origin.0) + (area.x * scale).round()) + .max(display.x) + .min(display.x + display.width - width); + let y = (f64::from(origin.1) + (area.y * scale).round()) + .max(display.y) + .min(display.y + display.height - height); + Rect { + x, + y, + width, + height, + } +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct RecordRegionSelection { + display_id: String, + bounds: Rect, +} + +struct RecordRegionRegistry(Mutex>); + +/// Builds a WebView window: never call from a synchronous command or an event handler on Windows (deadlock). Use an async command or spawn_blocking. +#[tauri::command] +async fn open_record_region(app: AppHandle, display_id: String) -> Result<(), SnaphubError> { + if let Some(window) = app.get_webview_window("record-region") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + return Ok(()); + } + // Cheap display enumeration only: the thumbnail-capturing sources() call + // must never run on the way to revealing the overlay. + let bounds = display_bounds(&display_id)?; + if let Ok(mut registry) = app.state::().0.lock() { + *registry = Some((display_id, bounds)); + } + WebviewWindowBuilder::new(&app, "record-region", WebviewUrl::App("index.html".into())) + .title("CapKit Record Region") + .decorations(false) + .transparent(true) + .resizable(false) + .skip_taskbar(true) + .always_on_top(true) + .shadow(false) + .visible(false) + .build() + .map_err(|error| SnaphubError::Window(error.to_string()))?; + let Some(window) = app.get_webview_window("record-region") else { + return Err(SnaphubError::Window("Region window is unavailable".into())); + }; + window + .set_position(PhysicalPosition::new( + bounds.x.round() as i32, + bounds.y.round() as i32, + )) + .map_err(|error| SnaphubError::Window(error.to_string()))?; + window + .set_size(PhysicalSize::new( + bounds.width.round() as u32, + bounds.height.round() as u32, + )) + .map_err(|error| SnaphubError::Window(error.to_string()))?; + Ok(()) +} + +#[tauri::command] +fn record_region_ready(app: AppHandle) -> Result<(), SnaphubError> { + let Some(window) = app.get_webview_window("record-region") else { + return Ok(()); + }; + let _ = window.unminimize(); + window + .show() + .map_err(|error| SnaphubError::Window(error.to_string()))?; + let _ = window.set_focus(); + Ok(()) +} + +#[tauri::command] +fn confirm_record_region( + app: AppHandle, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result<(), SnaphubError> { + let Some(window) = app.get_webview_window("record-region") else { + return Err(SnaphubError::Window("Region window is unavailable".into())); + }; + let (display_id, bounds) = app + .state::() + .0 + .lock() + .map_err(|_| SnaphubError::Window("Region state is unavailable".into()))? + .clone() + .ok_or_else(|| SnaphubError::Window("No display was chosen for the region".into()))?; + let position = window + .outer_position() + .map_err(|error| SnaphubError::Window(error.to_string()))?; + let scale = window + .scale_factor() + .map_err(|error| SnaphubError::Window(error.to_string()))?; + let desktop = logical_box_to_desktop( + (position.x, position.y), + scale, + Rect { + x, + y, + width, + height, + }, + bounds, + ); + let _ = app.emit( + "snaphub://record-region-selected", + RecordRegionSelection { + display_id, + bounds: desktop, + }, + ); + let _ = window.destroy(); + Ok(()) +} + +#[tauri::command] +fn cancel_record_region(app: AppHandle) -> Result<(), SnaphubError> { + let _ = app.emit("snaphub://record-region-cancelled", ()); + if let Some(window) = app.get_webview_window("record-region") { + let _ = window.destroy(); + } + Ok(()) +} + /// Opens the recorder, or brings it to front if it is already open. /// /// Bound to a global shortcut so recording can be reached without navigating @@ -1675,18 +1875,10 @@ fn show_recorder(app: &AppHandle) -> Result<(), SnaphubError> { return Ok(()); } - let monitor = app - .primary_monitor() - .ok() - .flatten() - .ok_or_else(|| SnaphubError::Window("No display available for the recorder".into()))?; - let bounds = monitor.size(); - let width = 720u32; - let height = 132u32; - - let window = WebviewWindowBuilder::new(app, "recorder", WebviewUrl::App("index.html".into())) + // The frontend fits the window to its content before revealing it. + match WebviewWindowBuilder::new(app, "recorder", WebviewUrl::App("index.html".into())) .title("CapKit Recorder") - .inner_size(f64::from(width), f64::from(height)) + .inner_size(724.0, 140.0) .decorations(false) .transparent(true) .resizable(false) @@ -1695,12 +1887,136 @@ fn show_recorder(app: &AppHandle) -> Result<(), SnaphubError> { .shadow(false) .visible(false) .build() - .map_err(|error| SnaphubError::Window(error.to_string()))?; + { + Ok(_) => Ok(()), + Err(error) => { + // A fast double click (or the shortcut racing the button) can + // reach here after the first call already built the window. + if let Some(window) = app.get_webview_window("recorder") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + return Ok(()); + } + Err(SnaphubError::Window(error.to_string())) + } + } +} - let x = (bounds.width.saturating_sub(width)) / 2; - let y = bounds.height.saturating_sub(height + 72); - let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32)); - let _ = window.set_size(PhysicalSize::new(width, height)); +/// Computes the recorder window's physical frame from its logical content size. +/// +/// All inputs are logical except `work_area`, which is physical. The window is +/// bottom-anchored with `bottom_gap` logical px above the taskbar and centred +/// horizontally. Content larger than the work area is clamped, and the frame +/// never starts above the work area. +fn dock_frame( + work_area: Rect, + scale: f64, + content_width: f64, + content_height: f64, + bottom_gap: f64, +) -> Rect { + let width = (content_width * scale) + .round() + .min(work_area.width) + .max(1.0); + let height = (content_height * scale) + .round() + .min(work_area.height) + .max(1.0); + let x = work_area.x + (work_area.width - width) / 2.0; + let y = + (work_area.y + work_area.height - height - (bottom_gap * scale).round()).max(work_area.y); + Rect { + x, + y, + width, + height, + } +} + +/// Computes the camera window's physical frame. +/// +/// `live` is the 260 logical px bottom-left bubble, 24 px from the work area's +/// left and bottom edges. Otherwise it is the 360x220 centred blocked panel. +fn camera_frame(work_area: Rect, scale: f64, live: bool) -> Rect { + let (content_width, content_height) = if live { (260.0, 260.0) } else { (360.0, 220.0) }; + let width = (content_width * scale) + .round() + .min(work_area.width) + .max(1.0); + let height = (content_height * scale) + .round() + .min(work_area.height) + .max(1.0); + let x = if live { + work_area.x + (24.0 * scale).round() + } else { + work_area.x + (work_area.width - width) / 2.0 + }; + let y = if live { + (work_area.y + work_area.height - height - (24.0 * scale).round()).max(work_area.y) + } else { + (work_area.y + (work_area.height - height) / 2.0).max(work_area.y) + }; + Rect { + x, + y, + width, + height, + } +} + +/// The work area and scale of the monitor hosting `window`, or the primary one. +fn window_work_area(app: &AppHandle, window: &WebviewWindow) -> Option<(Rect, f64)> { + let monitor = window + .current_monitor() + .ok() + .flatten() + .or_else(|| app.primary_monitor().ok().flatten())?; + let area = monitor.work_area(); + Some(( + Rect { + x: f64::from(area.position.x), + y: f64::from(area.position.y), + width: f64::from(area.size.width), + height: f64::from(area.size.height), + }, + monitor.scale_factor(), + )) +} + +/// Fits the recorder window to its content. +/// +/// A sync command is safe here: it builds no window, and `set_size` and +/// `set_position` do not deadlock. +#[tauri::command] +fn fit_recorder(app: AppHandle, width: f64, height: f64) -> Result<(), SnaphubError> { + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return Err(SnaphubError::Window( + "Recorder size must be finite and positive".into(), + )); + } + let Some(window) = app.get_webview_window("recorder") else { + return Err(SnaphubError::Window( + "Recorder window is unavailable".into(), + )); + }; + let (work_area, scale) = window_work_area(&app, &window) + .ok_or_else(|| SnaphubError::Window("No display available for the recorder".into()))?; + let frame = dock_frame(work_area, scale, width, height, 24.0); + window + .set_position(PhysicalPosition::new( + frame.x.round() as i32, + frame.y.round() as i32, + )) + .map_err(|error| SnaphubError::Window(error.to_string()))?; + window + .set_size(PhysicalSize::new( + frame.width.round() as u32, + frame.height.round() as u32, + )) + .map_err(|error| SnaphubError::Window(error.to_string()))?; Ok(()) } @@ -1748,6 +2064,7 @@ pub fn run() { .manage(recording_service()) .manage(ShortcutConfiguration(Mutex::new(shortcuts))) .manage(PinnedCaptureRegistry(Mutex::new(HashMap::new()))) + .manage(RecordRegionRegistry(Mutex::new(None))) .manage(OnScreenModeRegistry(Mutex::new(OnScreenModeState::Idle))) .invoke_handler(tauri::generate_handler![ begin_capture, @@ -1794,14 +2111,21 @@ pub fn run() { recording_status, set_capture_exclusion, recorder_ready, + fit_recorder, open_recorder, close_recorder, + open_record_region, + record_region_ready, + confirm_record_region, + cancel_record_region, set_recording_paused, show_recording_border, hide_recording_border, open_camera, camera_ready, - close_camera + close_camera, + prepare_camera_permission, + open_camera_privacy_settings ]) .setup(move |app| { if std::env::args().any(|argument| argument == "--background") @@ -2147,6 +2471,187 @@ mod shortcut_tests { } } +#[cfg(test)] +mod recorder_layout_tests { + use super::*; + + fn work_area(x: f64, y: f64, width: f64, height: f64) -> Rect { + Rect { + x, + y, + width, + height, + } + } + + #[test] + fn dock_frame_centres_above_the_taskbar_at_scale_one() { + let frame = dock_frame(work_area(0.0, 0.0, 1920.0, 1040.0), 1.0, 724.0, 140.0, 24.0); + assert_eq!( + frame, + Rect { + x: 598.0, + y: 876.0, + width: 724.0, + height: 140.0, + } + ); + } + + #[test] + fn dock_frame_scales_content_at_fractional_scales() { + let frame = dock_frame(work_area(0.0, 0.0, 2880.0, 1560.0), 1.5, 724.0, 140.0, 24.0); + assert_eq!( + frame, + Rect { + x: 897.0, + y: 1314.0, + width: 1086.0, + height: 210.0, + } + ); + let frame = dock_frame(work_area(0.0, 0.0, 3840.0, 2080.0), 2.0, 724.0, 140.0, 24.0); + assert_eq!(frame.width, 1448.0); + assert_eq!(frame.height, 280.0); + } + + #[test] + fn dock_frame_handles_a_work_area_away_from_the_origin() { + // A taskbar on the left pushes the work area right. + let frame = dock_frame( + work_area(80.0, 0.0, 1840.0, 1040.0), + 1.0, + 724.0, + 140.0, + 24.0, + ); + assert_eq!(frame.x, 80.0 + (1840.0 - 724.0) / 2.0); + assert_eq!(frame.y, 876.0); + } + + #[test] + fn dock_frame_clamps_content_taller_than_the_work_area() { + let frame = dock_frame(work_area(0.0, 0.0, 1920.0, 400.0), 1.0, 724.0, 900.0, 24.0); + assert_eq!(frame.height, 400.0); + assert_eq!(frame.y, 0.0); + } + + #[test] + fn logical_box_scales_to_physical_px() { + let area = logical_box_to_desktop( + (0, 0), + 1.0, + Rect { + x: 10.0, + y: 20.0, + width: 100.0, + height: 80.0, + }, + work_area(0.0, 0.0, 1920.0, 1080.0), + ); + assert_eq!( + area, + Rect { + x: 10.0, + y: 20.0, + width: 100.0, + height: 80.0, + } + ); + // A 1280x720 logical box on a 150% display is a 1920x1080 recording. + let area = logical_box_to_desktop( + (0, 0), + 1.5, + Rect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }, + work_area(0.0, 0.0, 1920.0, 1080.0), + ); + assert_eq!(area.width, 1920.0); + assert_eq!(area.height, 1080.0); + } + + #[test] + fn logical_box_supports_a_negative_origin() { + let area = logical_box_to_desktop( + (-1920, 0), + 1.0, + Rect { + x: 100.0, + y: 100.0, + width: 200.0, + height: 150.0, + }, + work_area(-1920.0, 0.0, 1920.0, 1080.0), + ); + assert_eq!(area.x, -1820.0); + assert_eq!(area.y, 100.0); + } + + #[test] + fn logical_box_clamps_at_the_display_edges() { + let area = logical_box_to_desktop( + (0, 0), + 1.0, + Rect { + x: 1850.0, + y: 1000.0, + width: 200.0, + height: 200.0, + }, + work_area(0.0, 0.0, 1920.0, 1080.0), + ); + assert_eq!(area.x, 1720.0); + assert_eq!(area.y, 880.0); + assert_eq!(area.width, 200.0); + assert_eq!(area.height, 200.0); + } + + #[test] + fn camera_frame_places_the_live_bubble_bottom_left() { + // 150% scaling: 260 logical px are 390 physical, 24 gap. + let frame = camera_frame(work_area(0.0, 0.0, 2880.0, 1560.0), 1.5, true); + assert_eq!(frame.width, 390.0); + assert_eq!(frame.height, 390.0); + assert_eq!(frame.x, 36.0); + assert_eq!(frame.y, 1560.0 - 390.0 - 36.0); + } + + #[test] + fn camera_frame_centres_the_blocked_panel() { + let frame = camera_frame(work_area(0.0, 0.0, 1920.0, 1040.0), 1.0, false); + assert_eq!( + frame, + Rect { + x: (1920.0 - 360.0) / 2.0, + y: (1040.0 - 220.0) / 2.0, + width: 360.0, + height: 220.0, + } + ); + } + + #[test] + fn logical_box_enforces_the_minimum_size() { + let area = logical_box_to_desktop( + (0, 0), + 1.0, + Rect { + x: 50.0, + y: 50.0, + width: 5.0, + height: 4.0, + }, + work_area(0.0, 0.0, 1920.0, 1080.0), + ); + assert_eq!(area.width, 32.0); + assert_eq!(area.height, 32.0); + } +} + #[cfg(test)] mod window_config_tests { #[test] diff --git a/apps/desktop/src-tauri/src/platform/mod.rs b/apps/desktop/src-tauri/src/platform/mod.rs index b19ee26..933db98 100644 --- a/apps/desktop/src-tauri/src/platform/mod.rs +++ b/apps/desktop/src-tauri/src/platform/mod.rs @@ -16,6 +16,8 @@ pub(crate) mod windows_recorder; pub mod windows_shell; #[cfg(target_os = "windows")] pub(crate) mod windows_ui; +#[cfg(target_os = "windows")] +pub(crate) mod windows_webview; pub mod xcap_backend; pub struct CapturedDisplay { @@ -59,11 +61,14 @@ pub trait PinnedWindowBackend: Send + Sync { pub trait ScreenRecordingBackend: Send + Sync { fn is_supported(&self) -> bool; fn sources(&self) -> Result, SnaphubError>; + /// Starts a recording. The second element names requested audio kinds + /// (`"system"` or `"microphone"`) that could not be opened; the video + /// still records, and the caller reports them. fn start( &self, request: &RecordingRequestDto, directory: &std::path::Path, - ) -> Result, SnaphubError>; + ) -> Result<(Box, Vec<&'static str>), SnaphubError>; } /// A recording in flight. Dropping one without `stop` abandons its output. diff --git a/apps/desktop/src-tauri/src/platform/windows_recorder/audio.rs b/apps/desktop/src-tauri/src/platform/windows_recorder/audio.rs index bbbfa33..4c6146a 100644 --- a/apps/desktop/src-tauri/src/platform/windows_recorder/audio.rs +++ b/apps/desktop/src-tauri/src/platform/windows_recorder/audio.rs @@ -34,14 +34,30 @@ const HNS_PER_SECOND: i64 = 10_000_000; #[derive(Debug, Clone, PartialEq, Eq)] pub enum AudioSource { /// Everything the machine is playing, via a loopback render endpoint. - System, + /// `None` follows the user's default output. + System(Option), /// A capture endpoint. `None` follows the user's default microphone. Microphone(Option), } impl AudioSource { fn is_loopback(&self) -> bool { - matches!(self, Self::System) + matches!(self, Self::System(_)) + } + + /// The explicitly chosen device id, if any. + fn explicit_id(&self) -> Option<&str> { + match self { + Self::System(id) | Self::Microphone(id) => id.as_deref().filter(|id| !id.is_empty()), + } + } + + /// `system` or `microphone`, for failure reporting. + pub fn kind(&self) -> &'static str { + match self { + Self::System(_) => "system", + Self::Microphone(_) => "microphone", + } } } @@ -342,6 +358,69 @@ fn convert(raw: &[u8], format: &WAVEFORMATEX, out: &mut Vec) { } } +/// Resolves the endpoint for a source: an explicitly chosen id first, then the +/// default endpoint of the same flow. A System id must resolve to a render +/// endpoint; anything else falls back to the default rather than failing. +fn resolve_endpoint( + enumerator: &IMMDeviceEnumerator, + source: &AudioSource, +) -> windows::core::Result { + if let Some(id) = source.explicit_id() { + let wide = HSTRING::from(id); + // SAFETY: `enumerator` and `wide` are live for the call. + if let Ok(device) = unsafe { enumerator.GetDevice(&wide) } { + let usable = match source { + AudioSource::System(_) => is_render_endpoint(&device), + AudioSource::Microphone(_) => true, + }; + if usable { + return Ok(device); + } + } + } + let flow = if source.is_loopback() { + eRender + } else { + eCapture + }; + // SAFETY: `enumerator` is live. + unsafe { enumerator.GetDefaultAudioEndpoint(flow, eConsole) } +} + +fn is_render_endpoint(device: &IMMDevice) -> bool { + use windows::Win32::Media::Audio::IMMEndpoint; + use windows::core::Interface as _; + device + .cast::() + .ok() + // SAFETY: the endpoint is live. + .and_then(|endpoint| unsafe { endpoint.GetDataFlow() }.ok()) + .is_some_and(|flow| flow == eRender) +} + +/// Synchronously checks that a requested source can be opened, without +/// recording anything. Used at recording start so a broken audio choice +/// warns instead of silently producing no track. +pub fn probe_source(source: &AudioSource) -> Result<(), SnaphubError> { + // SAFETY: COM is initialised for this thread; S_FALSE on repeat is fine. + unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) } + .ok() + .map_err(SnaphubError::audio)?; + // SAFETY: class and interface identifiers are matched. + let enumerator: IMMDeviceEnumerator = + unsafe { CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) } + .map_err(SnaphubError::audio)?; + let device = resolve_endpoint(&enumerator, source).map_err(SnaphubError::audio)?; + // SAFETY: `device` is live and the interface identifier matches. + let client: IAudioClient = + unsafe { device.Activate(CLSCTX_ALL, None) }.map_err(SnaphubError::audio)?; + // SAFETY: the returned pointer is owned by us and freed below. + let format_ptr = unsafe { client.GetMixFormat() }.map_err(SnaphubError::audio)?; + // SAFETY: the format was allocated by GetMixFormat and is released once. + unsafe { CoTaskMemFree(Some(format_ptr.cast())) }; + Ok(()) +} + fn capture_loop( source: AudioSource, path: &Path, @@ -357,25 +436,7 @@ fn capture_loop( unsafe { CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) } .map_err(SnaphubError::audio)?; - // An explicitly chosen microphone is resolved by id; everything else falls - // back to the user's current default endpoint. - let device = match &source { - AudioSource::Microphone(Some(id)) if !id.is_empty() => { - let wide = HSTRING::from(id.as_str()); - // SAFETY: `enumerator` and `wide` are live for the call. - unsafe { enumerator.GetDevice(&wide) } - } - other => { - let flow = if other.is_loopback() { - eRender - } else { - eCapture - }; - // SAFETY: `enumerator` is live. - unsafe { enumerator.GetDefaultAudioEndpoint(flow, eConsole) } - } - } - .map_err(SnaphubError::audio)?; + let device = resolve_endpoint(&enumerator, &source).map_err(SnaphubError::audio)?; // SAFETY: `device` is live and the interface identifier matches. let client: IAudioClient = unsafe { device.Activate(CLSCTX_ALL, None) }.map_err(SnaphubError::audio)?; @@ -481,3 +542,31 @@ fn capture_loop( let _ = unsafe { client.Stop() }; encoder.finish() } + +#[cfg(test)] +mod device_tests { + /// Prints the WASAPI endpoints on the dev machine. + /// + /// Ignored by default: it needs real audio hardware. Run it with + /// `cargo test -- --ignored --nocapture`. + #[test] + #[ignore = "needs audio hardware"] + fn lists_audio_endpoints() { + let devices = super::capture_devices().expect("endpoints enumerate"); + let microphones = devices + .iter() + .filter(|device| device.kind == "microphone") + .count(); + let systems = devices + .iter() + .filter(|device| device.kind == "system") + .count(); + println!("microphones={microphones} systems={systems}"); + for device in &devices { + println!( + "kind={} default={} name={}", + device.kind, device.is_default, device.name + ); + } + } +} diff --git a/apps/desktop/src-tauri/src/platform/windows_recorder/mod.rs b/apps/desktop/src-tauri/src/platform/windows_recorder/mod.rs index 7850c62..c0185c8 100644 --- a/apps/desktop/src-tauri/src/platform/windows_recorder/mod.rs +++ b/apps/desktop/src-tauri/src/platform/windows_recorder/mod.rs @@ -125,6 +125,72 @@ fn find_monitor(display_id: &str) -> Result<(HMONITOR, Rect, f64), SnaphubError> )) } +/// The desktop area the video actually covers, in physical px. +/// +/// Without a region it is the whole display; otherwise the display-local +/// region's desktop origin with the video's width and height, so cursor +/// samples line up with the cropped frames. +fn recorded_area(display: Rect, region: Option<&Rect>, width: u32, height: u32) -> Rect { + match region { + None => display, + Some(region) => Rect { + x: display.x + region.x, + y: display.y + region.y, + width: f64::from(width), + height: f64::from(height), + }, + } +} + +#[cfg(test)] +mod recorded_area_tests { + use super::recorded_area; + use crate::domain::Rect; + + fn rect(x: f64, y: f64, width: f64, height: f64) -> Rect { + Rect { + x, + y, + width, + height, + } + } + + #[test] + fn no_region_covers_the_whole_display() { + assert_eq!( + recorded_area(rect(0.0, 0.0, 1920.0, 1080.0), None, 1920, 1080), + rect(0.0, 0.0, 1920.0, 1080.0) + ); + } + + #[test] + fn a_region_offsets_by_the_display_origin() { + assert_eq!( + recorded_area( + rect(0.0, 0.0, 1920.0, 1080.0), + Some(&rect(100.0, 100.0, 640.0, 360.0)), + 640, + 360 + ), + rect(100.0, 100.0, 640.0, 360.0) + ); + } + + #[test] + fn a_negative_origin_display_stays_negative() { + assert_eq!( + recorded_area( + rect(-1920.0, 0.0, 1920.0, 1080.0), + Some(&rect(100.0, 100.0, 200.0, 150.0)), + 200, + 150 + ), + rect(-1820.0, 100.0, 200.0, 150.0) + ); + } +} + impl ScreenRecordingBackend for WindowsRecorderBackend { fn is_supported(&self) -> bool { wgc::is_supported() @@ -226,9 +292,9 @@ impl ScreenRecordingBackend for WindowsRecorderBackend { &self, request: &RecordingRequestDto, directory: &Path, - ) -> Result, SnaphubError> { - let session = WindowsRecordingSession::start(request, directory)?; - Ok(Box::new(session)) + ) -> Result<(Box, Vec<&'static str>), SnaphubError> { + let (session, audio_failures) = WindowsRecordingSession::start(request, directory)?; + Ok((Box::new(session), audio_failures)) } } @@ -275,7 +341,10 @@ pub struct WindowsRecordingSession { } impl WindowsRecordingSession { - fn start(request: &RecordingRequestDto, directory: &Path) -> Result { + fn start( + request: &RecordingRequestDto, + directory: &Path, + ) -> Result<(Self, Vec<&'static str>), SnaphubError> { std::fs::create_dir_all(directory).map_err(SnaphubError::record)?; encoder::startup()?; @@ -299,6 +368,15 @@ impl WindowsRecordingSession { let stream = CaptureStream::start(monitor, region, request.capture_cursor)?; let width = stream.width; let height = stream.height; + // The request region is in desktop coordinates; the area is computed + // from its display-local form, matching the frame pool above. + let local = request.region.map(|region| Rect { + x: (region.x - bounds.x).max(0.0), + y: (region.y - bounds.y).max(0.0), + width: region.width, + height: region.height, + }); + let area = recorded_area(bounds, local.as_ref(), width, height); let mut video = VideoEncoder::new(&video_path, stream.device(), width, height, fps)?; let running = Arc::new(AtomicBool::new(true)); @@ -379,41 +457,54 @@ impl WindowsRecordingSession { // The cursor track is recorded unconditionally: it cannot be // reconstructed later, and the smooth-cursor and zoom-on-click features - // are worthless without it. - let pointer = Some(PointerSampler::start(bounds, scale)); + // are worthless without it. The sampler works in the recorded area's + // coordinates, so crops line up with the video. + let pointer = Some(PointerSampler::start(area, scale)); + let mut audio_failures = Vec::new(); let system_audio = if request.system_audio { - AudioTrack::start(AudioSource::System, directory.join("audio-system.m4a")).ok() + let source = AudioSource::System(request.system_audio_device_id.clone()); + if audio::probe_source(&source).is_err() { + audio_failures.push(source.kind()); + None + } else { + AudioTrack::start(source, directory.join("audio-system.m4a")).ok() + } } else { None }; let microphone = if request.microphone { - AudioTrack::start( - AudioSource::Microphone(request.microphone_device_id.clone()), - directory.join("audio-mic.m4a"), - ) - .ok() + let source = AudioSource::Microphone(request.microphone_device_id.clone()); + if audio::probe_source(&source).is_err() { + audio_failures.push(source.kind()); + None + } else { + AudioTrack::start(source, directory.join("audio-mic.m4a")).ok() + } } else { None }; - Ok(Self { - id, - directory: directory.to_path_buf(), - video_path, - width, - height, - fps, - started: Instant::now(), - running, - paused, - counters, - worker: Some(worker), - outcome, - pointer, - system_audio, - microphone, - }) + Ok(( + Self { + id, + directory: directory.to_path_buf(), + video_path, + width, + height, + fps, + started: Instant::now(), + running, + paused, + counters, + worker: Some(worker), + outcome, + pointer, + system_audio, + microphone, + }, + audio_failures, + )) } fn write_cursor_track(&mut self) -> Option { @@ -543,9 +634,10 @@ mod tests { system_audio: false, microphone: false, microphone_device_id: None, + system_audio_device_id: None, }; - let session = backend.start(&request, &directory).expect("start"); + let (session, _) = backend.start(&request, &directory).expect("start"); std::thread::sleep(Duration::from_secs(4)); let stats = session.stats(); let artifacts = session.stop().expect("stop"); @@ -605,9 +697,10 @@ mod tests { system_audio: true, microphone: false, microphone_device_id: None, + system_audio_device_id: None, }; - let session = backend.start(&request, &directory).expect("start"); + let (session, _) = backend.start(&request, &directory).expect("start"); std::thread::sleep(Duration::from_secs(5)); let artifacts = session.stop().expect("stop"); diff --git a/apps/desktop/src-tauri/src/platform/windows_shell.rs b/apps/desktop/src-tauri/src/platform/windows_shell.rs index 915f608..3ccafc4 100644 --- a/apps/desktop/src-tauri/src/platform/windows_shell.rs +++ b/apps/desktop/src-tauri/src/platform/windows_shell.rs @@ -10,6 +10,36 @@ use windows::{ use crate::error::SnaphubError; +/// Opens the Windows camera privacy page. The URI is fixed here so no caller +/// can open an arbitrary location. +pub fn open_camera_privacy_settings() -> Result<(), SnaphubError> { + open_uri("ms-settings:privacy-webcam") +} + +fn open_uri(uri: &str) -> Result<(), SnaphubError> { + let verb = wide(OsStr::new("open")); + let target = wide(OsStr::new(uri)); + // SAFETY: the buffers are live for the call. + let result = unsafe { + ShellExecuteW( + None, + PCWSTR(verb.as_ptr()), + PCWSTR(target.as_ptr()), + PCWSTR::null(), + PCWSTR::null(), + SW_SHOWNORMAL, + ) + }; + if result.0 as isize > 32 { + Ok(()) + } else { + Err(SnaphubError::Window(format!( + "Windows could not open {uri} (ShellExecute code {})", + result.0 as isize + ))) + } +} + pub fn open_path(path: &Path, offer_open_with: bool) -> Result<(), SnaphubError> { let encoded_path = wide(path.as_os_str()); let open = wide(OsStr::new("open")); diff --git a/apps/desktop/src-tauri/src/platform/windows_webview.rs b/apps/desktop/src-tauri/src/platform/windows_webview.rs new file mode 100644 index 0000000..25901be --- /dev/null +++ b/apps/desktop/src-tauri/src/platform/windows_webview.rs @@ -0,0 +1,122 @@ +//! WebView2 permission plumbing for the camera window (Windows only). +//! +//! The dock's Camera button is the user's explicit consent, because it is a +//! click inside our own app. This grants camera access for the camera +//! webview only and clears a previously persisted deny, so the user never +//! sees a badly placed WebView2 prompt that cannot be undone. + +use std::sync::mpsc; +use std::time::Duration; + +use tauri::{AppHandle, Manager}; +use webview2_com::{ + Microsoft::Web::WebView2::Win32::{ + COREWEBVIEW2_PERMISSION_KIND, COREWEBVIEW2_PERMISSION_KIND_CAMERA, + COREWEBVIEW2_PERMISSION_STATE_ALLOW, COREWEBVIEW2_PERMISSION_STATE_DEFAULT, + ICoreWebView2_13, ICoreWebView2Controller, ICoreWebView2Profile4, + }, + PermissionRequestedEventHandler, SetPermissionStateCompletedHandler, +}; +use windows_core_061::{Interface as _, w}; + +use crate::error::SnaphubError; + +/// Origins the app serves: the release origin and the Vite dev server. +const ORIGIN_COUNT: usize = 2; +/// How long to wait for WebView2 to confirm the reset before continuing +/// with the grant handler alone. +const RESET_TIMEOUT: Duration = Duration::from_secs(10); + +/// Grants camera access for the camera webview and clears a persisted deny. +/// +/// Registration runs on the main thread through `with_webview`; the async +/// permission resets are awaited off-thread. Clearing is best-effort: the +/// `PermissionRequested` handler is the real grant, so a failed reset only +/// warns. +pub async fn prepare_camera_permission(app: &AppHandle) -> Result<(), SnaphubError> { + let Some(window) = app.get_webview_window("camera") else { + return Err(SnaphubError::Window("Camera window is unavailable".into())); + }; + let (setup_tx, setup_rx) = mpsc::channel::>(); + let (done_tx, done_rx) = mpsc::channel::>(); + window + .with_webview(move |webview| { + let result = register_camera_permission(&webview.controller(), done_tx); + let _ = setup_tx.send(result); + }) + .map_err(|error| SnaphubError::Window(format!("Camera webview is unavailable: {error}")))?; + setup_rx + .recv() + .map_err(|_| SnaphubError::Window("Camera permission setup did not complete".into()))? + .map_err(SnaphubError::Window)?; + let errors = tauri::async_runtime::spawn_blocking(move || { + let mut errors = Vec::new(); + for _ in 0..ORIGIN_COUNT { + match done_rx.recv_timeout(RESET_TIMEOUT) { + Ok(Ok(())) => {} + Ok(Err(error)) => errors.push(error), + Err(_) => { + errors.push("camera permission reset timed out".to_owned()); + break; + } + } + } + errors + }) + .await + .map_err(|error| SnaphubError::Window(format!("Camera permission wait failed: {error}")))?; + for error in errors { + eprintln!("SH-CAMERA-PERMISSION-001: {error}"); + } + Ok(()) +} + +fn register_camera_permission( + controller: &ICoreWebView2Controller, + done_tx: mpsc::Sender>, +) -> Result<(), String> { + // SAFETY: the controller is live. + let core = unsafe { controller.CoreWebView2() }.map_err(|error| error.to_string())?; + let mut token: i64 = 0; + // SAFETY: the webview is live, and it holds the handler. + unsafe { + core.add_PermissionRequested( + &PermissionRequestedEventHandler::create(Box::new(|_, args| { + let Some(args) = args else { return Ok(()) }; + let mut kind = COREWEBVIEW2_PERMISSION_KIND::default(); + args.PermissionKind(&mut kind)?; + if kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA { + args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)?; + } + Ok(()) + })), + &mut token, + ) + } + .map_err(|error| error.to_string())?; + let profile: ICoreWebView2Profile4 = core + .cast::() + .and_then(|core13| unsafe { core13.Profile() }) + .and_then(|profile| profile.cast()) + .map_err(|error| error.to_string())?; + for origin in [w!("https://tauri.localhost"), w!("http://localhost:1420")] { + let done_tx = done_tx.clone(); + let completed = SetPermissionStateCompletedHandler::create(Box::new( + move |result: windows_core_061::Result<()>| { + let _ = done_tx.send(result.map_err(|error| error.to_string())); + Ok(()) + }, + )); + // SAFETY: the profile is live; completion arrives on its own thread. + unsafe { + profile.SetPermissionState( + COREWEBVIEW2_PERMISSION_KIND_CAMERA, + origin, + COREWEBVIEW2_PERMISSION_STATE_DEFAULT, + &completed, + ) + } + .map_err(|error| error.to_string())?; + } + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/platform/xcap_backend.rs b/apps/desktop/src-tauri/src/platform/xcap_backend.rs index 586156a..0237e70 100644 --- a/apps/desktop/src-tauri/src/platform/xcap_backend.rs +++ b/apps/desktop/src-tauri/src/platform/xcap_backend.rs @@ -59,6 +59,37 @@ impl CaptureBackend for XcapPlatformBackend { } } +/// Physical desktop bounds of one display, by the id `sources()` reports. +/// +/// Cheap enumeration only: no thumbnails, no capture. Sync commands use this +/// instead of the thumbnail-capturing `sources()` call, which must never run +/// on the main thread. +pub fn display_bounds(display_id: &str) -> Result { + let monitors = Monitor::all().map_err(SnaphubError::capture)?; + monitors + .iter() + .find(|monitor| { + monitor + .id() + .map(|id| id.to_string() == display_id) + .unwrap_or(false) + }) + .and_then(|monitor| { + let (Ok(x), Ok(y), Ok(width), Ok(height)) = + (monitor.x(), monitor.y(), monitor.width(), monitor.height()) + else { + return None; + }; + Some(Rect { + x: f64::from(x), + y: f64::from(y), + width: f64::from(width), + height: f64::from(height), + }) + }) + .ok_or_else(|| SnaphubError::Window("The chosen display is unavailable".into())) +} + fn monitor_contains_point(monitor: &Monitor, point: Point) -> Result { let bounds = Rect { x: f64::from(monitor.x().map_err(SnaphubError::capture)?), diff --git a/apps/desktop/src-tauri/src/services/recording.rs b/apps/desktop/src-tauri/src/services/recording.rs index 7506fce..34a798e 100644 --- a/apps/desktop/src-tauri/src/services/recording.rs +++ b/apps/desktop/src-tauri/src/services/recording.rs @@ -53,7 +53,10 @@ impl RecordingService { self.exclusion.set_excluded(hwnd, excluded) } - pub fn start(&self, request: &RecordingRequestDto) -> Result<(), SnaphubError> { + /// Starts a recording. The returned names (`"system"` or `"microphone"`) + /// are requested audio kinds that could not be opened; the video still + /// records, and the caller reports them. + pub fn start(&self, request: &RecordingRequestDto) -> Result, SnaphubError> { let mut active = self.lock()?; if active.is_some() { return Err(SnaphubError::Record( @@ -61,8 +64,9 @@ impl RecordingService { )); } let directory = self.root.join(session_stamp()); - *active = Some(self.recorder.start(request, &directory)?); - Ok(()) + let (session, audio_failures) = self.recorder.start(request, &directory)?; + *active = Some(session); + Ok(audio_failures) } /// Live counters, or `None` when nothing is recording. diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index a27c89b..4f0ef66 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CapKit", - "version": "0.2.0", + "version": "0.2.1", "identifier": "com.snaphub.desktop", "build": { "beforeDevCommand": "pnpm dev", @@ -55,6 +55,19 @@ "bundle": { "active": true, "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "nsis": { + "installerIcon": "icons/icon.ico", + "installerHooks": "windows/installer-hooks.nsh" + } + }, "createUpdaterArtifacts": false, "shortDescription": "The lightweight desktop toolkit to capture, record, and showcase.", "longDescription": "CapKit is a lightweight desktop toolkit to capture, record, and showcase with fast in-place editing." diff --git a/apps/desktop/src-tauri/windows/installer-hooks.nsh b/apps/desktop/src-tauri/windows/installer-hooks.nsh new file mode 100644 index 0000000..f2d2088 --- /dev/null +++ b/apps/desktop/src-tauri/windows/installer-hooks.nsh @@ -0,0 +1,71 @@ +; CapKit installer hooks (wired via bundle.windows.nsis.installerHooks). +; +; A running tray process holds capkit-desktop.exe open, which makes the old +; uninstaller report "Unable to uninstall!" and the extractor fail writing the +; file. Both hooks stop any CapKit process and wait until the exe is gone or +; writable again before the normal flow continues. +; +; The uninstaller can only Call functions named "un.*", so the body exists +; twice with distinct labels. + +!macro NSIS_HOOK_PREINSTALL + Call CapKitKillAndWait +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + Call un.CapKitKillAndWait +!macroend + +Function CapKitKillAndWait + ; Ignore the exit code: 128 only means no CapKit process was running. + nsExec::Exec 'taskkill /F /T /IM capkit-desktop.exe' + Pop $0 + StrCpy $1 0 + CapKitKillAndWait_loop: + IntCmp $1 40 CapKitKillAndWait_locked + IntOp $1 $1 + 1 + Sleep 250 + ; Absent is fine; only a locked file blocks us. + IfFileExists "$INSTDIR\capkit-desktop.exe" CapKitKillAndWait_probe + Goto CapKitKillAndWait_done + CapKitKillAndWait_probe: + ; The file exists here, so opening for append never creates it, and fails + ; while a running process holds it. Closing without writing changes nothing. + ClearErrors + FileOpen $2 "$INSTDIR\capkit-desktop.exe" a + IfErrors CapKitKillAndWait_loop CapKitKillAndWait_unlocked + CapKitKillAndWait_unlocked: + FileClose $2 + CapKitKillAndWait_done: + Return + CapKitKillAndWait_locked: + MessageBox MB_OK|MB_ICONSTOP "CapKit is still running. Close it from the system tray, then run setup again." + Abort +FunctionEnd + +Function un.CapKitKillAndWait + ; Ignore the exit code: 128 only means no CapKit process was running. + nsExec::Exec 'taskkill /F /T /IM capkit-desktop.exe' + Pop $0 + StrCpy $3 0 + un_CapKitKillAndWait_loop: + IntCmp $3 40 un_CapKitKillAndWait_locked + IntOp $3 $3 + 1 + Sleep 250 + ; Absent is fine; only a locked file blocks us. + IfFileExists "$INSTDIR\capkit-desktop.exe" un_CapKitKillAndWait_probe + Goto un_CapKitKillAndWait_done + un_CapKitKillAndWait_probe: + ; The file exists here, so opening for append never creates it, and fails + ; while a running process holds it. Closing without writing changes nothing. + ClearErrors + FileOpen $4 "$INSTDIR\capkit-desktop.exe" a + IfErrors un_CapKitKillAndWait_loop un_CapKitKillAndWait_unlocked + un_CapKitKillAndWait_unlocked: + FileClose $4 + un_CapKitKillAndWait_done: + Return + un_CapKitKillAndWait_locked: + MessageBox MB_OK|MB_ICONSTOP "CapKit is still running. Close it from the system tray, then run setup again." + Abort +FunctionEnd diff --git a/apps/desktop/src/components/CameraPreview.test.tsx b/apps/desktop/src/components/CameraPreview.test.tsx new file mode 100644 index 0000000..4c19d12 --- /dev/null +++ b/apps/desktop/src/components/CameraPreview.test.tsx @@ -0,0 +1,97 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CameraPreview } from "./CameraPreview"; + +const mocks = { + ready: vi.fn<(mode: string) => Promise>(() => Promise.resolve()), + close: vi.fn<() => Promise>(() => Promise.resolve()), + prepare: vi.fn<() => Promise>(() => Promise.resolve()), + settings: vi.fn<() => Promise>(() => Promise.resolve()), + stream: vi.fn<() => Promise>(), +}; + +vi.mock("../lib/recordingTauri", () => ({ + cameraReady: (mode: string): Promise => mocks.ready(mode), + closeCamera: (): Promise => mocks.close(), + prepareCameraPermission: (): Promise => mocks.prepare(), + openCameraPrivacySettings: (): Promise => mocks.settings(), +})); + +function failingStream(name: string): () => Promise { + return (): Promise => { + const error = new DOMException("denied", name); + return Promise.reject(error); + }; +} + +describe("CameraPreview", () => { + beforeEach(() => { + Object.defineProperty(window.navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: mocks.stream }, + }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it("reveals itself under StrictMode remounting, where the first frame is cancelled", async () => { + mocks.stream.mockResolvedValue({ getTracks: () => [] } as unknown as MediaStream); + const { StrictMode } = await import("react"); + render( + + + , + ); + await waitFor(() => { + expect(mocks.ready).toHaveBeenCalled(); + }); + // One frame is cancelled by the StrictMode cleanup; the remount schedules + // again. Zero calls is the old readyRef bug. + expect(mocks.ready.mock.calls.length).toBeLessThanOrEqual(2); + }); + + it.each([ + ["NotAllowedError", "Windows is blocking camera access for Capkit.", true], + ["SecurityError", "Windows is blocking camera access for Capkit.", true], + ["NotFoundError", "No camera was found. Connect one and try again.", false], + ["OverconstrainedError", "No camera was found. Connect one and try again.", false], + ["NotReadableError", "The camera is being used by another app. Close it and try again.", false], + ["AbortError", "The camera is being used by another app. Close it and try again.", false], + ["Unknown", "The camera could not be started.", false], + ])( + "maps %s to its blocked copy and centres the panel", + async (name, copy, withSettings) => { + mocks.stream.mockImplementation(failingStream(name)); + render(); + + expect(await screen.findByText(copy)).toBeVisible(); + expect(mocks.ready).toHaveBeenCalledWith("blocked"); + if (withSettings) { + expect(screen.getByRole("button", { name: "Open camera settings" })).toBeVisible(); + } else { + expect(screen.queryByRole("button", { name: "Open camera settings" })).toBeNull(); + } + expect(screen.getByRole("button", { name: "Try again" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Close the camera" })).toBeVisible(); + }, + ); + + it("retries in place and reveals the live bubble on success", async () => { + mocks.stream.mockImplementation(failingStream("NotAllowedError")); + render(); + await screen.findByText("Windows is blocking camera access for Capkit."); + + mocks.stream.mockResolvedValue({ getTracks: () => [] } as unknown as MediaStream); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + + expect(screen.getByRole("button", { name: "Trying…" })).toBeDisabled(); + await waitFor(() => { + expect(mocks.ready).toHaveBeenCalledWith("live"); + }); + expect(await screen.findByLabelText("Camera preview")).toBeVisible(); + }); +}); diff --git a/apps/desktop/src/components/CameraPreview.tsx b/apps/desktop/src/components/CameraPreview.tsx index 6a77900..2287f1a 100644 --- a/apps/desktop/src/components/CameraPreview.tsx +++ b/apps/desktop/src/components/CameraPreview.tsx @@ -1,6 +1,11 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { cameraShapes, type CameraShape } from "../domain/videoScene"; -import { cameraReady, closeCamera } from "../lib/recordingTauri"; +import { + cameraReady, + closeCamera, + openCameraPrivacySettings, + prepareCameraPermission, +} from "../lib/recordingTauri"; const shapeClass: Readonly> = { circle: "rounded-full", @@ -8,19 +13,79 @@ const shapeClass: Readonly> = { square: "rounded-none", }; +type CameraFailure = + | { kind: "blocked" } + | { kind: "missing" } + | { kind: "busy" } + | { kind: "failed" }; + +const failureCopy: Record = { + blocked: "Windows is blocking camera access for Capkit.", + missing: "No camera was found. Connect one and try again.", + busy: "The camera is being used by another app. Close it and try again.", + failed: "The camera could not be started.", +}; + +function failureFor(error: unknown): CameraFailure { + const name = error instanceof DOMException + ? error.name + : typeof error === "object" && error !== null && "name" in error + ? String((error).name) + : ""; + if (name === "NotAllowedError" || name === "SecurityError") return { kind: "blocked" }; + if (name === "NotFoundError" || name === "OverconstrainedError") return { kind: "missing" }; + if (name === "NotReadableError" || name === "AbortError") return { kind: "busy" }; + return { kind: "failed" }; +} + /** * The floating webcam window. * * It is excluded from screen capture like the recorder dock: the camera is * recorded as its own track, so letting the preview appear in the screen video * would put two copies of the presenter in the export. + * + * The window stays hidden until the stream starts (or fails), then the + * backend places it: a bottom-left bubble when live, a centred panel when + * blocked. The dock's Camera button is the user's consent, so access is + * granted for this webview before asking, and a decline can be retried. */ export function CameraPreview(): React.JSX.Element { const [shape, setShape] = useState("circle"); - const [error, setError] = useState(null); + const [failure, setFailure] = useState(null); + const [retrying, setRetrying] = useState(false); const videoRef = useRef(null); const streamRef = useRef(null); - const readyRef = useRef(false); + + const stopStream = useCallback((): void => { + const stream = streamRef.current; + if (stream !== null) for (const track of stream.getTracks()) track.stop(); + streamRef.current = null; + }, []); + + const startStream = useCallback(async (): Promise => { + try { + await prepareCameraPermission(); + } catch { + // Fall through to getUserMedia: the old behaviour stays the fallback. + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { width: { ideal: 1280 }, height: { ideal: 720 } }, + audio: false, + }); + streamRef.current = stream; + if (videoRef.current !== null) videoRef.current.srcObject = stream; + setFailure(null); + await cameraReady("live"); + return true; + } catch (error: unknown) { + stopStream(); + setFailure(failureFor(error)); + await cameraReady("blocked").catch(() => undefined); + return false; + } + }, [stopStream]); useEffect(() => { document.documentElement.classList.add("on-screen-surface"); @@ -28,43 +93,32 @@ export function CameraPreview(): React.JSX.Element { }, []); useEffect(() => { - const lifetime = { active: true }; - void (async (): Promise => { - try { - const stream = await navigator.mediaDevices.getUserMedia({ - video: { width: { ideal: 1280 }, height: { ideal: 720 } }, - audio: false, - }); - if (!lifetime.active) { - for (const track of stream.getTracks()) track.stop(); - return; - } - streamRef.current = stream; - if (videoRef.current !== null) videoRef.current.srcObject = stream; - } catch { - if (lifetime.active) setError("No camera is available, or access was declined."); - } - })(); + const lifetime: { active: boolean; frame?: number } = { active: true }; + const frame = window.requestAnimationFrame(() => { + if (lifetime.active) void startStream(); + }); + lifetime.frame = frame; return (): void => { lifetime.active = false; + if (lifetime.frame !== undefined) window.cancelAnimationFrame(lifetime.frame); const stream = streamRef.current; if (stream !== null) for (const track of stream.getTracks()) track.stop(); streamRef.current = null; }; - }, []); + }, [startStream]); - useEffect(() => { - if (readyRef.current) return; - readyRef.current = true; - const frame = window.requestAnimationFrame(() => { - void cameraReady().catch(() => setError("The camera window could not be shown.")); - }); - return (): void => window.cancelAnimationFrame(frame); - }, []); + const retry = useCallback(async (): Promise => { + setRetrying(true); + try { + await startStream(); + } finally { + setRetrying(false); + } + }, [startStream]); return (
- {error === null ? ( + {failure === null ? (
) : ( -
+

- {error} + {failureCopy[failure.kind]}

+
+ {failure.kind === "blocked" ? ( + + ) : null} + + +
)}
diff --git a/apps/desktop/src/components/RecordRegion.test.tsx b/apps/desktop/src/components/RecordRegion.test.tsx new file mode 100644 index 0000000..40596e3 --- /dev/null +++ b/apps/desktop/src/components/RecordRegion.test.tsx @@ -0,0 +1,70 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RecordRegion } from "./RecordRegion"; + +const mocks = { + ready: vi.fn<() => Promise>(() => Promise.resolve()), + confirm: vi.fn<(box: unknown) => Promise>(() => Promise.resolve()), + cancel: vi.fn<() => Promise>(() => Promise.resolve()), +}; + +vi.mock("../lib/recordingTauri", () => ({ + recordRegionReady: (): Promise => mocks.ready(), + confirmRecordRegion: (box: unknown): Promise => mocks.confirm(box), + cancelRecordRegion: (): Promise => mocks.cancel(), +})); + +describe("RecordRegion", () => { + beforeEach(() => { + Object.defineProperties(HTMLElement.prototype, { + setPointerCapture: { configurable: true, value: vi.fn() }, + releasePointerCapture: { configurable: true, value: vi.fn() }, + hasPointerCapture: { configurable: true, value: vi.fn(() => true) }, + }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + function drawBox(width = 200, height = 120): void { + const surface = screen.getByRole("application", { name: "Choose a region to record" }); + fireEvent.pointerDown(surface, { button: 0, clientX: 100, clientY: 100, pointerId: 1 }); + fireEvent.pointerMove(surface, { clientX: 100 + width, clientY: 100 + height, pointerId: 1 }); + fireEvent.pointerUp(surface, { button: 0, pointerId: 1 }); + } + + it("confirms the logical box with the button and with Enter", () => { + render(); + drawBox(); + + fireEvent.click(screen.getByRole("button", { name: "Use this area" })); + expect(mocks.confirm).toHaveBeenCalledWith({ x: 100, y: 100, width: 200, height: 120 }); + + fireEvent.keyDown(window, { key: "Enter" }); + expect(mocks.confirm).toHaveBeenCalledTimes(2); + }); + + it("cancels with Escape and offers Redraw", () => { + render(); + drawBox(); + + fireEvent.click(screen.getByRole("button", { name: "Redraw" })); + expect(screen.queryByRole("button", { name: "Use this area" })).not.toBeInTheDocument(); + + drawBox(); + expect(screen.getByRole("button", { name: "Use this area" })).toBeVisible(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(mocks.cancel).toHaveBeenCalledTimes(1); + }); + + it("shows no confirm step for a drag under 32 px", () => { + render(); + drawBox(10, 10); + + expect(screen.queryByRole("button", { name: "Use this area" })).not.toBeInTheDocument(); + expect(mocks.confirm).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/components/RecordRegion.tsx b/apps/desktop/src/components/RecordRegion.tsx index afde3c9..f4f09f1 100644 --- a/apps/desktop/src/components/RecordRegion.tsx +++ b/apps/desktop/src/components/RecordRegion.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { cancelRecordRegion, confirmRecordRegion, recordRegionReady } from "../lib/recordingTauri"; type Drag = { readonly startX: number; readonly startY: number; readonly x: number; readonly y: number }; @@ -20,18 +21,34 @@ export function RecordRegion(): React.JSX.Element { return (): void => document.documentElement.classList.remove("on-screen-surface"); }, []); + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + void recordRegionReady().catch(() => undefined); + }); + return (): void => window.cancelAnimationFrame(frame); + }, []); + + const confirm = useCallback((box: { x: number; y: number; width: number; height: number }): void => { + void confirmRecordRegion(box).catch(() => undefined); + }, []); + useEffect(() => { function onKeyDown(event: KeyboardEvent): void { + if (event.key === "Enter" && committed !== null) { + const box = normalize(committed); + if (box.width >= minimumSize && box.height >= minimumSize) { + event.preventDefault(); + confirm(box); + } + return; + } if (event.key !== "Escape") return; event.preventDefault(); - void (async (): Promise => { - const { getCurrentWindow } = await import("@tauri-apps/api/window"); - await getCurrentWindow().destroy(); - })(); + void cancelRecordRegion().catch(() => undefined); } window.addEventListener("keydown", onKeyDown, true); return (): void => window.removeEventListener("keydown", onKeyDown, true); - }, []); + }, [committed, confirm]); function begin(event: React.PointerEvent): void { event.currentTarget.setPointerCapture(event.pointerId); @@ -84,6 +101,34 @@ export function RecordRegion(): React.JSX.Element { > {`${String(Math.round(box.width))} × ${String(Math.round(box.height))}`}

+ {committed === null ? null : ( +
+ + +
+ )} )}
diff --git a/apps/desktop/src/components/RecorderDock.test.tsx b/apps/desktop/src/components/RecorderDock.test.tsx index 37cb13d..566e829 100644 --- a/apps/desktop/src/components/RecorderDock.test.tsx +++ b/apps/desktop/src/components/RecorderDock.test.tsx @@ -43,26 +43,67 @@ const artifacts: RecordingArtifacts = { stats: { paused: false, elapsedSeconds: 4, encodedFrames: 120, droppedFrames: 0, bytesWritten: 1024 }, }; +const audioDeviceList = [ + { id: "mic-1", name: "Headset", kind: "microphone", isDefault: true }, + { id: "mic-2", name: "USB Mic", kind: "microphone", isDefault: false }, + { id: "spk-1", name: "Speakers", kind: "system", isDefault: true }, + { id: "spk-2", name: "Headphones", kind: "system", isDefault: false }, +]; + const mocks = { - start: vi.fn<() => Promise>(() => Promise.resolve()), + devices: vi.fn<() => Promise>(() => Promise.resolve(audioDeviceList)), + start: vi.fn< + ( + settings: unknown, + source: unknown, + region: unknown, + ) => Promise + >(() => Promise.resolve()), stop: vi.fn<() => Promise>(() => Promise.resolve(artifacts)), cancel: vi.fn<() => Promise>(() => Promise.resolve()), close: vi.fn<() => Promise>(() => Promise.resolve()), ready: vi.fn<() => Promise>(() => Promise.resolve()), + fit: vi.fn<(width: number, height: number) => Promise>(() => Promise.resolve()), + border: vi.fn<(bounds: unknown) => Promise>(() => Promise.resolve()), + openRegion: vi.fn<() => Promise>(() => Promise.resolve()), }; +const regionListeners = vi.hoisted(() => ({ + selected: null as null | ((selection: { displayId: string; bounds: { x: number; y: number; width: number; height: number } }) => void), + cancelled: null as null | (() => void), +})); + +const audioFailedListeners = vi.hoisted(() => ({ + handlers: [] as ((kind: string) => void)[], +})); + vi.mock("../lib/recordingTauri", () => ({ + fitRecorder: (width: number, height: number): Promise => mocks.fit(width, height), + openRecordRegion: (): Promise => mocks.openRegion(), + listenForRecordRegion: ( + onSelected: (selection: { displayId: string; bounds: { x: number; y: number; width: number; height: number } }) => void, + onCancelled: () => void, + ): Promise<() => void> => { + regionListeners.selected = onSelected; + regionListeners.cancelled = onCancelled; + return Promise.resolve((): void => undefined); + }, listRecordingSources: (): Promise => Promise.resolve([display, secondDisplay, editorWindow]), listAudioDevices: (): Promise => - Promise.resolve([{ id: "mic-1", name: "Headset", kind: "microphone", isDefault: true }]), - startRecording: (): Promise => mocks.start(), + mocks.devices(), + listenForAudioFailure: (onFailed: (kind: string) => void): Promise<() => void> => { + audioFailedListeners.handlers.push(onFailed); + return Promise.resolve((): void => undefined); + }, + startRecording: (settings: unknown, source: unknown, region: unknown): Promise => + mocks.start(settings, source, region), stopRecording: (): Promise => mocks.stop(), cancelRecording: (): Promise => mocks.cancel(), closeRecorder: (): Promise => mocks.close(), recorderReady: (): Promise => mocks.ready(), recordingStatus: (): Promise => Promise.resolve(null), - showRecordingBorder: (): Promise => Promise.resolve(), + showRecordingBorder: (bounds: unknown): Promise => mocks.border(bounds), hideRecordingBorder: (): Promise => Promise.resolve(), recordingSrc: (path: string): string => path, })); @@ -79,6 +120,9 @@ describe("RecorderDock", () => { afterEach(() => { cleanup(); vi.clearAllMocks(); + regionListeners.selected = null; + regionListeners.cancelled = null; + audioFailedListeners.handlers.length = 0; }); it("reveals itself only after its first frame, so a hidden window never flashes", async () => { @@ -88,6 +132,192 @@ describe("RecorderDock", () => { }); }); + it("reveals itself under StrictMode remounting, where the first frame is cancelled", async () => { + const { StrictMode } = await import("react"); + render( + + + , + ); + await waitFor(() => { + expect(mocks.ready).toHaveBeenCalled(); + }); + // One frame is cancelled by the StrictMode cleanup; the remount schedules + // again. Zero calls is the old readyRef bug. + expect(mocks.ready.mock.calls.length).toBeLessThanOrEqual(2); + }); + + it("fits the window before revealing it, and still reveals when fitting fails", async () => { + const rect = { + x: 0, y: 0, width: 724, height: 140, top: 0, left: 0, right: 724, bottom: 140, + toJSON: (): string => "{}", + } as DOMRect; + const measure = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect); + try { + render(); + await waitFor(() => { + expect(mocks.ready).toHaveBeenCalled(); + }); + expect(mocks.fit).toHaveBeenCalledWith(724, 140); + const fitOrder = mocks.fit.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY; + const readyOrder = mocks.ready.mock.invocationCallOrder[0] ?? Number.NEGATIVE_INFINITY; + expect(fitOrder).toBeLessThan(readyOrder); + } finally { + measure.mockRestore(); + } + }); + + it("shows the resize error but still reveals the dock when fitting fails", async () => { + const rect = { + x: 0, y: 0, width: 724, height: 140, top: 0, left: 0, right: 724, bottom: 140, + toJSON: (): string => "{}", + } as DOMRect; + const measure = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect); + mocks.fit.mockRejectedValueOnce(new Error("no monitor")); + try { + render(); + await waitFor(() => { + expect(mocks.ready).toHaveBeenCalled(); + }); + expect(await screen.findByText("The recorder could not be resized.")).toBeVisible(); + } finally { + measure.mockRestore(); + } + }); + + it("opens the source grid above the toolbar row and closes it with Escape", async () => { + render(); + const picker = await screen.findByRole("button", { name: "Choose what to record" }); + + fireEvent.click(picker); + const listbox = await screen.findByRole("listbox", { name: "Available sources" }); + const toolbar = screen.getByRole("toolbar", { name: "Recorder" }); + expect(toolbar.innerHTML.indexOf("Available sources")).toBeLessThan( + toolbar.innerHTML.indexOf("What to record"), + ); + + fireEvent.keyDown(listbox, { key: "Escape" }); + expect(screen.queryByRole("listbox", { name: "Available sources" })).not.toBeInTheDocument(); + expect(picker).toHaveFocus(); + }); + + it("disables Record in Region mode until an area is drawn", async () => { + render(); + await screen.findByRole("button", { name: "Choose what to record" }); + + fireEvent.click(screen.getByRole("button", { name: "Record a region" })); + + expect(screen.getByRole("button", { name: "Start recording" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Draw area to record" })).toBeVisible(); + }); + + it("records the drawn area and ignores areas drawn for another display", async () => { + render(); + await screen.findByRole("button", { name: "Choose what to record" }); + fireEvent.click(screen.getByRole("button", { name: "Record a region" })); + fireEvent.change(screen.getByRole("combobox", { name: "Countdown before recording" }), { + target: { value: "0" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Draw area to record" })); + expect(mocks.openRegion).toHaveBeenCalledTimes(1); + + regionListeners.selected?.({ displayId: "other", bounds: { x: 0, y: 0, width: 100, height: 100 } }); + expect(screen.getByRole("button", { name: "Start recording" })).toBeDisabled(); + + regionListeners.selected?.({ displayId: "1", bounds: { x: 10, y: 20, width: 640, height: 360 } }); + const redraw = await screen.findByRole("button", { name: "Redraw area to record" }); + expect(redraw).toHaveTextContent("640 × 360 · Redraw"); + expect(screen.getByRole("button", { name: "Start recording" })).not.toBeDisabled(); + + fireEvent.click(screen.getByRole("button", { name: "Start recording" })); + await waitFor(() => { + expect(mocks.border).toHaveBeenCalledWith({ x: 10, y: 20, width: 640, height: 360 }); + }); + expect(mocks.start).toHaveBeenCalledWith( + expect.objectContaining({ mode: "region" }), + expect.objectContaining({ displayId: "1" }), + { x: 10, y: 20, width: 640, height: 360 }, + ); + }); + + it("lists both device kinds with defaults and disables selects with their toggles", async () => { + render(); + await screen.findByRole("button", { name: "Choose what to record" }); + + const microphone = screen.getByRole("combobox", { name: "Microphone device" }); + const speaker = screen.getByRole("combobox", { name: "Speaker device" }); + expect(microphone).toBeDisabled(); + expect(speaker).not.toBeDisabled(); + for (const [select, names] of [ + [microphone, ["Default (Headset)", "Headset", "USB Mic"]], + [speaker, ["Default (Speakers)", "Speakers", "Headphones"]], + ] as const) { + const options = Array.from(select.querySelectorAll("option")).map((option) => option.textContent); + expect(options).toEqual(names); + } + + fireEvent.click(screen.getByRole("button", { name: "Record the microphone" })); + expect(screen.getByRole("combobox", { name: "Microphone device" })).not.toBeDisabled(); + }); + + it("resets a missing device on refocus and shows the fallback message", async () => { + window.localStorage.setItem( + "capkit.recorder.settings.v1", + JSON.stringify({ + mode: "display", + sourceId: "", + fps: 30, + countdown: 3, + systemAudio: true, + microphone: true, + microphoneDeviceId: "mic-1", + systemAudioDeviceId: "", + captureCursor: false, + }), + ); + render(); + await screen.findByRole("button", { name: "Choose what to record" }); + expect(screen.getByRole("combobox", { name: "Microphone device" })).toHaveValue("mic-1"); + + mocks.devices.mockResolvedValueOnce(audioDeviceList.filter((device) => device.id !== "mic-1")); + fireEvent(window, new Event("focus")); + await waitFor(() => { + expect(screen.getByRole("combobox", { name: "Microphone device" })).toHaveValue(""); + }); + expect( + screen.getByText("Your chosen microphone is not connected, so the default will be used."), + ).toBeVisible(); + }); + + it("warns when a requested audio track cannot be opened", async () => { + render(); + await screen.findByRole("button", { name: "Choose what to record" }); + + audioFailedListeners.handlers.forEach((handler) => handler("microphone")); + expect( + await screen.findByText("The microphone could not be recorded. The video is still recording."), + ).toBeVisible(); + + audioFailedListeners.handlers.forEach((handler) => handler("system")); + expect( + await screen.findByText("System audio could not be recorded. The video is still recording."), + ).toBeVisible(); + }); + + it("re-enables drawing with no error when the overlay is cancelled", async () => { + render(); + await screen.findByRole("button", { name: "Choose what to record" }); + fireEvent.click(screen.getByRole("button", { name: "Record a region" })); + + fireEvent.click(screen.getByRole("button", { name: "Draw area to record" })); + expect(screen.getByRole("button", { name: "Draw area to record" })).toBeDisabled(); + + regionListeners.cancelled?.(); + expect(await screen.findByRole("button", { name: "Draw area to record" })).not.toBeDisabled(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + it("offers the capture modes and lists sources for the chosen one, with a preview to pick from", async () => { render(); diff --git a/apps/desktop/src/components/RecorderDock.tsx b/apps/desktop/src/components/RecorderDock.tsx index 039b80f..cc1d16b 100644 --- a/apps/desktop/src/components/RecorderDock.tsx +++ b/apps/desktop/src/components/RecorderDock.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { isTauri } from "@tauri-apps/api/core"; import { countdownSeconds, @@ -10,19 +10,25 @@ import { recorderSettingsStorageKey, recordingLibrarySchema, recordingLibraryStorageKey, + type AudioDevice, type CaptureMode, type CountdownSeconds, type FrameRate, type RecorderSettings, type RecordingSource, type RecordingStats, + type RecordRegionSelection, } from "../domain/recording"; import { cancelRecording, closeCamera, closeRecorder, hideRecordingBorder, + listenForAudioFailure, + listenForRecordRegion, openCamera, + openRecordRegion, + fitRecorder, listAudioDevices, listRecordingSources, recorderReady, @@ -61,16 +67,21 @@ export function RecorderDock(): React.JSX.Element { const [phase, setPhase] = useState("setup"); const [settings, setSettings] = useState(defaultRecorderSettings); const [sources, setSources] = useState([]); - const [microphones, setMicrophones] = useState([]); + const [audioDevices, setAudioDevices] = useState([]); const [stats, setStats] = useState(null); const [countdown, setCountdown] = useState(0); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); const [excluded, setExcluded] = useState(true); const [paused, setPaused] = useState(false); const [camera, setCamera] = useState(false); const [sourcePickerOpen, setSourcePickerOpen] = useState(false); - const readyRef = useRef(false); + const [region, setRegion] = useState(null); + const [drawingRegion, setDrawingRegion] = useState(false); const pickerRef = useRef(null); + const dockSizeRef = useRef(null); + const sourceTriggerRef = useRef(null); + const pendingDisplayRef = useRef(null); useEffect(() => { document.documentElement.classList.add("on-screen-surface"); @@ -81,28 +92,65 @@ export function RecorderDock(): React.JSX.Element { setSettings(readRecorderSettings(window.localStorage.getItem(recorderSettingsStorageKey))); }, []); - // The dock is revealed only once its first styled frame exists, matching the - // capture overlay's prepare-then-reveal lifecycle. + // Measures the dock and fits the window to it. Skips zero sizes, which only + // happen where there is no layout (jsdom). + const fitDock = useCallback(async (): Promise => { + const node = dockSizeRef.current; + if (node === null) return; + const rect = node.getBoundingClientRect(); + const width = Math.ceil(rect.width); + const height = Math.ceil(rect.height); + if (width <= 0 || height <= 0) return; + await fitRecorder(width, height); + }, []); + + // Reveal ordering: the window is built hidden, the first fit runs, then the + // rAF fires and only then is the window revealed. A rejected fit still + // reveals, because a visible dock at the wrong size beats an invisible one. useEffect(() => { - if (readyRef.current) return; - readyRef.current = true; - const frame = window.requestAnimationFrame(() => { - void recorderReady().catch((cause: unknown) => { - setError(describeInvokeError(cause, "The recorder could not be shown")); + // The flag lives on an object because TypeScript narrows a plain `let` to + // its initial value inside the async closure below. + const lifetime = { active: true }; + let frame: number | null = null; + void (async (): Promise => { + try { + await fitDock(); + } catch (cause: unknown) { + if (lifetime.active) { + setError(describeInvokeError(cause, "The recorder could not be resized.")); + } + } + if (!lifetime.active) return; + frame = window.requestAnimationFrame(() => { + void recorderReady().catch((cause: unknown) => { + setError(describeInvokeError(cause, "The recorder could not be shown")); + }); }); + })(); + return (): void => { + lifetime.active = false; + if (frame !== null) window.cancelAnimationFrame(frame); + }; + }, [fitDock]); + + // Later fits (picker open or close, phase change, error line appearing) run + // without waiting for anything. Each sets absolute geometry, so the last + // call wins and no ordering guard is needed. + useLayoutEffect(() => { + const node = dockSizeRef.current; + if (node === null || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + void fitDock().catch(() => undefined); }); - return (): void => window.cancelAnimationFrame(frame); - }, []); + observer.observe(node); + return (): void => observer.disconnect(); + }, [fitDock]); useEffect(() => { const unlisten = (): void => undefined; void (async (): Promise => { - const [available, devices] = await Promise.all([ - listRecordingSources().catch(() => []), - listAudioDevices().catch(() => []), - ]); + const available = await listRecordingSources().catch(() => []); setSources(available); - setMicrophones(devices.filter((device) => device.kind === "microphone")); setSettings((current) => { if (current.sourceId !== "" && available.some((item) => item.id === current.sourceId)) return current; const primary = available.find((item) => item.kind === "display" && item.isPrimary) ?? available.at(0); @@ -120,6 +168,69 @@ export function RecorderDock(): React.JSX.Element { }); }, []); + const settingsRef = useRef(settings); + settingsRef.current = settings; + + // Devices refresh on mount and whenever the dock regains focus, so a device + // plugged in while the dock is open appears without reopening it. + const refreshDevices = useCallback(async (): Promise => { + const devices = await listAudioDevices().catch(() => []); + setAudioDevices(devices); + const current = settingsRef.current; + if ( + current.microphoneDeviceId !== "" && + !devices.some((device) => device.kind === "microphone" && device.id === current.microphoneDeviceId) + ) { + patch({ microphoneDeviceId: "" }); + setNotice("Your chosen microphone is not connected, so the default will be used."); + } + if ( + current.systemAudioDeviceId !== "" && + !devices.some((device) => device.kind === "system" && device.id === current.systemAudioDeviceId) + ) { + patch({ systemAudioDeviceId: "" }); + setNotice("Your chosen speaker is not connected, so the default will be used."); + } + }, [patch]); + + useEffect(() => { + void refreshDevices(); + }, [refreshDevices]); + + useEffect(() => { + function onFocus(): void { + void refreshDevices(); + } + window.addEventListener("focus", onFocus); + return (): void => window.removeEventListener("focus", onFocus); + }, [refreshDevices]); + + const handleAudioFailure = useCallback((kind: string): void => { + setNotice( + kind === "microphone" + ? "The microphone could not be recorded. The video is still recording." + : "System audio could not be recorded. The video is still recording.", + ); + }, []); + + // The backend emits this at recording start when a requested track cannot be + // opened. No isTauri guard: the dock only renders inside the Tauri recorder + // window, and the subscription must stay testable through the mocked module. + useEffect(() => { + let stop: (() => void) | undefined; + void listenForAudioFailure(handleAudioFailure) + .then((stopListening) => { + stop = stopListening; + }) + .catch(() => undefined); + return (): void => { + stop?.(); + }; + }, [handleAudioFailure]); + + const microphones = audioDevices.filter((device) => device.kind === "microphone"); + const speakers = audioDevices.filter((device) => device.kind === "system"); + const visibleSources = sources.filter((source) => settings.mode === "window" ? source.kind === "window" : source.kind === "display", ); @@ -129,8 +240,8 @@ export function RecorderDock(): React.JSX.Element { const active = visibleSources.find((source) => source.id === settings.sourceId) ?? visibleSources.at(0) ?? null; - // Closes the source picker on an outside click, since it floats over a - // window with almost no other chrome to click instead. + // Closes the source picker on a pointer down outside the dock card. The + // picker lives inside the card now, so the card is the boundary. useEffect(() => { if (!sourcePickerOpen) return; function onPointerDown(event: PointerEvent): void { @@ -140,32 +251,103 @@ export function RecorderDock(): React.JSX.Element { return (): void => document.removeEventListener("pointerdown", onPointerDown); }, [sourcePickerOpen]); + const closePickerFocusTrigger = useCallback((): void => { + setSourcePickerOpen(false); + sourceTriggerRef.current?.focus(); + }, []); + + // Region overlay results. A selection for a display that is no longer + // current is ignored, but drawing always ends. No isTauri guard: the dock + // only renders inside the Tauri recorder window, and the subscription must + // stay testable through the mocked recordingTauri module. + useEffect(() => { + let stop: (() => void) | undefined; + void listenForRecordRegion( + (selection) => { + if (selection.displayId !== pendingDisplayRef.current) { + setDrawingRegion(false); + return; + } + setRegion(selection); + setDrawingRegion(false); + }, + () => setDrawingRegion(false), + ) + .then((stopListening) => { + stop = stopListening; + }) + .catch(() => undefined); + return (): void => { + stop?.(); + }; + }, []); + + // Choosing a different display clears the drawn area; switching mode away + // and back keeps it. + const regionDisplayId = region?.displayId ?? null; + const activeDisplayId = active?.displayId ?? null; + useEffect(() => { + if (settings.mode !== "region" || regionDisplayId === null) return; + if (activeDisplayId !== regionDisplayId) setRegion(null); + }, [settings.mode, regionDisplayId, activeDisplayId]); + + const drawArea = useCallback((): void => { + if (active === null || drawingRegion) return; + pendingDisplayRef.current = active.displayId; + setDrawingRegion(true); + void openRecordRegion(active.displayId).catch((cause: unknown) => { + setDrawingRegion(false); + setError(describeInvokeError(cause, "The area picker could not be opened")); + }); + }, [active, drawingRegion]); + const begin = useCallback(async (): Promise => { - if (active === null) { + const target = + settings.mode === "region" && region !== null + ? (sources.find( + (source) => source.kind === "display" && source.displayId === region.displayId, + ) ?? active) + : active; + if (target === null) { setError("Choose something to record first"); return; } + setSourcePickerOpen(false); setError(null); try { // A window records as the crop of its display it currently occupies, so - // the region is derived rather than asked for again. - await startRecording(settings, active, settings.mode === "region" ? active.bounds : null); + // the region is derived rather than asked for again. A drawn area is + // used directly; `toRecordingRequest` keeps the window behaviour. + await startRecording( + settings, + target, + settings.mode === "region" && region !== null ? region.bounds : null, + ); setPhase("recording"); } catch (cause: unknown) { setPhase("setup"); setError(describeInvokeError(cause, "That recording could not be started")); void hideRecordingBorder(); } - }, [active, settings]); + }, [active, region, settings, sources]); function requestStart(): void { - if (active === null) { + const target = + settings.mode === "region" && region !== null + ? (sources.find( + (source) => source.kind === "display" && source.displayId === region.displayId, + ) ?? active) + : active; + if (target === null) { setError("Choose something to record first"); return; } + setSourcePickerOpen(false); // Shown for the whole countdown, not just once recording starts, so the // target is visible before a single frame is captured. - void showRecordingBorder(active.bounds).catch(() => undefined); + const bounds = + settings.mode === "region" && region !== null ? region.bounds : target.bounds; + void showRecordingBorder(bounds).catch(() => undefined); if (settings.countdown <= 0) { void begin(); return; @@ -220,27 +402,67 @@ export function RecorderDock(): React.JSX.Element { await closeRecorder().catch(() => undefined); } + function handleCardKeyDown(event: React.KeyboardEvent): void { + if (event.key === "Escape" && sourcePickerOpen) { + event.preventDefault(); + closePickerFocusTrigger(); + } + } + if (phase === "counting") { return ( -
-
- {countdown} +
+
+
+ {countdown} +
); } return ( -
+
+ {sourcePickerOpen ? ( +
+ {visibleSources.length === 0 ? ( +

Nothing available to record

+ ) : ( + visibleSources.map((source) => ( + + )) + )} +
+ ) : null} {phase === "recording" || phase === "saving" ? (
-
+
+
- {sourcePickerOpen ? ( -
- {visibleSources.length === 0 ? ( -

Nothing available to record

- ) : ( - visibleSources.map((source) => ( - - )) - )} -
- ) : null} -
+