From 68eda51cc4ab7ac4ff55aec6ec876d0b44af8215 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sun, 17 May 2026 18:50:57 -0400 Subject: [PATCH 01/13] feat: add Cap desktop action deeplinks --- .../desktop/src-tauri/src/deeplink_actions.rs | 166 +++++++++++++++++- apps/desktop/src-tauri/src/hotkeys.rs | 4 +- 2 files changed, 167 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a1170284877..13e6e55859d 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -6,7 +6,14 @@ use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Url}; use tracing::trace; -use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; +use crate::{ + App, ArcLock, + hotkeys::{self, HotkeyAction}, + recording::StartRecordingInputs, + recording_settings::{RecordingSettingsStore, RecordingTargetMode}, + tray, + windows::ShowCapWindow, +}; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -15,6 +22,14 @@ pub enum CaptureMode { Window(String), } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScreenshotTarget { + CurrentDisplay, + CurrentWindow, + Area, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DeepLinkAction { @@ -25,13 +40,34 @@ pub enum DeepLinkAction { capture_system_audio: bool, mode: RecordingMode, }, + StartRecordingWithCurrentSettings { + mode: RecordingMode, + }, + RunHotkeyAction { + action: HotkeyAction, + }, StopRecording, + RestartRecording, + TogglePauseRecording, + CycleRecordingMode, + SetRecordingMode { + mode: RecordingMode, + }, + OpenMain, + OpenRecordingPicker { + target_mode: Option, + }, OpenEditor { project_path: PathBuf, }, OpenSettings { page: Option, }, + OpenRecordings, + OpenScreenshots, + TakeScreenshot { + target: ScreenshotTarget, + }, } pub fn handle(app_handle: &AppHandle, urls: Vec) { @@ -144,15 +180,143 @@ impl DeepLinkAction { .await .map(|_| ()) } + DeepLinkAction::StartRecordingWithCurrentSettings { mode } => { + start_recording_with_current_settings(app, mode).await + } + DeepLinkAction::RunHotkeyAction { action } => { + hotkeys::handle_action(app.clone(), action).await + } DeepLinkAction::StopRecording => { crate::recording::stop_recording(app.clone(), app.state()).await } + DeepLinkAction::RestartRecording => { + hotkeys::handle_action(app.clone(), HotkeyAction::RestartRecording).await + } + DeepLinkAction::TogglePauseRecording => { + hotkeys::handle_action(app.clone(), HotkeyAction::TogglePauseRecording).await + } + DeepLinkAction::CycleRecordingMode => { + hotkeys::handle_action(app.clone(), HotkeyAction::CycleRecordingMode).await + } + DeepLinkAction::SetRecordingMode { mode } => set_recording_mode(app, mode), + DeepLinkAction::OpenMain => { + crate::show_window( + app.clone(), + ShowCapWindow::Main { + init_target_mode: None, + }, + ) + .await + } + DeepLinkAction::OpenRecordingPicker { target_mode } => { + open_recording_picker(app, target_mode).await + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } DeepLinkAction::OpenSettings { page } => { crate::show_window(app.clone(), ShowCapWindow::Settings { page }).await } + DeepLinkAction::OpenRecordings => { + open_settings_page(app, "recordings".to_string()).await + } + DeepLinkAction::OpenScreenshots => { + open_settings_page(app, "screenshots".to_string()).await + } + DeepLinkAction::TakeScreenshot { target } => take_screenshot(app, target).await, + } + } +} + +async fn start_recording_with_current_settings( + app: &AppHandle, + mode: RecordingMode, +) -> Result<(), String> { + let settings = RecordingSettingsStore::get(app) + .ok() + .flatten() + .unwrap_or_default(); + let state = app.state::>(); + + crate::set_mic_input(state.clone(), settings.mic_name).await?; + crate::set_camera_input(app.clone(), state.clone(), settings.camera_id, None).await?; + + let capture_target = settings.target.unwrap_or_else(|| { + use scap_targets::Display; + + ScreenCaptureTarget::Display { + id: Display::primary().id(), + } + }); + + crate::recording::start_recording( + app.clone(), + state, + StartRecordingInputs { + capture_target, + mode, + capture_system_audio: settings.system_audio, + organization_id: settings.organization_id, + }, + ) + .await + .map(|_| ()) +} + +async fn open_recording_picker( + app: &AppHandle, + target_mode: Option, +) -> Result<(), String> { + if let Some(target_mode) = target_mode { + crate::open_target_picker(app, target_mode).await; + return Ok(()); + } + + crate::show_window( + app.clone(), + ShowCapWindow::Main { + init_target_mode: None, + }, + ) + .await +} + +async fn open_settings_page(app: &AppHandle, page: String) -> Result<(), String> { + crate::show_window(app.clone(), ShowCapWindow::Settings { page: Some(page) }).await +} + +fn set_recording_mode(app: &AppHandle, mode: RecordingMode) -> Result<(), String> { + RecordingSettingsStore::set_mode(app, mode)?; + tray::update_tray_icon_for_mode(app, mode); + Ok(()) +} + +async fn take_screenshot(app: &AppHandle, target: ScreenshotTarget) -> Result<(), String> { + let capture_target = match target { + ScreenshotTarget::CurrentDisplay => { + use scap_targets::Display; + + let display = Display::get_containing_cursor().unwrap_or_else(Display::primary); + ScreenCaptureTarget::Display { id: display.id() } + } + ScreenshotTarget::CurrentWindow => { + use scap_targets::Window; + + let window = Window::get_topmost_at_cursor() + .ok_or_else(|| "No window found under cursor".to_string())?; + ScreenCaptureTarget::Window { id: window.id() } + } + ScreenshotTarget::Area => { + set_recording_mode(app, RecordingMode::Screenshot)?; + crate::open_target_picker(app, RecordingTargetMode::Area).await; + return Ok(()); + } + }; + + match crate::recording::take_screenshot(app.clone(), capture_target).await { + Ok(path) => { + crate::show_window(app.clone(), ShowCapWindow::ScreenshotEditor { path }).await } + Err(e) => Err(format!("Failed to take screenshot: {e}")), } } diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs index eccd9e700bf..1c50d4e448f 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -111,7 +111,7 @@ pub fn init(app: &AppHandle) { for (action, hotkey) in &store.hotkeys { if &Shortcut::from(*hotkey) == shortcut { - tokio::spawn(handle_hotkey(app.clone(), *action)); + tokio::spawn(handle_action(app.clone(), *action)); } } }) @@ -136,7 +136,7 @@ pub fn init(app: &AppHandle) { app.manage(Mutex::new(store)); } -async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), String> { +pub async fn handle_action(app: AppHandle, action: HotkeyAction) -> Result<(), String> { match action { HotkeyAction::StartStudioRecording => { let _ = RequestStartRecording { From 6806a95f0bf39c44aa0c909dd2c7997625e9debb Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Wed, 20 May 2026 15:39:17 -0400 Subject: [PATCH 02/13] fix: accept Cap action deeplinks --- .../desktop/src-tauri/src/deeplink_actions.rs | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index 13e6e55859d..e57e0950c75 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -106,6 +106,7 @@ pub fn handle(app_handle: &AppHandle, urls: Vec) { }); } +#[derive(Debug)] pub enum ActionParseFromUrlError { ParseFailed(String), Invalid, @@ -125,9 +126,10 @@ impl TryFrom<&Url> for DeepLinkAction { } match url.domain() { - Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction), - _ => Err(ActionParseFromUrlError::Invalid), - }?; + Some("action") => {} + Some(_) => return Err(ActionParseFromUrlError::NotAction), + None => return Err(ActionParseFromUrlError::Invalid), + }; let params = url .query_pairs() @@ -141,6 +143,42 @@ impl TryFrom<&Url> for DeepLinkAction { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_action_deeplink() { + let mut url = Url::parse("cap-desktop://action").unwrap(); + url.query_pairs_mut().append_pair( + "value", + &serde_json::to_string(&DeepLinkAction::RunHotkeyAction { + action: HotkeyAction::ScreenshotArea, + }) + .unwrap(), + ); + + let action = DeepLinkAction::try_from(&url).unwrap(); + + match action { + DeepLinkAction::RunHotkeyAction { action } => { + assert_eq!(action, HotkeyAction::ScreenshotArea); + } + _ => panic!("expected RunHotkeyAction"), + } + } + + #[test] + fn ignores_non_action_deeplink() { + let url = Url::parse("cap-desktop://login?value=ignored").unwrap(); + + assert!(matches!( + DeepLinkAction::try_from(&url), + Err(ActionParseFromUrlError::NotAction) + )); + } +} + impl DeepLinkAction { pub async fn execute(self, app: &AppHandle) -> Result<(), String> { match self { @@ -314,9 +352,7 @@ async fn take_screenshot(app: &AppHandle, target: ScreenshotTarget) -> Result<() }; match crate::recording::take_screenshot(app.clone(), capture_target).await { - Ok(path) => { - crate::show_window(app.clone(), ShowCapWindow::ScreenshotEditor { path }).await - } + Ok(path) => crate::show_window(app.clone(), ShowCapWindow::ScreenshotEditor { path }).await, Err(e) => Err(format!("Failed to take screenshot: {e}")), } } From 0530620ec28b007710715a3d0c8ed63ea895a8b2 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Wed, 20 May 2026 17:14:14 -0400 Subject: [PATCH 03/13] fix: keep screenshot mode from activating media inputs --- apps/desktop/src-tauri/src/windows.rs | 36 ++++++- .../routes/(window-chrome)/new-main/index.tsx | 100 ++++++++++++++---- .../src/routes/target-select-overlay.tsx | 92 +++++++++++++--- 3 files changed, 191 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index a2ad65fd569..1adf1dc2286 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -40,7 +40,7 @@ use crate::{ target_select_overlay::WindowFocusManager, window_exclusion::WindowExclusion, }; -use cap_recording::{feeds, sources::screen_capture::ScreenCaptureTarget}; +use cap_recording::{RecordingMode, feeds, sources::screen_capture::ScreenCaptureTarget}; #[cfg(target_os = "macos")] const DEFAULT_TRAFFIC_LIGHTS_INSET: LogicalPosition = LogicalPosition::new(12.0, 12.0); @@ -300,6 +300,40 @@ pub(crate) async fn restore_main_window_inputs(app: &AppHandle) { .ok() .flatten() .unwrap_or_default(); + + if matches!(settings.mode, Some(RecordingMode::Screenshot)) { + if let Err(err) = crate::set_mic_input(state.clone(), None).await { + warn!("Failed to suspend microphone input for screenshot mode: {err}"); + } + + let Some(operation_lock) = app.try_state::() else { + warn!("CameraWindowOperationLock unavailable while suspending camera input"); + return; + }; + + let _operation_guard = operation_lock.lock().await; + let (camera_feed, had_camera_input) = { + let app_state = &mut *state.write().await; + let had_camera_input = + app_state.selected_camera_id.is_some() || app_state.camera_in_use; + app_state.selected_camera_id = None; + app_state.camera_in_use = false; + app_state.camera_cleanup_done = true; + app_state.camera_preview.pause(); + (app_state.camera_feed.clone(), had_camera_input) + }; + + if had_camera_input && let Err(err) = camera_feed.ask(feeds::camera::RemoveInput).await { + warn!("Failed to suspend camera input for screenshot mode: {err}"); + } + + if let Some(window) = CapWindowId::Camera.get(app) { + let _ = window.hide(); + } + + return; + } + let stored_camera_id = settings.camera_id.clone(); if let Err(err) = crate::set_mic_input(state.clone(), settings.mic_name).await { diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index 1ef0a048ec2..a2b9cde573d 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -2058,8 +2058,61 @@ function Page() { const setCamera = createCameraMutation(); + const suspendRecordingInputsForScreenshot = async () => { + await Promise.all([ + commands + .setMicInput(null) + .catch((error) => + console.error( + "Failed to suspend mic input for screenshot mode:", + error, + ), + ), + commands + .setCameraInput(null, null) + .catch((error) => + console.error( + "Failed to suspend camera input for screenshot mode:", + error, + ), + ), + ]); + }; + + const restoreRecordingInputs = async ( + micName: string | null, + cameraID: DeviceOrModelID | null, + ) => { + if (micName) { + await setMicInput + .mutateAsync(micName) + .catch((error) => console.error("Failed to set mic input:", error)); + } + + if (cameraID) { + await setCamera + .mutateAsync({ model: cameraID }) + .catch((error) => console.error("Failed to set camera input:", error)); + } + }; + createUpdateCheck(); + createEffect((wasScreenshotMode) => { + const isScreenshotMode = rawOptions.mode === "screenshot"; + + if (isScreenshotMode && !wasScreenshotMode) { + void suspendRecordingInputsForScreenshot(); + } else if (!isScreenshotMode && wasScreenshotMode) { + void restoreRecordingInputs( + rawOptions.micName ?? null, + rawOptions.cameraID ?? null, + ); + } + + return isScreenshotMode; + }, false); + onMount(async () => { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); @@ -2091,16 +2144,19 @@ function Page() { void emit("main-window-ready"); scheduleTargetListPrewarm(); - if (rawOptions.micName) { - setMicInput - .mutateAsync(rawOptions.micName) - .catch((error) => console.error("Failed to set mic input:", error)); - } + const storedSettings = await recordingSettingsStore.get().catch((error) => { + console.error("Failed to read recording settings:", error); + return null; + }); + const mode = storedSettings?.mode ?? rawOptions.mode; - if (rawOptions.cameraID) { - setCamera - .mutateAsync({ model: rawOptions.cameraID }) - .catch((error) => console.error("Failed to set camera input:", error)); + if (mode === "screenshot") { + await suspendRecordingInputsForScreenshot(); + } else { + await restoreRecordingInputs( + storedSettings?.micName ?? rawOptions.micName ?? null, + storedSettings?.cameraId ?? rawOptions.cameraID ?? null, + ); } const unlistenFocus = currentWindow.onFocusChanged( @@ -2528,19 +2584,23 @@ function Page() { name="Area" class="flex-1" /> - { - toggleTargetMode("camera"); - }} - name="Camera Only" - class="flex-1" - /> + + { + toggleTargetMode("camera"); + }} + name="Camera Only" + class="flex-1" + /> + - + + + ); diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 4064596a572..625be75a684 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -56,7 +56,11 @@ import { } from "~/components/Cropper"; import ModeSelect from "~/components/ModeSelect"; import SelectionHint from "~/components/selection-hint"; -import { authStore, generalSettingsStore } from "~/store"; +import { + authStore, + generalSettingsStore, + recordingSettingsStore, +} from "~/store"; import { getCameraWindow } from "~/utils/camera-window"; import { createDevicesQuery } from "~/utils/devices"; import { @@ -1637,25 +1641,81 @@ function RecordingControls(props: { })); const setCamera = createCameraMutation(); - onMount(async () => { - if (rawOptions.micName) { - setMicInput - .mutateAsync(rawOptions.micName) + const suspendRecordingInputsForScreenshot = async () => { + await Promise.all([ + commands + .setMicInput(null) + .catch((error) => + console.error( + "Failed to suspend mic input for screenshot mode:", + error, + ), + ), + commands + .setCameraInput(null, null) + .catch((error) => + console.error( + "Failed to suspend camera input for screenshot mode:", + error, + ), + ), + ]); + }; + + const restoreRecordingInputs = async ( + micName: string | null, + cameraID: DeviceOrModelID | null, + ) => { + const isCameraOnly = props.target.variant === "cameraOnly"; + + if (micName) { + await setMicInput + .mutateAsync(micName) .catch((error) => console.error("Failed to set mic input:", error)); } - const isCameraOnly = props.target.variant === "cameraOnly"; - if (rawOptions.cameraID && "ModelID" in rawOptions.cameraID) - await setCamera.mutateAsync({ - model: { ModelID: rawOptions.cameraID.ModelID }, - skipCameraWindow: isCameraOnly, - }); - else if (rawOptions.cameraID && "DeviceID" in rawOptions.cameraID) - await setCamera.mutateAsync({ - model: { DeviceID: rawOptions.cameraID.DeviceID }, - skipCameraWindow: isCameraOnly, - }); + if (cameraID) { + await setCamera + .mutateAsync({ + model: cameraID, + skipCameraWindow: isCameraOnly, + }) + .catch((error) => console.error("Failed to set camera input:", error)); + } + }; + + createEffect((wasScreenshotMode) => { + const isScreenshotMode = rawOptions.mode === "screenshot"; + if (isScreenshotMode && !wasScreenshotMode) { + void suspendRecordingInputsForScreenshot(); + } else if (!isScreenshotMode && wasScreenshotMode) { + void restoreRecordingInputs( + rawOptions.micName ?? null, + rawOptions.cameraID ?? null, + ); + } + + return isScreenshotMode; + }, false); + + onMount(async () => { + const storedSettings = await recordingSettingsStore.get().catch((error) => { + console.error("Failed to read recording settings:", error); + return null; + }); + const mode = storedSettings?.mode ?? rawOptions.mode; + + if (mode === "screenshot") { + await suspendRecordingInputsForScreenshot(); + } else { + await restoreRecordingInputs( + storedSettings?.micName ?? rawOptions.micName ?? null, + storedSettings?.cameraId ?? rawOptions.cameraID ?? null, + ); + } + + const isCameraOnly = props.target.variant === "cameraOnly"; if (isCameraOnly) { const win = await getCameraWindow(); if (win) win.close(); From be6e68c6fadf1b3ed4949219841d1283ad43655b Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Thu, 21 May 2026 14:41:51 -0400 Subject: [PATCH 04/13] docs: add CleanShot replacement plan --- CLEANSHOT_X_REPLACEMENT_PLAN.md | 248 ++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 CLEANSHOT_X_REPLACEMENT_PLAN.md diff --git a/CLEANSHOT_X_REPLACEMENT_PLAN.md b/CLEANSHOT_X_REPLACEMENT_PLAN.md new file mode 100644 index 00000000000..fa202e9377d --- /dev/null +++ b/CLEANSHOT_X_REPLACEMENT_PLAN.md @@ -0,0 +1,248 @@ +# Cap CleanShot X Replacement Plan + +Created: 2026-05-21 + +## Goal + +Make Cap good enough as a daily screenshot utility that CleanShot X is no longer needed, while preserving Cap's stronger open-source, cross-platform, recording, editing, and sharing direction. + +## Active Branch Tracking + +Each major feature area should stay on its own focused branch so upstream PRs remain small and reviewable. + +| Area | Branch | Repo | Status | Notes | +| --- | --- | --- | --- | --- | +| Cap desktop action deeplinks | `codex/cap-desktop-action-deeplinks` | `ryanr14/Cap` | Pushed | Adds `cap-desktop://action?value=` action handling and screenshot-mode media input safeguards. | +| Raycast command pack | `codex/cap-raycast-deeplink-commands` | `ryanr14/cap-raycast-extension` | Pushed | Separate Raycast extension repo; commands now use direct Cap deeplink actions where available. | +| Screenshot post-capture actions | `codex/cap-screenshot-post-capture-actions` | `ryanr14/Cap` | Pushed | Adds default screenshot post-capture behavior for editor, overlay, or clipboard copy. | +| Floating pinned screenshots | TBD | `ryanr14/Cap` | Not started | Next high-value CleanShot replacement slice. | +| Standalone OCR capture-to-clipboard | TBD | `ryanr14/Cap` | Not started | Should reuse screenshot editor OCR plumbing without opening the full editor. | +| Screenshot overlay/history upgrades | TBD | `ryanr14/Cap` | Not started | Likely split overlay actions and history filters into separate branches if the diff grows. | +| Annotation parity tools | TBD | `ryanr14/Cap` | Not started | Prefer small tool-by-tool branches after the capture flow is solid. | +| Capture precision tools | TBD | `ryanr14/Cap` | Not started | Previous area, timer, crosshair, magnifier, freeze screen. | +| Scrolling capture | TBD | `ryanr14/Cap` | Not started | Needs architecture spike before implementation. | +| Desktop cleanup and recording polish | TBD | `ryanr14/Cap` | Not started | Lower-priority polish after screenshot replacement basics. | + +## Current Cap Strengths + +- Screen recording is already a strong fit: Instant and Studio modes, local editing, export to MP4/GIF, system audio, microphone, camera, cursor handling, keyboard capture, captions, transcripts, and share links. +- Screenshots already exist as a first-class mode with display/window/area capture, hotkeys, local screenshot storage, screenshot history, upload, copy, save, and editor windows. +- The screenshot editor already supports crop, aspect ratio, background, padding, rounding, shadow, border, layers, arrow, rectangle, circle, text, mask, blur/pixelate style masking, copy, save, open folder, delete, undo/redo, and native OCR-backed text selection. +- Cap already has a Quick Access-style overlay for recent recordings and screenshots, with copy/save/upload actions. +- The local branch `codex/cap-desktop-action-deeplinks` adds a better automation surface for Raycast-style workflows through `cap-desktop://action?value=`. + +## CleanShot X Surface To Match + +CleanShot X groups its value around: + +- Capture modes: area, fullscreen, window, previous area, self-timer, scrolling capture, freeze screen, crosshair, magnifier. +- Post-capture flow: Quick Access Overlay with copy/save/annotate/upload, restore recently closed overlay, drag-and-drop to other apps, auto-close, multi-display positioning. +- Annotation: crop, arrows, rectangles, filled rectangles, ellipses, lines, pixelate, blur, spotlight, counter, pencil, highlighter, text styles, editable project files, combining screenshots. +- Background polish: backgrounds, custom backgrounds, padding, alignment, aspect ratio, auto-balance. +- Screen recording: MP4/GIF, window/fullscreen/area, quality/FPS/resolution controls, mic/computer audio, DND, cursor, click capture, keystrokes, camera, trim, quality/resolution/audio controls. +- Sharing: cloud links for screenshots and videos, passwords, self-destruct, tags, custom domains, teams. +- Floating screenshots: pin any screenshot above all windows, resize, opacity, arrow-key positioning, lock-through mode. +- OCR: standalone capture-text flow that copies recognized text to clipboard. +- Automation: URL scheme commands for captures, post-capture actions, OCR, pinning, annotate, history, desktop icons, settings. + +## Replacement-Critical Gap List + +### P0: Raycast command pack over Cap deeplinks + +The fastest way to make Cap feel like a CleanShot replacement for Ryan is to build the Raycast layer on top of the deeplinks already added in this branch. + +Work items: + +- Add Raycast commands for screenshot display, screenshot window, screenshot area, record display/window/area, open recording picker, open screenshots, open recordings, pause/resume, restart, stop, and cycle mode. +- Add Raycast command preferences for default post-capture action: open editor, copy, save, upload, or leave in overlay. +- Add a "Copy Cap deeplink" developer command for testing individual action payloads. +- Add docs with example payloads for `cap-desktop://action?value=...`. + +Why it matters: + +CleanShot exposes many URL scheme commands. Cap now has the core app-control path, but it needs the launcher surface to feel immediate. + +### P0: Post-capture actions for screenshot hotkeys and deeplinks + +CleanShot lets captures specify an action like copy, save, annotate, upload, or pin. Cap currently tends to open the screenshot editor or overlay, depending on the path. + +Work items: + +- Introduce a shared `ScreenshotPostCaptureAction` type: `editor`, `overlay`, `copy`, `save`, `upload`, `pin`. +- Let screenshot hotkeys and deeplink actions pass the post-capture action. +- Store a default screenshot post-capture action in settings. +- Make screenshot display/window/area all use the same post-capture pipeline. + +Why it matters: + +This removes the biggest daily friction compared with CleanShot: one shortcut should do exactly what the user expects after capture. + +### P0: Floating pinned screenshots + +CleanShot's pinned screenshots are a habit-forming reference feature. Cap has transparent always-on-top window primitives, but not a dedicated pin workflow. + +Work items: + +- Add a `PinnedScreenshot` window type that displays an image above all windows. +- Add pin action from screenshot editor, screenshot history, quick overlay, and deeplink. +- Support resize, opacity, close, duplicate, and reveal/open editor. +- Add click-through or lock-through mode so apps underneath remain interactive. +- Persist pinned screenshot window position during the session. + +Why it matters: + +This is likely the single most missed CleanShot feature for day-to-day support, design review, and implementation work. + +### P1: Standalone capture-text command + +Cap has OCR in the screenshot editor, but CleanShot has a fast OCR mode that copies selected text directly to clipboard. + +Work items: + +- Add OCR target picker mode for selecting an area without opening the full editor. +- Reuse the existing native OCR engines from the screenshot editor. +- Copy recognized text to clipboard with optional line-break preservation. +- Add Raycast and deeplink command support. +- Add a fallback route to open the captured area in the editor if OCR fails. + +Why it matters: + +OCR is one of the small utilities that makes a screenshot tool replace system features, not just recording software. + +### P1: Better Quick Access Overlay for screenshots + +Cap has a recent media overlay, but screenshot handling is not yet CleanShot-like. + +Work items: + +- Change screenshot overlay primary action from "View" to "Edit" or make it configurable. +- Add pin, annotate/edit, copy, save, upload, delete, reveal, and drag-out affordances. +- Add restore recently closed overlay. +- Add configurable auto-close timing. +- Improve multi-display placement and remember overlay position. + +Why it matters: + +The overlay is where speed is won or lost. It should be a tiny command station, not just a preview. + +### P1: Screenshot history parity + +Cap has screenshot history in settings. CleanShot's history is closer to a capture inbox. + +Work items: + +- Add filters for screenshot/video/GIF/imported media. +- Add actions: copy, save, upload, pin, edit, delete, reveal, restore overlay. +- Add richer metadata: capture type, dimensions, created date, upload state. +- Add a command/deeplink to open history directly. +- Consider retention preferences. + +Why it matters: + +Ryan needs confidence that a capture is recoverable even after closing the overlay. + +### P1: Annotation tool parity + +Cap already covers the essential annotation base, but CleanShot still has more daily markup tools. + +Work items: + +- Add line tool. +- Add filled rectangle preset/tool. +- Add highlighter. +- Add pencil/freehand with smoothing. +- Add spotlight/emphasis tool. +- Add counter/step marker tool. +- Add curved arrow or arrow style variants. +- Add text style presets. +- Add combine-images support by importing another image as a movable layer. + +Why it matters: + +This is the visible "does it feel like CleanShot" layer once capture and post-capture actions are solved. + +### P2: Scrolling capture + +CleanShot supports scrolling capture across many apps. Cap does not appear to have this mode. + +Work items: + +- Start with browser/webview scrolling capture, where the capture target can be controlled more predictably. +- Add manual scrolling capture with guided stitching. +- Investigate macOS Accessibility-driven autoscroll for native apps. +- Add stitch preview and crop correction. +- Add Raycast/deeplink command once the mode is stable. + +Why it matters: + +High value, but harder and riskier than post-capture flow, pinning, OCR, and annotations. + +### P2: Capture precision tools + +CleanShot has crosshair, magnifier, freeze screen, exact dimensions, and previous area. + +Work items: + +- Add crosshair and magnifier to area selection. +- Add frozen-screen area selection option. +- Add exact size controls and aspect lock in area picker. +- Store and retake previous screenshot area. +- Add self-timer for screenshots. + +Why it matters: + +These are power-user features that make screenshots feel precise, especially for UI work. + +### P2: Desktop cleanup and recording polish + +CleanShot can hide desktop clutter and enable Do Not Disturb while recording. + +Work items: + +- Add macOS desktop icon hide/show commands or integration. +- Add optional focus/DND behavior while recording, if macOS APIs permit it cleanly. +- Add settings for whether these behaviors apply to screenshot, recording, or both. +- Add automation commands for hide/show/toggle desktop icons. + +Why it matters: + +Not core to screenshot replacement, but useful for demos and polished captures. + +## Suggested Build Order + +1. Raycast command pack over current Cap deeplinks. +2. Shared screenshot post-capture action pipeline. +3. Floating pinned screenshots. +4. Standalone capture-text/OCR-to-clipboard. +5. Screenshot overlay and history upgrades. +6. Annotation parity tools. +7. Capture precision tools. +8. Scrolling capture. +9. Desktop cleanup and recording polish. + +## First Issues To Create + +- Build Raycast commands for Cap action deeplinks. +- Add screenshot post-capture actions to Cap hotkeys and deeplinks. +- Add pinned screenshot windows. +- Add standalone OCR capture-to-clipboard. +- Upgrade screenshot Quick Access overlay actions. +- Add screenshot history filters and restore-overlay action. +- Add highlighter, line, pencil, counter, and spotlight annotation tools. +- Add previous-area and self-timer screenshot capture. +- Investigate scrolling screenshot capture architecture. + +## Source Notes + +- CleanShot X official feature list: https://cleanshot.com/features +- CleanShot X URL scheme API: https://cleanshot.com/docs-api +- Cap official product page: https://cap.so +- Cap local evidence: + - `README.md` + - `apps/desktop/src-tauri/src/deeplink_actions.rs` + - `apps/desktop/src-tauri/src/hotkeys.rs` + - `apps/desktop/src-tauri/src/recording.rs` + - `apps/desktop/src-tauri/src/screenshot_editor.rs` + - `apps/desktop/src/routes/screenshot-editor` + - `apps/desktop/src/routes/recordings-overlay.tsx` + - `apps/desktop/src/routes/(window-chrome)/settings/screenshots.tsx` From af51819b9c5c6da3dfbd686155b7165b05aac1bb Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Fri, 29 May 2026 09:19:04 -0400 Subject: [PATCH 05/13] Support device overrides in desktop deeplinks --- .../desktop/src-tauri/src/deeplink_actions.rs | 130 +++++++++++++- scripts/cap-desktop-action-deeplinks.js | 170 ++++++++++++++++++ 2 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 scripts/cap-desktop-action-deeplinks.js diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index e57e0950c75..a9f1b58b184 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -42,6 +42,16 @@ pub enum DeepLinkAction { }, StartRecordingWithCurrentSettings { mode: RecordingMode, + #[serde(default)] + mic_label: Option, + #[serde(default)] + use_mic: Option, + #[serde(default)] + camera_label: Option, + #[serde(default)] + use_camera: Option, + #[serde(default)] + capture_system_audio: Option, }, RunHotkeyAction { action: HotkeyAction, @@ -177,6 +187,66 @@ mod tests { Err(ActionParseFromUrlError::NotAction) )); } + + #[test] + fn parses_current_settings_recording_without_overrides() { + let mut url = Url::parse("cap-desktop://action").unwrap(); + url.query_pairs_mut().append_pair( + "value", + r#"{"start_recording_with_current_settings":{"mode":"studio"}}"#, + ); + + let action = DeepLinkAction::try_from(&url).unwrap(); + + match action { + DeepLinkAction::StartRecordingWithCurrentSettings { + mode, + mic_label, + use_mic, + camera_label, + use_camera, + capture_system_audio, + } => { + assert_eq!(mode, RecordingMode::Studio); + assert_eq!(mic_label, None); + assert_eq!(use_mic, None); + assert_eq!(camera_label, None); + assert_eq!(use_camera, None); + assert_eq!(capture_system_audio, None); + } + _ => panic!("expected StartRecordingWithCurrentSettings"), + } + } + + #[test] + fn parses_current_settings_recording_overrides() { + let mut url = Url::parse("cap-desktop://action").unwrap(); + url.query_pairs_mut().append_pair( + "value", + r#"{"start_recording_with_current_settings":{"mode":"instant","mic_label":"Studio Mic","use_mic":true,"camera_label":"Desk Camera","use_camera":true,"capture_system_audio":false}}"#, + ); + + let action = DeepLinkAction::try_from(&url).unwrap(); + + match action { + DeepLinkAction::StartRecordingWithCurrentSettings { + mode, + mic_label, + use_mic, + camera_label, + use_camera, + capture_system_audio, + } => { + assert_eq!(mode, RecordingMode::Instant); + assert_eq!(mic_label, Some("Studio Mic".to_string())); + assert_eq!(use_mic, Some(true)); + assert_eq!(camera_label, Some("Desk Camera".to_string())); + assert_eq!(use_camera, Some(true)); + assert_eq!(capture_system_audio, Some(false)); + } + _ => panic!("expected StartRecordingWithCurrentSettings"), + } + } } impl DeepLinkAction { @@ -218,8 +288,26 @@ impl DeepLinkAction { .await .map(|_| ()) } - DeepLinkAction::StartRecordingWithCurrentSettings { mode } => { - start_recording_with_current_settings(app, mode).await + DeepLinkAction::StartRecordingWithCurrentSettings { + mode, + mic_label, + use_mic, + camera_label, + use_camera, + capture_system_audio, + } => { + start_recording_with_current_settings( + app, + mode, + StartRecordingOverrides { + mic_label, + use_mic, + camera_label, + use_camera, + capture_system_audio, + }, + ) + .await } DeepLinkAction::RunHotkeyAction { action } => { hotkeys::handle_action(app.clone(), action).await @@ -269,6 +357,7 @@ impl DeepLinkAction { async fn start_recording_with_current_settings( app: &AppHandle, mode: RecordingMode, + overrides: StartRecordingOverrides, ) -> Result<(), String> { let settings = RecordingSettingsStore::get(app) .ok() @@ -276,8 +365,21 @@ async fn start_recording_with_current_settings( .unwrap_or_default(); let state = app.state::>(); - crate::set_mic_input(state.clone(), settings.mic_name).await?; - crate::set_camera_input(app.clone(), state.clone(), settings.camera_id, None).await?; + let mic_label = match overrides.use_mic { + Some(false) => None, + _ => overrides.mic_label.or(settings.mic_name), + }; + + let camera_id = match overrides.use_camera { + Some(false) => None, + _ => match overrides.camera_label { + Some(label) => Some(camera_id_for_label(&label)?), + None => settings.camera_id, + }, + }; + + crate::set_mic_input(state.clone(), mic_label).await?; + crate::set_camera_input(app.clone(), state.clone(), camera_id, None).await?; let capture_target = settings.target.unwrap_or_else(|| { use scap_targets::Display; @@ -293,7 +395,9 @@ async fn start_recording_with_current_settings( StartRecordingInputs { capture_target, mode, - capture_system_audio: settings.system_audio, + capture_system_audio: overrides + .capture_system_audio + .unwrap_or(settings.system_audio), organization_id: settings.organization_id, }, ) @@ -301,6 +405,22 @@ async fn start_recording_with_current_settings( .map(|_| ()) } +#[derive(Debug, Default)] +struct StartRecordingOverrides { + mic_label: Option, + use_mic: Option, + camera_label: Option, + use_camera: Option, + capture_system_audio: Option, +} + +fn camera_id_for_label(label: &str) -> Result { + cap_camera::list_cameras() + .find(|camera| camera.display_name() == label) + .map(|camera| DeviceOrModelID::from_info(&camera)) + .ok_or_else(|| format!("No camera with label \"{}\"", label)) +} + async fn open_recording_picker( app: &AppHandle, target_mode: Option, diff --git a/scripts/cap-desktop-action-deeplinks.js b/scripts/cap-desktop-action-deeplinks.js new file mode 100644 index 00000000000..3b8ea8498e8 --- /dev/null +++ b/scripts/cap-desktop-action-deeplinks.js @@ -0,0 +1,170 @@ +import { spawnSync } from "node:child_process"; + +const actions = [ + { + name: "open-main", + payload: "open_main", + expected: "Opens or focuses the main Cap window.", + }, + { + name: "screenshot-area", + payload: { take_screenshot: { target: "area" } }, + expected: + "Opens the target selector in screenshot area mode. After selection, opens the screenshot editor without starting camera or mic preview.", + }, + { + name: "screenshot-current-display", + payload: { take_screenshot: { target: "current_display" } }, + expected: + "Captures the display under the cursor and opens the screenshot editor.", + }, + { + name: "screenshot-current-window", + payload: { take_screenshot: { target: "current_window" } }, + expected: + "Captures the window under the cursor and opens the screenshot editor. If no window is found, the app logs an error and no editor opens.", + }, + { + name: "hotkey-screenshot-area", + payload: { run_hotkey_action: { action: "screenshotArea" } }, + expected: + "Runs the same path as the Screenshot Area hotkey and opens the screenshot area selector.", + }, + { + name: "open-screenshots", + payload: "open_screenshots", + expected: "Opens Settings to the screenshots page.", + }, + { + name: "open-recordings", + payload: "open_recordings", + expected: "Opens Settings to the recordings page.", + }, + { + name: "recording-picker", + payload: { open_recording_picker: { target_mode: null } }, + expected: "Opens or focuses the main Cap window.", + }, + { + name: "recording-picker-display", + payload: { open_recording_picker: { target_mode: "display" } }, + expected: "Opens the target selector in display mode.", + }, + { + name: "recording-picker-window", + payload: { open_recording_picker: { target_mode: "window" } }, + expected: "Opens the target selector in window mode.", + }, + { + name: "recording-picker-area", + payload: { open_recording_picker: { target_mode: "area" } }, + expected: "Opens the target selector in area mode.", + }, + { + name: "start-studio-current-settings", + payload: { start_recording_with_current_settings: { mode: "studio" } }, + expected: + "Starts a Studio recording using saved target, mic, camera, system-audio, and organization settings.", + }, + { + name: "start-instant-current-settings", + payload: { start_recording_with_current_settings: { mode: "instant" } }, + expected: + "Starts an Instant recording using saved target, mic, camera, system-audio, and organization settings.", + }, + { + name: "stop-recording", + payload: "stop_recording", + expected: "Stops the active recording.", + }, + { + name: "toggle-pause-recording", + payload: "toggle_pause_recording", + expected: "Pauses or resumes the active recording.", + }, + { + name: "restart-recording", + payload: "restart_recording", + expected: "Restarts the active recording.", + }, + { + name: "cycle-recording-mode", + payload: "cycle_recording_mode", + expected: + "Cycles saved mode through Studio, Instant, and Screenshot, and updates the tray icon.", + }, + { + name: "set-screenshot-mode", + payload: { set_recording_mode: { mode: "screenshot" } }, + expected: "Saves Screenshot as the current mode and updates the tray icon.", + }, + { + name: "open-settings-screenshots", + payload: { open_settings: { page: "screenshots" } }, + expected: "Opens Settings to the screenshots page.", + }, +]; + +const args = process.argv.slice(2); +const flags = new Set(args.filter((arg) => arg.startsWith("--"))); +const actionName = args.find((arg) => !arg.startsWith("--")); + +function urlFor(payload) { + return `cap-desktop://action?value=${encodeURIComponent(JSON.stringify(payload))}`; +} + +function printAction(action) { + const url = urlFor(action.payload); + console.log(action.name); + console.log(` payload: ${JSON.stringify(action.payload)}`); + console.log(` url: ${url}`); + console.log(` command: open '${url}'`); + console.log(` expected: ${action.expected}`); +} + +function printHelp() { + console.log( + "Usage: node scripts/cap-desktop-action-deeplinks.js [name] [--open]", + ); + console.log(""); + console.log("With no name, prints every test command."); + console.log("With --open, opens the selected deeplink on macOS."); + console.log(""); + console.log("Names:"); + for (const action of actions) { + console.log(` ${action.name}`); + } +} + +if (flags.has("--help")) { + printHelp(); +} else if (!actionName) { + for (const action of actions) { + printAction(action); + console.log(""); + } +} else { + const action = actions.find((candidate) => candidate.name === actionName); + + if (!action) { + console.error(`Unknown deeplink action: ${actionName}`); + console.error( + "Run `node scripts/cap-desktop-action-deeplinks.js --help` for valid names.", + ); + process.exitCode = 1; + } else { + printAction(action); + + if (flags.has("--open")) { + if (process.platform !== "darwin") { + console.error("--open currently uses the macOS `open` command."); + process.exitCode = 1; + } else { + const result = spawnSync("open", [urlFor(action.payload)], { + stdio: "inherit", + }); + process.exitCode = result.status ?? 1; + } + } + } +} From a7c5d5bc23ae433fc2597d5bac45c3e9452bd61b Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sat, 6 Jun 2026 17:33:18 -0400 Subject: [PATCH 06/13] Separate desktop logs by bundle identifier --- apps/desktop/src-tauri/src/main.rs | 33 ++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 0480cbbc7e5..4e0e2e0cd26 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,10 +1,12 @@ #![recursion_limit = "256"] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#[cfg(target_os = "macos")] +use std::ffi::CStr; use std::sync::Arc; use cap_desktop_lib::DynLoggingLayer; -use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer}; const TOKIO_WORKER_THREAD_STACK_SIZE: usize = 16 * 1024 * 1024; @@ -56,7 +58,7 @@ fn main() { let path = dirs::home_dir() .unwrap() .join("Library/Logs") - .join("so.cap.desktop"); + .join(macos_log_bundle_identifier().unwrap_or_else(|| "so.cap.desktop".to_string())); #[cfg(not(target_os = "macos"))] let path = dirs::data_local_dir() @@ -158,6 +160,33 @@ fn main() { .block_on(cap_desktop_lib::run(handle, logs_dir)); } +#[cfg(target_os = "macos")] +fn macos_log_bundle_identifier() -> Option { + use cocoa::base::{id, nil}; + use cocoa::foundation::NSAutoreleasePool; + use objc::{class, msg_send, sel, sel_impl}; + + unsafe { + let _pool = NSAutoreleasePool::new(nil); + let bundle: id = msg_send![class!(NSBundle), mainBundle]; + if bundle == nil { + return None; + } + + let identifier: id = msg_send![bundle, bundleIdentifier]; + if identifier == nil { + return None; + } + + let utf8: *const std::os::raw::c_char = msg_send![identifier, UTF8String]; + if utf8.is_null() { + return None; + } + + Some(CStr::from_ptr(utf8).to_string_lossy().into_owned()) + } +} + fn install_panic_hook(logs_dir: std::path::PathBuf) { let prev = std::panic::take_hook(); let panics_log = logs_dir.join("panics.log"); From a3ff3f99cd5a0436552046420197e5b201b54fff Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sun, 7 Jun 2026 09:53:37 -0400 Subject: [PATCH 07/13] fix: address desktop deeplink review feedback --- .../desktop/src-tauri/src/deeplink_actions.rs | 18 ++-- apps/desktop/src-tauri/src/main.rs | 2 +- apps/desktop/src-tauri/src/windows.rs | 8 +- .../routes/(window-chrome)/new-main/index.tsx | 61 +++-------- .../src/routes/target-select-overlay.tsx | 67 +++--------- .../src/utils/screenshot-recording-inputs.ts | 101 ++++++++++++++++++ 6 files changed, 146 insertions(+), 111 deletions(-) create mode 100644 apps/desktop/src/utils/screenshot-recording-inputs.ts diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a9f1b58b184..4e86f8c24c6 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -343,12 +343,8 @@ impl DeepLinkAction { DeepLinkAction::OpenSettings { page } => { crate::show_window(app.clone(), ShowCapWindow::Settings { page }).await } - DeepLinkAction::OpenRecordings => { - open_settings_page(app, "recordings".to_string()).await - } - DeepLinkAction::OpenScreenshots => { - open_settings_page(app, "screenshots".to_string()).await - } + DeepLinkAction::OpenRecordings => open_settings_page(app, "recordings").await, + DeepLinkAction::OpenScreenshots => open_settings_page(app, "screenshots").await, DeepLinkAction::TakeScreenshot { target } => take_screenshot(app, target).await, } } @@ -439,8 +435,14 @@ async fn open_recording_picker( .await } -async fn open_settings_page(app: &AppHandle, page: String) -> Result<(), String> { - crate::show_window(app.clone(), ShowCapWindow::Settings { page: Some(page) }).await +async fn open_settings_page(app: &AppHandle, page: &str) -> Result<(), String> { + crate::show_window( + app.clone(), + ShowCapWindow::Settings { + page: Some(page.to_owned()), + }, + ) + .await } fn set_recording_mode(app: &AppHandle, mode: RecordingMode) -> Result<(), String> { diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 4e0e2e0cd26..39b408678f4 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -6,7 +6,7 @@ use std::ffi::CStr; use std::sync::Arc; use cap_desktop_lib::DynLoggingLayer; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer}; +use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt}; const TOKIO_WORKER_THREAD_STACK_SIZE: usize = 16 * 1024 * 1024; diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 1adf1dc2286..15c15ccd920 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -306,12 +306,14 @@ pub(crate) async fn restore_main_window_inputs(app: &AppHandle) { warn!("Failed to suspend microphone input for screenshot mode: {err}"); } - let Some(operation_lock) = app.try_state::() else { + let operation_lock = app.try_state::(); + let _operation_guard = if let Some(operation_lock) = operation_lock.as_ref() { + Some(operation_lock.lock().await) + } else { warn!("CameraWindowOperationLock unavailable while suspending camera input"); - return; + None }; - let _operation_guard = operation_lock.lock().await; let (camera_feed, had_camera_input) = { let app_state = &mut *state.write().await; let had_camera_input = diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index a2b9cde573d..aca94c85bb0 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -63,6 +63,7 @@ import { listWindows, listWindowsWithThumbnails, } from "~/utils/queries"; +import { createRecordingInputHandlers } from "~/utils/screenshot-recording-inputs"; import { type CaptureDisplay, type CaptureDisplayWithThumbnail, @@ -2058,43 +2059,14 @@ function Page() { const setCamera = createCameraMutation(); - const suspendRecordingInputsForScreenshot = async () => { - await Promise.all([ - commands - .setMicInput(null) - .catch((error) => - console.error( - "Failed to suspend mic input for screenshot mode:", - error, - ), - ), - commands - .setCameraInput(null, null) - .catch((error) => - console.error( - "Failed to suspend camera input for screenshot mode:", - error, - ), - ), - ]); - }; - - const restoreRecordingInputs = async ( - micName: string | null, - cameraID: DeviceOrModelID | null, - ) => { - if (micName) { - await setMicInput - .mutateAsync(micName) - .catch((error) => console.error("Failed to set mic input:", error)); - } - - if (cameraID) { - await setCamera - .mutateAsync({ model: cameraID }) - .catch((error) => console.error("Failed to set camera input:", error)); - } - }; + const { + restoreRecordingInputs, + suspendRecordingInputsForScreenshot, + syncRecordingInputsForMode, + } = createRecordingInputHandlers({ + setMicInput: (name) => setMicInput.mutateAsync(name), + setCameraInput: (args) => setCamera.mutateAsync(args), + }); createUpdateCheck(); @@ -2148,16 +2120,11 @@ function Page() { console.error("Failed to read recording settings:", error); return null; }); - const mode = storedSettings?.mode ?? rawOptions.mode; - - if (mode === "screenshot") { - await suspendRecordingInputsForScreenshot(); - } else { - await restoreRecordingInputs( - storedSettings?.micName ?? rawOptions.micName ?? null, - storedSettings?.cameraId ?? rawOptions.cameraID ?? null, - ); - } + await syncRecordingInputsForMode({ + mode: rawOptions.mode, + micName: storedSettings?.micName ?? rawOptions.micName ?? null, + cameraID: storedSettings?.cameraId ?? rawOptions.cameraID ?? null, + }); const unlistenFocus = currentWindow.onFocusChanged( ({ payload: focused }) => { diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 625be75a684..5612b9c400e 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -68,6 +68,7 @@ import { createOptionsQuery, createOrganizationsQuery, } from "~/utils/queries"; +import { createRecordingInputHandlers } from "~/utils/screenshot-recording-inputs"; import { type CameraInfo, commands, @@ -1641,48 +1642,15 @@ function RecordingControls(props: { })); const setCamera = createCameraMutation(); - const suspendRecordingInputsForScreenshot = async () => { - await Promise.all([ - commands - .setMicInput(null) - .catch((error) => - console.error( - "Failed to suspend mic input for screenshot mode:", - error, - ), - ), - commands - .setCameraInput(null, null) - .catch((error) => - console.error( - "Failed to suspend camera input for screenshot mode:", - error, - ), - ), - ]); - }; - - const restoreRecordingInputs = async ( - micName: string | null, - cameraID: DeviceOrModelID | null, - ) => { - const isCameraOnly = props.target.variant === "cameraOnly"; - - if (micName) { - await setMicInput - .mutateAsync(micName) - .catch((error) => console.error("Failed to set mic input:", error)); - } - - if (cameraID) { - await setCamera - .mutateAsync({ - model: cameraID, - skipCameraWindow: isCameraOnly, - }) - .catch((error) => console.error("Failed to set camera input:", error)); - } - }; + const { + restoreRecordingInputs, + suspendRecordingInputsForScreenshot, + syncRecordingInputsForMode, + } = createRecordingInputHandlers({ + setMicInput: (name) => setMicInput.mutateAsync(name), + setCameraInput: (args) => setCamera.mutateAsync(args), + skipCameraWindow: () => props.target.variant === "cameraOnly", + }); createEffect((wasScreenshotMode) => { const isScreenshotMode = rawOptions.mode === "screenshot"; @@ -1704,16 +1672,11 @@ function RecordingControls(props: { console.error("Failed to read recording settings:", error); return null; }); - const mode = storedSettings?.mode ?? rawOptions.mode; - - if (mode === "screenshot") { - await suspendRecordingInputsForScreenshot(); - } else { - await restoreRecordingInputs( - storedSettings?.micName ?? rawOptions.micName ?? null, - storedSettings?.cameraId ?? rawOptions.cameraID ?? null, - ); - } + await syncRecordingInputsForMode({ + mode: rawOptions.mode, + micName: storedSettings?.micName ?? rawOptions.micName ?? null, + cameraID: storedSettings?.cameraId ?? rawOptions.cameraID ?? null, + }); const isCameraOnly = props.target.variant === "cameraOnly"; if (isCameraOnly) { diff --git a/apps/desktop/src/utils/screenshot-recording-inputs.ts b/apps/desktop/src/utils/screenshot-recording-inputs.ts new file mode 100644 index 00000000000..f4a7fbc0c85 --- /dev/null +++ b/apps/desktop/src/utils/screenshot-recording-inputs.ts @@ -0,0 +1,101 @@ +import { + commands, + type DeviceOrModelID, + type RecordingMode, +} from "~/utils/tauri"; + +type SetCameraInput = (args: { + model: DeviceOrModelID; + skipCameraWindow?: boolean; +}) => Promise; + +type RecordingInputHandlers = { + restoreRecordingInputs: ( + micName: string | null, + cameraID: DeviceOrModelID | null, + ) => Promise; + suspendRecordingInputsForScreenshot: () => Promise; + syncRecordingInputsForMode: (args: { + mode: RecordingMode; + micName: string | null; + cameraID: DeviceOrModelID | null; + }) => Promise; +}; + +type SkipCameraWindow = boolean | (() => boolean); + +export function createRecordingInputHandlers({ + setMicInput, + setCameraInput, + skipCameraWindow, +}: { + setMicInput: (name: string) => Promise; + setCameraInput: SetCameraInput; + skipCameraWindow?: SkipCameraWindow; +}): RecordingInputHandlers { + const getSkipCameraWindow = () => + typeof skipCameraWindow === "function" + ? skipCameraWindow() + : skipCameraWindow; + + const suspendRecordingInputsForScreenshot = async () => { + await Promise.all([ + commands + .setMicInput(null) + .catch((error) => + console.error( + "Failed to suspend mic input for screenshot mode:", + error, + ), + ), + commands + .setCameraInput(null, null) + .catch((error) => + console.error( + "Failed to suspend camera input for screenshot mode:", + error, + ), + ), + ]); + }; + + const restoreRecordingInputs = async ( + micName: string | null, + cameraID: DeviceOrModelID | null, + ) => { + if (micName) { + await setMicInput(micName).catch((error) => + console.error("Failed to set mic input:", error), + ); + } + + if (cameraID) { + await setCameraInput({ + model: cameraID, + skipCameraWindow: getSkipCameraWindow(), + }).catch((error) => console.error("Failed to set camera input:", error)); + } + }; + + const syncRecordingInputsForMode = async ({ + mode, + micName, + cameraID, + }: { + mode: RecordingMode; + micName: string | null; + cameraID: DeviceOrModelID | null; + }) => { + if (mode === "screenshot") { + await suspendRecordingInputsForScreenshot(); + } else { + await restoreRecordingInputs(micName, cameraID); + } + }; + + return { + restoreRecordingInputs, + suspendRecordingInputsForScreenshot, + syncRecordingInputsForMode, + }; +} From bba6ed2c018bb2ad932fb2be711778353d71c5a9 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sun, 7 Jun 2026 10:03:17 -0400 Subject: [PATCH 08/13] fix: restore mode after area screenshot deeplink --- .../desktop/src-tauri/src/deeplink_actions.rs | 50 +++++++++++++++++-- apps/desktop/src-tauri/src/lib.rs | 2 + .../src-tauri/src/recording_settings.rs | 9 +++- .../src-tauri/src/target_select_overlay.rs | 2 + 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index 4e86f8c24c6..eddf006c5d9 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -2,9 +2,12 @@ use cap_recording::{ RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget, }; use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +use std::{ + path::{Path, PathBuf}, + sync::{Mutex, PoisonError}, +}; use tauri::{AppHandle, Manager, Url}; -use tracing::trace; +use tracing::{trace, warn}; use crate::{ App, ArcLock, @@ -15,6 +18,8 @@ use crate::{ windows::ShowCapWindow, }; +static TEMPORARY_SCREENSHOT_MODE: Mutex>> = Mutex::new(None); + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CaptureMode { @@ -451,6 +456,45 @@ fn set_recording_mode(app: &AppHandle, mode: RecordingMode) -> Result<(), String Ok(()) } +fn begin_temporary_screenshot_mode(app: &AppHandle) -> Result<(), String> { + let previous_mode = RecordingSettingsStore::get(app) + .map(|settings| settings.and_then(|settings| settings.mode))?; + + if matches!(previous_mode, Some(RecordingMode::Screenshot)) { + return Ok(()); + } + + { + let mut pending = TEMPORARY_SCREENSHOT_MODE + .lock() + .unwrap_or_else(PoisonError::into_inner); + + if pending.is_none() { + *pending = Some(previous_mode); + } + } + + set_recording_mode(app, RecordingMode::Screenshot) +} + +pub(crate) fn restore_temporary_recording_mode(app: &AppHandle) { + let previous_mode = TEMPORARY_SCREENSHOT_MODE + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(); + + let Some(previous_mode) = previous_mode else { + return; + }; + + if let Err(err) = RecordingSettingsStore::set_mode_option(app, previous_mode) { + warn!("Failed to restore recording mode after screenshot deeplink: {err}"); + return; + } + + tray::update_tray_icon_for_mode(app, previous_mode.unwrap_or_default()); +} + async fn take_screenshot(app: &AppHandle, target: ScreenshotTarget) -> Result<(), String> { let capture_target = match target { ScreenshotTarget::CurrentDisplay => { @@ -467,7 +511,7 @@ async fn take_screenshot(app: &AppHandle, target: ScreenshotTarget) -> Result<() ScreenCaptureTarget::Window { id: window.id() } } ScreenshotTarget::Area => { - set_recording_mode(app, RecordingMode::Screenshot)?; + begin_temporary_screenshot_mode(app)?; crate::open_target_picker(app, RecordingTargetMode::Area).await; return Ok(()); } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1d7f868f42b..c400e57d8bc 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -5174,6 +5174,8 @@ fn close_target_select_overlays(app: &AppHandle) { if !saw_overlay && let Some(focus_manager) = focus_manager { focus_manager.shutdown(app); } + + deeplink_actions::restore_temporary_recording_mode(app); } #[cfg(target_os = "windows")] diff --git a/apps/desktop/src-tauri/src/recording_settings.rs b/apps/desktop/src-tauri/src/recording_settings.rs index c8935289818..bb5c11620a1 100644 --- a/apps/desktop/src-tauri/src/recording_settings.rs +++ b/apps/desktop/src-tauri/src/recording_settings.rs @@ -48,10 +48,17 @@ impl RecordingSettingsStore { } pub fn set_mode(app: &AppHandle, mode: RecordingMode) -> Result<(), String> { + Self::set_mode_option(app, Some(mode)) + } + + pub(crate) fn set_mode_option( + app: &AppHandle, + mode: Option, + ) -> Result<(), String> { let store = app.store("store").map_err(|e| e.to_string())?; let mut settings = Self::get(app)?.unwrap_or_default(); - settings.mode = Some(mode); + settings.mode = mode; store.set(Self::KEY, serde_json::json!(settings)); store.save().map_err(|e| e.to_string()) diff --git a/apps/desktop/src-tauri/src/target_select_overlay.rs b/apps/desktop/src-tauri/src/target_select_overlay.rs index 82eac1701f0..6cea4ac4637 100644 --- a/apps/desktop/src-tauri/src/target_select_overlay.rs +++ b/apps/desktop/src-tauri/src/target_select_overlay.rs @@ -326,6 +326,8 @@ pub async fn close_target_select_overlays( state.destroy(&display_id, app.global_shortcut()); } + crate::deeplink_actions::restore_temporary_recording_mode(&app); + Ok(()) } From 289356eec79018234c8ce675346fe81738773793 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sun, 7 Jun 2026 10:04:36 -0400 Subject: [PATCH 09/13] docs: remove CleanShot plan from deeplink PR --- CLEANSHOT_X_REPLACEMENT_PLAN.md | 248 -------------------------------- 1 file changed, 248 deletions(-) delete mode 100644 CLEANSHOT_X_REPLACEMENT_PLAN.md diff --git a/CLEANSHOT_X_REPLACEMENT_PLAN.md b/CLEANSHOT_X_REPLACEMENT_PLAN.md deleted file mode 100644 index fa202e9377d..00000000000 --- a/CLEANSHOT_X_REPLACEMENT_PLAN.md +++ /dev/null @@ -1,248 +0,0 @@ -# Cap CleanShot X Replacement Plan - -Created: 2026-05-21 - -## Goal - -Make Cap good enough as a daily screenshot utility that CleanShot X is no longer needed, while preserving Cap's stronger open-source, cross-platform, recording, editing, and sharing direction. - -## Active Branch Tracking - -Each major feature area should stay on its own focused branch so upstream PRs remain small and reviewable. - -| Area | Branch | Repo | Status | Notes | -| --- | --- | --- | --- | --- | -| Cap desktop action deeplinks | `codex/cap-desktop-action-deeplinks` | `ryanr14/Cap` | Pushed | Adds `cap-desktop://action?value=` action handling and screenshot-mode media input safeguards. | -| Raycast command pack | `codex/cap-raycast-deeplink-commands` | `ryanr14/cap-raycast-extension` | Pushed | Separate Raycast extension repo; commands now use direct Cap deeplink actions where available. | -| Screenshot post-capture actions | `codex/cap-screenshot-post-capture-actions` | `ryanr14/Cap` | Pushed | Adds default screenshot post-capture behavior for editor, overlay, or clipboard copy. | -| Floating pinned screenshots | TBD | `ryanr14/Cap` | Not started | Next high-value CleanShot replacement slice. | -| Standalone OCR capture-to-clipboard | TBD | `ryanr14/Cap` | Not started | Should reuse screenshot editor OCR plumbing without opening the full editor. | -| Screenshot overlay/history upgrades | TBD | `ryanr14/Cap` | Not started | Likely split overlay actions and history filters into separate branches if the diff grows. | -| Annotation parity tools | TBD | `ryanr14/Cap` | Not started | Prefer small tool-by-tool branches after the capture flow is solid. | -| Capture precision tools | TBD | `ryanr14/Cap` | Not started | Previous area, timer, crosshair, magnifier, freeze screen. | -| Scrolling capture | TBD | `ryanr14/Cap` | Not started | Needs architecture spike before implementation. | -| Desktop cleanup and recording polish | TBD | `ryanr14/Cap` | Not started | Lower-priority polish after screenshot replacement basics. | - -## Current Cap Strengths - -- Screen recording is already a strong fit: Instant and Studio modes, local editing, export to MP4/GIF, system audio, microphone, camera, cursor handling, keyboard capture, captions, transcripts, and share links. -- Screenshots already exist as a first-class mode with display/window/area capture, hotkeys, local screenshot storage, screenshot history, upload, copy, save, and editor windows. -- The screenshot editor already supports crop, aspect ratio, background, padding, rounding, shadow, border, layers, arrow, rectangle, circle, text, mask, blur/pixelate style masking, copy, save, open folder, delete, undo/redo, and native OCR-backed text selection. -- Cap already has a Quick Access-style overlay for recent recordings and screenshots, with copy/save/upload actions. -- The local branch `codex/cap-desktop-action-deeplinks` adds a better automation surface for Raycast-style workflows through `cap-desktop://action?value=`. - -## CleanShot X Surface To Match - -CleanShot X groups its value around: - -- Capture modes: area, fullscreen, window, previous area, self-timer, scrolling capture, freeze screen, crosshair, magnifier. -- Post-capture flow: Quick Access Overlay with copy/save/annotate/upload, restore recently closed overlay, drag-and-drop to other apps, auto-close, multi-display positioning. -- Annotation: crop, arrows, rectangles, filled rectangles, ellipses, lines, pixelate, blur, spotlight, counter, pencil, highlighter, text styles, editable project files, combining screenshots. -- Background polish: backgrounds, custom backgrounds, padding, alignment, aspect ratio, auto-balance. -- Screen recording: MP4/GIF, window/fullscreen/area, quality/FPS/resolution controls, mic/computer audio, DND, cursor, click capture, keystrokes, camera, trim, quality/resolution/audio controls. -- Sharing: cloud links for screenshots and videos, passwords, self-destruct, tags, custom domains, teams. -- Floating screenshots: pin any screenshot above all windows, resize, opacity, arrow-key positioning, lock-through mode. -- OCR: standalone capture-text flow that copies recognized text to clipboard. -- Automation: URL scheme commands for captures, post-capture actions, OCR, pinning, annotate, history, desktop icons, settings. - -## Replacement-Critical Gap List - -### P0: Raycast command pack over Cap deeplinks - -The fastest way to make Cap feel like a CleanShot replacement for Ryan is to build the Raycast layer on top of the deeplinks already added in this branch. - -Work items: - -- Add Raycast commands for screenshot display, screenshot window, screenshot area, record display/window/area, open recording picker, open screenshots, open recordings, pause/resume, restart, stop, and cycle mode. -- Add Raycast command preferences for default post-capture action: open editor, copy, save, upload, or leave in overlay. -- Add a "Copy Cap deeplink" developer command for testing individual action payloads. -- Add docs with example payloads for `cap-desktop://action?value=...`. - -Why it matters: - -CleanShot exposes many URL scheme commands. Cap now has the core app-control path, but it needs the launcher surface to feel immediate. - -### P0: Post-capture actions for screenshot hotkeys and deeplinks - -CleanShot lets captures specify an action like copy, save, annotate, upload, or pin. Cap currently tends to open the screenshot editor or overlay, depending on the path. - -Work items: - -- Introduce a shared `ScreenshotPostCaptureAction` type: `editor`, `overlay`, `copy`, `save`, `upload`, `pin`. -- Let screenshot hotkeys and deeplink actions pass the post-capture action. -- Store a default screenshot post-capture action in settings. -- Make screenshot display/window/area all use the same post-capture pipeline. - -Why it matters: - -This removes the biggest daily friction compared with CleanShot: one shortcut should do exactly what the user expects after capture. - -### P0: Floating pinned screenshots - -CleanShot's pinned screenshots are a habit-forming reference feature. Cap has transparent always-on-top window primitives, but not a dedicated pin workflow. - -Work items: - -- Add a `PinnedScreenshot` window type that displays an image above all windows. -- Add pin action from screenshot editor, screenshot history, quick overlay, and deeplink. -- Support resize, opacity, close, duplicate, and reveal/open editor. -- Add click-through or lock-through mode so apps underneath remain interactive. -- Persist pinned screenshot window position during the session. - -Why it matters: - -This is likely the single most missed CleanShot feature for day-to-day support, design review, and implementation work. - -### P1: Standalone capture-text command - -Cap has OCR in the screenshot editor, but CleanShot has a fast OCR mode that copies selected text directly to clipboard. - -Work items: - -- Add OCR target picker mode for selecting an area without opening the full editor. -- Reuse the existing native OCR engines from the screenshot editor. -- Copy recognized text to clipboard with optional line-break preservation. -- Add Raycast and deeplink command support. -- Add a fallback route to open the captured area in the editor if OCR fails. - -Why it matters: - -OCR is one of the small utilities that makes a screenshot tool replace system features, not just recording software. - -### P1: Better Quick Access Overlay for screenshots - -Cap has a recent media overlay, but screenshot handling is not yet CleanShot-like. - -Work items: - -- Change screenshot overlay primary action from "View" to "Edit" or make it configurable. -- Add pin, annotate/edit, copy, save, upload, delete, reveal, and drag-out affordances. -- Add restore recently closed overlay. -- Add configurable auto-close timing. -- Improve multi-display placement and remember overlay position. - -Why it matters: - -The overlay is where speed is won or lost. It should be a tiny command station, not just a preview. - -### P1: Screenshot history parity - -Cap has screenshot history in settings. CleanShot's history is closer to a capture inbox. - -Work items: - -- Add filters for screenshot/video/GIF/imported media. -- Add actions: copy, save, upload, pin, edit, delete, reveal, restore overlay. -- Add richer metadata: capture type, dimensions, created date, upload state. -- Add a command/deeplink to open history directly. -- Consider retention preferences. - -Why it matters: - -Ryan needs confidence that a capture is recoverable even after closing the overlay. - -### P1: Annotation tool parity - -Cap already covers the essential annotation base, but CleanShot still has more daily markup tools. - -Work items: - -- Add line tool. -- Add filled rectangle preset/tool. -- Add highlighter. -- Add pencil/freehand with smoothing. -- Add spotlight/emphasis tool. -- Add counter/step marker tool. -- Add curved arrow or arrow style variants. -- Add text style presets. -- Add combine-images support by importing another image as a movable layer. - -Why it matters: - -This is the visible "does it feel like CleanShot" layer once capture and post-capture actions are solved. - -### P2: Scrolling capture - -CleanShot supports scrolling capture across many apps. Cap does not appear to have this mode. - -Work items: - -- Start with browser/webview scrolling capture, where the capture target can be controlled more predictably. -- Add manual scrolling capture with guided stitching. -- Investigate macOS Accessibility-driven autoscroll for native apps. -- Add stitch preview and crop correction. -- Add Raycast/deeplink command once the mode is stable. - -Why it matters: - -High value, but harder and riskier than post-capture flow, pinning, OCR, and annotations. - -### P2: Capture precision tools - -CleanShot has crosshair, magnifier, freeze screen, exact dimensions, and previous area. - -Work items: - -- Add crosshair and magnifier to area selection. -- Add frozen-screen area selection option. -- Add exact size controls and aspect lock in area picker. -- Store and retake previous screenshot area. -- Add self-timer for screenshots. - -Why it matters: - -These are power-user features that make screenshots feel precise, especially for UI work. - -### P2: Desktop cleanup and recording polish - -CleanShot can hide desktop clutter and enable Do Not Disturb while recording. - -Work items: - -- Add macOS desktop icon hide/show commands or integration. -- Add optional focus/DND behavior while recording, if macOS APIs permit it cleanly. -- Add settings for whether these behaviors apply to screenshot, recording, or both. -- Add automation commands for hide/show/toggle desktop icons. - -Why it matters: - -Not core to screenshot replacement, but useful for demos and polished captures. - -## Suggested Build Order - -1. Raycast command pack over current Cap deeplinks. -2. Shared screenshot post-capture action pipeline. -3. Floating pinned screenshots. -4. Standalone capture-text/OCR-to-clipboard. -5. Screenshot overlay and history upgrades. -6. Annotation parity tools. -7. Capture precision tools. -8. Scrolling capture. -9. Desktop cleanup and recording polish. - -## First Issues To Create - -- Build Raycast commands for Cap action deeplinks. -- Add screenshot post-capture actions to Cap hotkeys and deeplinks. -- Add pinned screenshot windows. -- Add standalone OCR capture-to-clipboard. -- Upgrade screenshot Quick Access overlay actions. -- Add screenshot history filters and restore-overlay action. -- Add highlighter, line, pencil, counter, and spotlight annotation tools. -- Add previous-area and self-timer screenshot capture. -- Investigate scrolling screenshot capture architecture. - -## Source Notes - -- CleanShot X official feature list: https://cleanshot.com/features -- CleanShot X URL scheme API: https://cleanshot.com/docs-api -- Cap official product page: https://cap.so -- Cap local evidence: - - `README.md` - - `apps/desktop/src-tauri/src/deeplink_actions.rs` - - `apps/desktop/src-tauri/src/hotkeys.rs` - - `apps/desktop/src-tauri/src/recording.rs` - - `apps/desktop/src-tauri/src/screenshot_editor.rs` - - `apps/desktop/src/routes/screenshot-editor` - - `apps/desktop/src/routes/recordings-overlay.tsx` - - `apps/desktop/src/routes/(window-chrome)/settings/screenshots.tsx` From 8f90fa24df234f015970ca7f63c4fc2e12982c26 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sun, 7 Jun 2026 10:06:12 -0400 Subject: [PATCH 10/13] fix: avoid duplicate screenshot input suspension --- apps/desktop/src/routes/(window-chrome)/new-main/index.tsx | 2 +- apps/desktop/src/routes/target-select-overlay.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index aca94c85bb0..a5f4adda145 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -2083,7 +2083,7 @@ function Page() { } return isScreenshotMode; - }, false); + }, rawOptions.mode === "screenshot"); onMount(async () => { if (document.activeElement instanceof HTMLElement) { diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 5612b9c400e..3e8bf421a57 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -1665,7 +1665,7 @@ function RecordingControls(props: { } return isScreenshotMode; - }, false); + }, rawOptions.mode === "screenshot"); onMount(async () => { const storedSettings = await recordingSettingsStore.get().catch((error) => { From 0ed1cd436fc403668578314883c73c2132c90c4f Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sun, 7 Jun 2026 11:30:33 -0400 Subject: [PATCH 11/13] Address screenshot deeplink review feedback --- .../desktop/src-tauri/src/deeplink_actions.rs | 106 +++++++++++++++--- apps/desktop/src-tauri/src/windows.rs | 9 +- .../routes/(window-chrome)/new-main/index.tsx | 38 ++++++- .../src/routes/target-select-overlay.tsx | 38 ++++++- .../src/utils/screenshot-recording-inputs.ts | 37 +++--- 5 files changed, 175 insertions(+), 53 deletions(-) diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index eddf006c5d9..bb30633b418 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -18,7 +18,47 @@ use crate::{ windows::ShowCapWindow, }; -static TEMPORARY_SCREENSHOT_MODE: Mutex>> = Mutex::new(None); +#[derive(Debug, Default)] +struct TemporaryScreenshotModeState { + previous_mode: Option, + active_count: usize, +} + +impl TemporaryScreenshotModeState { + fn begin(&mut self, previous_mode: Option) -> bool { + if self.active_count > 0 { + self.active_count += 1; + return false; + } + + if matches!(previous_mode, Some(RecordingMode::Screenshot)) { + return false; + } + + self.previous_mode = previous_mode; + self.active_count = 1; + true + } + + fn restore(&mut self) -> Option> { + if self.active_count == 0 { + return None; + } + + self.active_count -= 1; + if self.active_count > 0 { + return None; + } + + Some(self.previous_mode.take()) + } +} + +static TEMPORARY_SCREENSHOT_MODE: Mutex = + Mutex::new(TemporaryScreenshotModeState { + previous_mode: None, + active_count: 0, + }); #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -252,6 +292,39 @@ mod tests { _ => panic!("expected StartRecordingWithCurrentSettings"), } } + + #[test] + fn temporary_screenshot_mode_restores_after_last_nested_flow() { + let mut state = TemporaryScreenshotModeState::default(); + + assert!(state.begin(Some(RecordingMode::Studio))); + assert!(!state.begin(Some(RecordingMode::Screenshot))); + assert_eq!(state.active_count, 2); + + assert_eq!(state.restore(), None); + assert_eq!(state.restore(), Some(Some(RecordingMode::Studio))); + assert_eq!(state.restore(), None); + } + + #[test] + fn temporary_screenshot_mode_preserves_missing_previous_mode() { + let mut state = TemporaryScreenshotModeState::default(); + + assert!(state.begin(None)); + + assert_eq!(state.restore(), Some(None)); + assert_eq!(state.restore(), None); + } + + #[test] + fn temporary_screenshot_mode_noops_when_already_screenshot() { + let mut state = TemporaryScreenshotModeState::default(); + + assert!(!state.begin(Some(RecordingMode::Screenshot))); + + assert_eq!(state.active_count, 0); + assert_eq!(state.restore(), None); + } } impl DeepLinkAction { @@ -460,28 +533,35 @@ fn begin_temporary_screenshot_mode(app: &AppHandle) -> Result<(), String> { let previous_mode = RecordingSettingsStore::get(app) .map(|settings| settings.and_then(|settings| settings.mode))?; - if matches!(previous_mode, Some(RecordingMode::Screenshot)) { + let should_enable_screenshot_mode = { + let mut temporary_mode = TEMPORARY_SCREENSHOT_MODE + .lock() + .unwrap_or_else(PoisonError::into_inner); + temporary_mode.begin(previous_mode) + }; + + if !should_enable_screenshot_mode { return Ok(()); } - { - let mut pending = TEMPORARY_SCREENSHOT_MODE + if let Err(err) = set_recording_mode(app, RecordingMode::Screenshot) { + let mut temporary_mode = TEMPORARY_SCREENSHOT_MODE .lock() .unwrap_or_else(PoisonError::into_inner); - - if pending.is_none() { - *pending = Some(previous_mode); - } + let _ = temporary_mode.restore(); + return Err(err); } - set_recording_mode(app, RecordingMode::Screenshot) + Ok(()) } pub(crate) fn restore_temporary_recording_mode(app: &AppHandle) { - let previous_mode = TEMPORARY_SCREENSHOT_MODE - .lock() - .unwrap_or_else(PoisonError::into_inner) - .take(); + let previous_mode = { + let mut temporary_mode = TEMPORARY_SCREENSHOT_MODE + .lock() + .unwrap_or_else(PoisonError::into_inner); + temporary_mode.restore() + }; let Some(previous_mode) = previous_mode else { return; diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 15c15ccd920..ad732dca7c6 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -306,13 +306,8 @@ pub(crate) async fn restore_main_window_inputs(app: &AppHandle) { warn!("Failed to suspend microphone input for screenshot mode: {err}"); } - let operation_lock = app.try_state::(); - let _operation_guard = if let Some(operation_lock) = operation_lock.as_ref() { - Some(operation_lock.lock().await) - } else { - warn!("CameraWindowOperationLock unavailable while suspending camera input"); - None - }; + let operation_lock = app.state::(); + let _operation_guard = operation_lock.lock().await; let (camera_feed, had_camera_input) = { let app_state = &mut *state.write().await; diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index a5f4adda145..32e8b1077c0 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -2068,17 +2068,40 @@ function Page() { setCameraInput: (args) => setCamera.mutateAsync(args), }); + let recordingInputsToRestore: { + micName: string | null; + cameraID: DeviceOrModelID | null; + } = { + micName: rawOptions.micName ?? null, + cameraID: rawOptions.cameraID ? { ...rawOptions.cameraID } : null, + }; + + const rememberRecordingInputsToRestore = (inputs: { + micName: string | null; + cameraID: DeviceOrModelID | null; + }) => { + recordingInputsToRestore = { + micName: inputs.micName, + cameraID: inputs.cameraID ? { ...inputs.cameraID } : null, + }; + return recordingInputsToRestore; + }; + createUpdateCheck(); createEffect((wasScreenshotMode) => { const isScreenshotMode = rawOptions.mode === "screenshot"; if (isScreenshotMode && !wasScreenshotMode) { + rememberRecordingInputsToRestore({ + micName: rawOptions.micName ?? null, + cameraID: rawOptions.cameraID ?? null, + }); void suspendRecordingInputsForScreenshot(); } else if (!isScreenshotMode && wasScreenshotMode) { void restoreRecordingInputs( - rawOptions.micName ?? null, - rawOptions.cameraID ?? null, + recordingInputsToRestore.micName, + recordingInputsToRestore.cameraID, ); } @@ -2120,11 +2143,14 @@ function Page() { console.error("Failed to read recording settings:", error); return null; }); - await syncRecordingInputsForMode({ - mode: rawOptions.mode, + const recordingInputs = rememberRecordingInputsToRestore({ micName: storedSettings?.micName ?? rawOptions.micName ?? null, cameraID: storedSettings?.cameraId ?? rawOptions.cameraID ?? null, }); + await syncRecordingInputsForMode({ + mode: rawOptions.mode, + ...recordingInputs, + }); const unlistenFocus = currentWindow.onFocusChanged( ({ payload: focused }) => { @@ -2551,7 +2577,7 @@ function Page() { name="Area" class="flex-1" /> - + - + diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 3e8bf421a57..758d9bafbcd 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -1652,15 +1652,38 @@ function RecordingControls(props: { skipCameraWindow: () => props.target.variant === "cameraOnly", }); + let recordingInputsToRestore: { + micName: string | null; + cameraID: DeviceOrModelID | null; + } = { + micName: rawOptions.micName ?? null, + cameraID: rawOptions.cameraID ? { ...rawOptions.cameraID } : null, + }; + + const rememberRecordingInputsToRestore = (inputs: { + micName: string | null; + cameraID: DeviceOrModelID | null; + }) => { + recordingInputsToRestore = { + micName: inputs.micName, + cameraID: inputs.cameraID ? { ...inputs.cameraID } : null, + }; + return recordingInputsToRestore; + }; + createEffect((wasScreenshotMode) => { const isScreenshotMode = rawOptions.mode === "screenshot"; if (isScreenshotMode && !wasScreenshotMode) { + rememberRecordingInputsToRestore({ + micName: rawOptions.micName ?? null, + cameraID: rawOptions.cameraID ?? null, + }); void suspendRecordingInputsForScreenshot(); } else if (!isScreenshotMode && wasScreenshotMode) { void restoreRecordingInputs( - rawOptions.micName ?? null, - rawOptions.cameraID ?? null, + recordingInputsToRestore.micName, + recordingInputsToRestore.cameraID, ); } @@ -1672,11 +1695,14 @@ function RecordingControls(props: { console.error("Failed to read recording settings:", error); return null; }); - await syncRecordingInputsForMode({ - mode: rawOptions.mode, + const recordingInputs = rememberRecordingInputsToRestore({ micName: storedSettings?.micName ?? rawOptions.micName ?? null, cameraID: storedSettings?.cameraId ?? rawOptions.cameraID ?? null, }); + await syncRecordingInputsForMode({ + mode: rawOptions.mode, + ...recordingInputs, + }); const isCameraOnly = props.target.variant === "cameraOnly"; if (isCameraOnly) { @@ -1865,7 +1891,7 @@ function RecordingControls(props: { - + @@ -1901,7 +1927,7 @@ function RecordingControls(props: { - +
Promise; @@ -29,7 +25,7 @@ export function createRecordingInputHandlers({ setCameraInput, skipCameraWindow, }: { - setMicInput: (name: string) => Promise; + setMicInput: (name: string | null) => Promise; setCameraInput: SetCameraInput; skipCameraWindow?: SkipCameraWindow; }): RecordingInputHandlers { @@ -40,22 +36,21 @@ export function createRecordingInputHandlers({ const suspendRecordingInputsForScreenshot = async () => { await Promise.all([ - commands - .setMicInput(null) - .catch((error) => - console.error( - "Failed to suspend mic input for screenshot mode:", - error, - ), + setMicInput(null).catch((error) => + console.error( + "Failed to suspend mic input for screenshot mode:", + error, ), - commands - .setCameraInput(null, null) - .catch((error) => - console.error( - "Failed to suspend camera input for screenshot mode:", - error, - ), + ), + setCameraInput({ + model: null, + skipCameraWindow: getSkipCameraWindow(), + }).catch((error) => + console.error( + "Failed to suspend camera input for screenshot mode:", + error, ), + ), ]); }; From 4bedebe95543baf7083d6c0f511410c66d60c643 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sun, 7 Jun 2026 11:39:39 -0400 Subject: [PATCH 12/13] Ensure screenshot overlay cleanup on failure --- apps/desktop/src/routes/target-select-overlay.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 758d9bafbcd..48e65443868 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -1861,11 +1861,19 @@ function RecordingControls(props: { target: props.target, }); await commands.showWindow({ ScreenshotEditor: { path } }); - await commands.closeTargetSelectOverlays(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(`Failed to take screenshot: ${message}`); console.error("Failed to take screenshot", e); + } finally { + await commands + .closeTargetSelectOverlays() + .catch((error) => + console.error( + "Failed to close target select overlays after screenshot:", + error, + ), + ); } return; } From a29c6181c4246159bf70e4d3ef3c68b5dc8dbba4 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sun, 7 Jun 2026 12:48:10 -0400 Subject: [PATCH 13/13] Address screenshot deeplink review follow-ups --- .../desktop/src-tauri/src/deeplink_actions.rs | 26 +++++++------------ apps/desktop/src-tauri/src/lib.rs | 2 -- .../src-tauri/src/target_select_overlay.rs | 2 +- .../utils/screenshot-recording-inputs.test.ts | 24 +++++++++++++++++ .../src/utils/screenshot-recording-inputs.ts | 4 +-- 5 files changed, 36 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/src/utils/screenshot-recording-inputs.test.ts diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index bb30633b418..470921b6ee4 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -2,11 +2,9 @@ use cap_recording::{ RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget, }; use serde::{Deserialize, Serialize}; -use std::{ - path::{Path, PathBuf}, - sync::{Mutex, PoisonError}, -}; +use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Url}; +use tokio::sync::Mutex; use tracing::{trace, warn}; use crate::{ @@ -55,7 +53,7 @@ impl TemporaryScreenshotModeState { } static TEMPORARY_SCREENSHOT_MODE: Mutex = - Mutex::new(TemporaryScreenshotModeState { + Mutex::const_new(TemporaryScreenshotModeState { previous_mode: None, active_count: 0, }); @@ -529,14 +527,12 @@ fn set_recording_mode(app: &AppHandle, mode: RecordingMode) -> Result<(), String Ok(()) } -fn begin_temporary_screenshot_mode(app: &AppHandle) -> Result<(), String> { +async fn begin_temporary_screenshot_mode(app: &AppHandle) -> Result<(), String> { let previous_mode = RecordingSettingsStore::get(app) .map(|settings| settings.and_then(|settings| settings.mode))?; let should_enable_screenshot_mode = { - let mut temporary_mode = TEMPORARY_SCREENSHOT_MODE - .lock() - .unwrap_or_else(PoisonError::into_inner); + let mut temporary_mode = TEMPORARY_SCREENSHOT_MODE.lock().await; temporary_mode.begin(previous_mode) }; @@ -545,9 +541,7 @@ fn begin_temporary_screenshot_mode(app: &AppHandle) -> Result<(), String> { } if let Err(err) = set_recording_mode(app, RecordingMode::Screenshot) { - let mut temporary_mode = TEMPORARY_SCREENSHOT_MODE - .lock() - .unwrap_or_else(PoisonError::into_inner); + let mut temporary_mode = TEMPORARY_SCREENSHOT_MODE.lock().await; let _ = temporary_mode.restore(); return Err(err); } @@ -555,11 +549,9 @@ fn begin_temporary_screenshot_mode(app: &AppHandle) -> Result<(), String> { Ok(()) } -pub(crate) fn restore_temporary_recording_mode(app: &AppHandle) { +pub(crate) async fn restore_temporary_recording_mode(app: &AppHandle) { let previous_mode = { - let mut temporary_mode = TEMPORARY_SCREENSHOT_MODE - .lock() - .unwrap_or_else(PoisonError::into_inner); + let mut temporary_mode = TEMPORARY_SCREENSHOT_MODE.lock().await; temporary_mode.restore() }; @@ -591,7 +583,7 @@ async fn take_screenshot(app: &AppHandle, target: ScreenshotTarget) -> Result<() ScreenCaptureTarget::Window { id: window.id() } } ScreenshotTarget::Area => { - begin_temporary_screenshot_mode(app)?; + begin_temporary_screenshot_mode(app).await?; crate::open_target_picker(app, RecordingTargetMode::Area).await; return Ok(()); } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c400e57d8bc..1d7f868f42b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -5174,8 +5174,6 @@ fn close_target_select_overlays(app: &AppHandle) { if !saw_overlay && let Some(focus_manager) = focus_manager { focus_manager.shutdown(app); } - - deeplink_actions::restore_temporary_recording_mode(app); } #[cfg(target_os = "windows")] diff --git a/apps/desktop/src-tauri/src/target_select_overlay.rs b/apps/desktop/src-tauri/src/target_select_overlay.rs index 6cea4ac4637..a31ca6fcec2 100644 --- a/apps/desktop/src-tauri/src/target_select_overlay.rs +++ b/apps/desktop/src-tauri/src/target_select_overlay.rs @@ -326,7 +326,7 @@ pub async fn close_target_select_overlays( state.destroy(&display_id, app.global_shortcut()); } - crate::deeplink_actions::restore_temporary_recording_mode(&app); + crate::deeplink_actions::restore_temporary_recording_mode(&app).await; Ok(()) } diff --git a/apps/desktop/src/utils/screenshot-recording-inputs.test.ts b/apps/desktop/src/utils/screenshot-recording-inputs.test.ts new file mode 100644 index 00000000000..ae1b0516fc7 --- /dev/null +++ b/apps/desktop/src/utils/screenshot-recording-inputs.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { DeviceOrModelID } from "~/utils/tauri"; +import { createRecordingInputHandlers } from "./screenshot-recording-inputs"; + +describe("screenshot-recording-inputs", () => { + it("restores an empty-string mic label", async () => { + const setMicInput = vi.fn().mockResolvedValue(undefined); + const setCameraInput = vi.fn().mockResolvedValue(undefined); + const cameraID: DeviceOrModelID = { DeviceID: "camera" }; + const { restoreRecordingInputs } = createRecordingInputHandlers({ + setMicInput, + setCameraInput, + }); + + await restoreRecordingInputs("", cameraID); + + expect(setMicInput).toHaveBeenCalledWith(""); + expect(setCameraInput).toHaveBeenCalledWith({ + model: cameraID, + skipCameraWindow: undefined, + }); + }); +}); diff --git a/apps/desktop/src/utils/screenshot-recording-inputs.ts b/apps/desktop/src/utils/screenshot-recording-inputs.ts index b3b99f77c57..83c552dd4f7 100644 --- a/apps/desktop/src/utils/screenshot-recording-inputs.ts +++ b/apps/desktop/src/utils/screenshot-recording-inputs.ts @@ -58,13 +58,13 @@ export function createRecordingInputHandlers({ micName: string | null, cameraID: DeviceOrModelID | null, ) => { - if (micName) { + if (micName != null) { await setMicInput(micName).catch((error) => console.error("Failed to set mic input:", error), ); } - if (cameraID) { + if (cameraID != null) { await setCameraInput({ model: cameraID, skipCameraWindow: getSkipCameraWindow(),