From 99d1101de754f585823dc811f27140b2b1b9f77a Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Thu, 21 May 2026 14:38:42 -0400 Subject: [PATCH 01/10] feat: add screenshot post-capture actions --- .../desktop/src-tauri/src/general_settings.rs | 12 +++ apps/desktop/src-tauri/src/hotkeys.rs | 26 +++-- apps/desktop/src-tauri/src/lib.rs | 1 + .../src-tauri/src/screenshot_post_capture.rs | 96 +++++++++++++++++++ apps/desktop/src-tauri/src/tray.rs | 8 +- .../(window-chrome)/settings/general.tsx | 15 +++ .../src/utils/general-settings.test.ts | 1 + apps/desktop/src/utils/general-settings.ts | 7 ++ 8 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src-tauri/src/screenshot_post_capture.rs diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index d96b1cb32e6..3b7ac0e930e 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -19,6 +19,15 @@ pub enum PostStudioRecordingBehaviour { ShowOverlay, } +#[derive(Default, Serialize, Deserialize, Type, Debug, Clone, Copy)] +#[serde(rename_all = "camelCase")] +pub enum PostScreenshotCaptureBehaviour { + #[default] + OpenEditor, + ShowOverlay, + CopyToClipboard, +} + #[derive(Default, Serialize, Deserialize, Type, Debug, Clone, Copy)] #[serde(rename_all = "camelCase")] pub enum MainWindowRecordingStartBehaviour { @@ -149,6 +158,8 @@ pub struct GeneralSettingsStore { #[serde(default)] pub post_studio_recording_behaviour: PostStudioRecordingBehaviour, #[serde(default)] + pub post_screenshot_capture_behaviour: PostScreenshotCaptureBehaviour, + #[serde(default)] pub main_window_recording_start_behaviour: MainWindowRecordingStartBehaviour, #[serde(default = "default_true", rename = "custom_cursor_capture2")] pub custom_cursor_capture: bool, @@ -258,6 +269,7 @@ impl Default for GeneralSettingsStore { last_version: None, window_transparency: false, post_studio_recording_behaviour: PostStudioRecordingBehaviour::OpenEditor, + post_screenshot_capture_behaviour: PostScreenshotCaptureBehaviour::OpenEditor, main_window_recording_start_behaviour: MainWindowRecordingStartBehaviour::Close, custom_cursor_capture: true, server_url: default_server_url(), diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs index eccd9e700bf..d35865ba954 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -1,8 +1,8 @@ use crate::{ RequestOpenRecordingPicker, RequestStartRecording, recording, recording_settings::{RecordingSettingsStore, RecordingTargetMode}, + screenshot_post_capture::{self, ScreenshotPostCaptureAction}, tray, - windows::ShowCapWindow, }; use cap_recording::screen_capture::ScreenCaptureTarget; use global_hotkey::HotKeyState; @@ -209,31 +209,27 @@ async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), Strin let display = Display::get_containing_cursor().unwrap_or_else(Display::primary); let target = ScreenCaptureTarget::Display { id: display.id() }; + let action = ScreenshotPostCaptureAction::from_settings(&app); - match recording::take_screenshot(app.clone(), target).await { - Ok(path) => { - let _ = ShowCapWindow::ScreenshotEditor { path }.show(&app).await; - Ok(()) - } - Err(e) => Err(format!("Failed to take screenshot: {e}")), - } + let path = recording::take_screenshot(app.clone(), target) + .await + .map_err(|e| format!("Failed to take screenshot: {e}"))?; + screenshot_post_capture::handle(&app, path, action).await } HotkeyAction::ScreenshotWindow => { use scap_targets::Window; + let action = ScreenshotPostCaptureAction::from_settings(&app); let target = { let window = Window::get_topmost_at_cursor() .ok_or_else(|| "No window found under cursor".to_string())?; ScreenCaptureTarget::Window { id: window.id() } }; - match recording::take_screenshot(app.clone(), target).await { - Ok(path) => { - let _ = ShowCapWindow::ScreenshotEditor { path }.show(&app).await; - Ok(()) - } - Err(e) => Err(format!("Failed to take screenshot: {e}")), - } + let path = recording::take_screenshot(app.clone(), target) + .await + .map_err(|e| format!("Failed to take screenshot: {e}"))?; + screenshot_post_capture::handle(&app, path, action).await } HotkeyAction::ScreenshotArea => { RecordingSettingsStore::set_mode(&app, cap_recording::RecordingMode::Screenshot) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1d7f868f42b..d6654e4d2ba 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ mod recording_settings; mod recording_telemetry; mod recovery; mod screenshot_editor; +mod screenshot_post_capture; mod target_select_overlay; mod thumbnails; mod tray; diff --git a/apps/desktop/src-tauri/src/screenshot_post_capture.rs b/apps/desktop/src-tauri/src/screenshot_post_capture.rs new file mode 100644 index 00000000000..a497bbba881 --- /dev/null +++ b/apps/desktop/src-tauri/src/screenshot_post_capture.rs @@ -0,0 +1,96 @@ +use std::{ + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +use clipboard_rs::{Clipboard, ClipboardContext, RustImageData, common::RustImage}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::{AppHandle, Manager}; +use tokio::time::sleep; + +use crate::{ + ArcLock, + general_settings::{GeneralSettingsStore, PostScreenshotCaptureBehaviour}, + notifications::{self, NotificationType}, + windows::ShowCapWindow, +}; + +#[derive(Default, Serialize, Deserialize, Type, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ScreenshotPostCaptureAction { + #[default] + OpenEditor, + ShowOverlay, + CopyToClipboard, +} + +impl From for ScreenshotPostCaptureAction { + fn from(value: PostScreenshotCaptureBehaviour) -> Self { + match value { + PostScreenshotCaptureBehaviour::OpenEditor => Self::OpenEditor, + PostScreenshotCaptureBehaviour::ShowOverlay => Self::ShowOverlay, + PostScreenshotCaptureBehaviour::CopyToClipboard => Self::CopyToClipboard, + } + } +} + +impl ScreenshotPostCaptureAction { + pub fn from_settings(app: &AppHandle) -> Self { + GeneralSettingsStore::get(app) + .ok() + .flatten() + .map(|settings| settings.post_screenshot_capture_behaviour.into()) + .unwrap_or_default() + } +} + +pub async fn handle( + app: &AppHandle, + path: PathBuf, + action: ScreenshotPostCaptureAction, +) -> Result<(), String> { + match action { + ScreenshotPostCaptureAction::OpenEditor => { + let _ = ShowCapWindow::ScreenshotEditor { path }.show(app).await; + Ok(()) + } + ScreenshotPostCaptureAction::ShowOverlay => { + let _ = ShowCapWindow::RecordingsOverlay.show(app).await; + Ok(()) + } + ScreenshotPostCaptureAction::CopyToClipboard => { + copy_screenshot_to_clipboard(app, &path).await?; + notifications::send_notification(app, NotificationType::ScreenshotCopiedToClipboard); + Ok(()) + } + } +} + +async fn read_screenshot_image(path: &Path) -> Result { + let started_at = Instant::now(); + let path = path + .to_str() + .ok_or_else(|| format!("Invalid screenshot path: {}", path.display()))?; + + loop { + match RustImageData::from_path(path) { + Ok(img_data) => return Ok(img_data), + Err(e) => { + if started_at.elapsed() >= Duration::from_secs(2) { + return Err(format!("Failed to copy screenshot to clipboard: {e}")); + } + sleep(Duration::from_millis(50)).await; + } + } + } +} + +async fn copy_screenshot_to_clipboard(app: &AppHandle, path: &Path) -> Result<(), String> { + let img_data = read_screenshot_image(path).await?; + app.state::>() + .write() + .await + .set_image(img_data) + .map_err(|err| format!("Failed to copy screenshot to clipboard: {err}")) +} diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index 348145a2519..20f2bf8760a 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -2,6 +2,7 @@ use crate::{ NewScreenshotAdded, NewStudioRecordingAdded, RecordingStarted, RecordingStopped, RequestOpenSettings, recording, recording_settings::{RecordingSettingsStore, RecordingTargetMode}, + screenshot_post_capture::{self, ScreenshotPostCaptureAction}, windows::ShowCapWindow, }; use cap_recording::RecordingMode; @@ -723,10 +724,15 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { let display = Display::get_containing_cursor().unwrap_or_else(Display::primary); let target = ScreenCaptureTarget::Display { id: display.id() }; + let action = ScreenshotPostCaptureAction::from_settings(&app); match recording::take_screenshot(app.clone(), target).await { Ok(path) => { - let _ = ShowCapWindow::ScreenshotEditor { path }.show(&app).await; + if let Err(e) = + screenshot_post_capture::handle(&app, path, action).await + { + tracing::error!("Failed to handle screenshot: {e}"); + } } Err(e) => { tracing::error!("Failed to take screenshot: {e}"); diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index e4e52d5e60b..92c71a44990 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -29,6 +29,7 @@ import { authStore, generalSettingsStore } from "~/store"; import { deriveGeneralSettings, type GeneralSettingsStore, + type PostScreenshotCaptureBehaviour, } from "~/utils/general-settings"; import { type AppTheme, @@ -362,6 +363,7 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { T extends | MainWindowRecordingStartBehaviour | PostStudioRecordingBehaviour + | PostScreenshotCaptureBehaviour | PostDeletionBehaviour | StudioRecordingQuality | number, @@ -503,6 +505,19 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { { text: "Show in overlay", value: "showOverlay" }, ]} /> + + handleChange("postScreenshotCaptureBehaviour", value) + } + options={[ + { text: "Open editor", value: "openEditor" }, + { text: "Show in overlay", value: "showOverlay" }, + { text: "Copy to clipboard", value: "copyToClipboard" }, + ]} + /> { autoZoomOnClicks: false, captureKeyboardEvents: true, custom_cursor_capture2: true, + postScreenshotCaptureBehaviour: "openEditor", }); }); diff --git a/apps/desktop/src/utils/general-settings.ts b/apps/desktop/src/utils/general-settings.ts index 4479462cc3d..878acfc8811 100644 --- a/apps/desktop/src/utils/general-settings.ts +++ b/apps/desktop/src/utils/general-settings.ts @@ -1,7 +1,13 @@ import type { GeneralSettingsStore as TauriGeneralSettingsStore } from "~/utils/tauri"; +export type PostScreenshotCaptureBehaviour = + | "openEditor" + | "showOverlay" + | "copyToClipboard"; + export type GeneralSettingsStore = TauriGeneralSettingsStore & { captureKeyboardEvents?: boolean; + postScreenshotCaptureBehaviour?: PostScreenshotCaptureBehaviour; transcriptionHints?: string[]; enableTelemetry?: boolean; outOfProcessMuxer?: boolean; @@ -23,6 +29,7 @@ export function createDefaultGeneralSettings(): GeneralSettingsStore { enableNativeCameraPreview: false, autoZoomOnClicks: false, captureKeyboardEvents: true, + postScreenshotCaptureBehaviour: "openEditor", custom_cursor_capture2: true, excludedWindows: [], instantModeMaxResolution: 1920, From af8095bd24a8fde4bb3b27225b27850d62c1ed0e Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sat, 6 Jun 2026 17:45:01 -0400 Subject: [PATCH 02/10] Honor screenshot post-capture actions --- .../desktop/src-tauri/src/deeplink_actions.rs | 119 ++++++++++++++++- apps/desktop/src-tauri/src/hotkeys.rs | 1 + apps/desktop/src-tauri/src/lib.rs | 2 + .../src-tauri/src/screenshot_post_capture.rs | 120 +++++++++++++++++- .../src-tauri/src/target_select_overlay.rs | 3 + .../src/routes/target-select-overlay.tsx | 17 +-- 6 files changed, 244 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a1170284877..bea3355b6b5 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, + recording::StartRecordingInputs, + recording_settings::{RecordingSettingsStore, RecordingTargetMode}, + screenshot_post_capture::{self, ScreenshotPostCaptureAction}, + 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, PartialEq, Eq, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub enum ScreenshotTarget { + CurrentDisplay, + CurrentWindow, + Area, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DeepLinkAction { @@ -32,6 +47,11 @@ pub enum DeepLinkAction { OpenSettings { page: Option, }, + TakeScreenshot { + target: ScreenshotTarget, + #[serde(default)] + post_capture_action: Option, + }, } pub fn handle(app_handle: &AppHandle, urls: Vec) { @@ -70,6 +90,7 @@ pub fn handle(app_handle: &AppHandle, urls: Vec) { }); } +#[derive(Debug)] pub enum ActionParseFromUrlError { ParseFailed(String), Invalid, @@ -89,9 +110,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() @@ -153,6 +175,95 @@ impl DeepLinkAction { DeepLinkAction::OpenSettings { page } => { crate::show_window(app.clone(), ShowCapWindow::Settings { page }).await } + DeepLinkAction::TakeScreenshot { + target, + post_capture_action, + } => take_screenshot(app, target, post_capture_action).await, + } + } +} + +async fn take_screenshot( + app: &AppHandle, + target: ScreenshotTarget, + post_capture_action: Option, +) -> 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 => { + if let Some(action) = post_capture_action { + screenshot_post_capture::set_pending_action(app, action)?; + } else { + screenshot_post_capture::clear_pending_action(app); + } + + RecordingSettingsStore::set_mode(app, RecordingMode::Screenshot)?; + tray::update_tray_icon_for_mode(app, RecordingMode::Screenshot); + crate::open_target_picker(app, RecordingTargetMode::Area).await; + return Ok(()); + } + }; + + let action = + post_capture_action.unwrap_or_else(|| ScreenshotPostCaptureAction::from_settings(app)); + let path = crate::recording::take_screenshot(app.clone(), capture_target) + .await + .map_err(|e| format!("Failed to take screenshot: {e}"))?; + screenshot_post_capture::handle(app, path, action).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_take_screenshot_deeplink_with_post_capture_action() { + let mut url = Url::parse("cap-desktop://action").unwrap(); + url.query_pairs_mut().append_pair( + "value", + &serde_json::to_string(&DeepLinkAction::TakeScreenshot { + target: ScreenshotTarget::CurrentDisplay, + post_capture_action: Some(ScreenshotPostCaptureAction::CopyToClipboard), + }) + .unwrap(), + ); + + let action = DeepLinkAction::try_from(&url).unwrap(); + + match action { + DeepLinkAction::TakeScreenshot { + target, + post_capture_action, + } => { + assert_eq!(target, ScreenshotTarget::CurrentDisplay); + assert_eq!( + post_capture_action, + Some(ScreenshotPostCaptureAction::CopyToClipboard) + ); + } + _ => panic!("expected TakeScreenshot"), + } + } + + #[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) + )); } } diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs index d35865ba954..177949695d2 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -3,6 +3,7 @@ use crate::{ recording_settings::{RecordingSettingsStore, RecordingTargetMode}, screenshot_post_capture::{self, ScreenshotPostCaptureAction}, tray, + windows::ShowCapWindow, }; use cap_recording::screen_capture::ScreenCaptureTarget; use global_hotkey::HotKeyState; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d6654e4d2ba..d97b37cd3c2 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3986,6 +3986,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { recording::restart_recording, recording::delete_recording, recording::take_screenshot, + screenshot_post_capture::take_screenshot_with_post_capture, recording::list_cameras, recording::get_camera_formats, recording::get_microphone_info, @@ -4274,6 +4275,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { app.manage(http_client::HttpClient::default()); app.manage(http_client::RetryableHttpClient::default()); app.manage(PendingScreenshots::default()); + app.manage(screenshot_post_capture::PendingScreenshotPostCaptureAction::default()); app.manage(FinalizingRecordings::default()); #[cfg(unix)] diff --git a/apps/desktop/src-tauri/src/screenshot_post_capture.rs b/apps/desktop/src-tauri/src/screenshot_post_capture.rs index a497bbba881..c1e5ccdc79d 100644 --- a/apps/desktop/src-tauri/src/screenshot_post_capture.rs +++ b/apps/desktop/src-tauri/src/screenshot_post_capture.rs @@ -1,8 +1,10 @@ use std::{ path::{Path, PathBuf}, + sync::{Arc, Mutex}, time::{Duration, Instant}, }; +use cap_recording::sources::screen_capture::ScreenCaptureTarget; use clipboard_rs::{Clipboard, ClipboardContext, RustImageData, common::RustImage}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -10,12 +12,23 @@ use tauri::{AppHandle, Manager}; use tokio::time::sleep; use crate::{ - ArcLock, + ArcLock, PendingScreenshot, PendingScreenshots, general_settings::{GeneralSettingsStore, PostScreenshotCaptureBehaviour}, notifications::{self, NotificationType}, windows::ShowCapWindow, }; +const PENDING_ACTION_TTL: Duration = Duration::from_secs(120); + +#[derive(Clone, Default)] +pub struct PendingScreenshotPostCaptureAction(Arc>>); + +#[derive(Clone)] +struct PendingAction { + action: ScreenshotPostCaptureAction, + created_at: Instant, +} + #[derive(Default, Serialize, Deserialize, Type, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum ScreenshotPostCaptureAction { @@ -43,6 +56,55 @@ impl ScreenshotPostCaptureAction { .map(|settings| settings.post_screenshot_capture_behaviour.into()) .unwrap_or_default() } + + pub fn from_pending_or_settings(app: &AppHandle) -> Self { + app.try_state::() + .and_then(|pending| pending.take()) + .unwrap_or_else(|| Self::from_settings(app)) + } +} + +impl PendingScreenshotPostCaptureAction { + pub fn set(&self, action: ScreenshotPostCaptureAction) { + let mut pending = self.0.lock().unwrap(); + *pending = Some(PendingAction { + action, + created_at: Instant::now(), + }); + } + + pub fn take(&self) -> Option { + let mut pending = self.0.lock().unwrap(); + let action = pending.take()?; + + if action.created_at.elapsed() <= PENDING_ACTION_TTL { + Some(action.action) + } else { + None + } + } + + pub fn clear(&self) { + let mut pending = self.0.lock().unwrap(); + *pending = None; + } +} + +pub fn set_pending_action( + app: &AppHandle, + action: ScreenshotPostCaptureAction, +) -> Result<(), String> { + let pending = app + .try_state::() + .ok_or_else(|| "Screenshot post-capture state unavailable".to_string())?; + pending.set(action); + Ok(()) +} + +pub fn clear_pending_action(app: &AppHandle) { + if let Some(pending) = app.try_state::() { + pending.clear(); + } } pub async fn handle( @@ -52,11 +114,17 @@ pub async fn handle( ) -> Result<(), String> { match action { ScreenshotPostCaptureAction::OpenEditor => { - let _ = ShowCapWindow::ScreenshotEditor { path }.show(app).await; + ShowCapWindow::ScreenshotEditor { path } + .show(app) + .await + .map_err(|e| e.to_string())?; Ok(()) } ScreenshotPostCaptureAction::ShowOverlay => { - let _ = ShowCapWindow::RecordingsOverlay.show(app).await; + ShowCapWindow::RecordingsOverlay + .show(app) + .await + .map_err(|e| e.to_string())?; Ok(()) } ScreenshotPostCaptureAction::CopyToClipboard => { @@ -67,6 +135,45 @@ pub async fn handle( } } +#[tauri::command(async)] +#[specta::specta] +#[tracing::instrument(skip(app))] +pub async fn take_screenshot_with_post_capture( + app: AppHandle, + target: ScreenCaptureTarget, +) -> Result { + let action = ScreenshotPostCaptureAction::from_pending_or_settings(&app); + let path = crate::recording::take_screenshot(app.clone(), target).await?; + handle(&app, path.clone(), action).await?; + Ok(path) +} + +fn pending_screenshot_image(app: &AppHandle, path: &Path) -> Option> { + let key = path.parent()?.to_string_lossy().to_string(); + let pending = app.try_state::()?; + pending.get(&key).map(image_from_pending_screenshot) +} + +fn image_from_pending_screenshot(frame: PendingScreenshot) -> Result { + let image = match frame.channels { + 4 => image::RgbaImage::from_raw(frame.width, frame.height, frame.data) + .map(image::DynamicImage::ImageRgba8), + 3 => image::RgbImage::from_raw(frame.width, frame.height, frame.data) + .map(image::DynamicImage::ImageRgb8), + channels => { + return Err(format!("Unsupported screenshot channel count: {channels}")); + } + } + .ok_or_else(|| { + format!( + "Invalid screenshot image data: {}x{}x{}", + frame.width, frame.height, frame.channels + ) + })?; + + Ok(RustImageData::from_dynamic_image(image)) +} + async fn read_screenshot_image(path: &Path) -> Result { let started_at = Instant::now(); let path = path @@ -87,7 +194,12 @@ async fn read_screenshot_image(path: &Path) -> Result { } async fn copy_screenshot_to_clipboard(app: &AppHandle, path: &Path) -> Result<(), String> { - let img_data = read_screenshot_image(path).await?; + let img_data = if let Some(img_data) = pending_screenshot_image(app, path) { + img_data? + } else { + read_screenshot_image(path).await? + }; + app.state::>() .write() .await diff --git a/apps/desktop/src-tauri/src/target_select_overlay.rs b/apps/desktop/src-tauri/src/target_select_overlay.rs index 82eac1701f0..2821dd25da5 100644 --- a/apps/desktop/src-tauri/src/target_select_overlay.rs +++ b/apps/desktop/src-tauri/src/target_select_overlay.rs @@ -12,6 +12,7 @@ use crate::exit_shutdown::{abort_join_handles, read_target_under_cursor}; use crate::{ App, ArcLock, general_settings, recording_settings::RecordingTargetMode, + screenshot_post_capture, window_exclusion::WindowExclusion, windows::{CapWindowId, ShowCapWindow, hide_overlay, show_overlay}, }; @@ -326,6 +327,8 @@ pub async fn close_target_select_overlays( state.destroy(&display_id, app.global_shortcut()); } + screenshot_post_capture::clear_pending_action(&app); + Ok(()) } diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 4064596a572..2fb7d4987af 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -84,6 +84,11 @@ import { const MIN_SIZE = { width: 150, height: 150 }; const MIN_SCREENSHOT_SIZE = { width: 1, height: 1 }; +async function takeScreenshotWithPostCapture(target: ScreenCaptureTarget) { + await invoke("take_screenshot_with_post_capture", { target }); + await commands.closeTargetSelectOverlays(); +} + const capitalize = (str: string) => { return str.charAt(0).toUpperCase() + str.slice(1); }; @@ -1067,11 +1072,7 @@ function Inner() { } await new Promise((resolve) => setTimeout(resolve, 50)); - const path = await invoke("take_screenshot", { - target, - }); - await commands.showWindow({ ScreenshotEditor: { path } }); - await commands.closeTargetSelectOverlays(); + await takeScreenshotWithPostCapture(target); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(`Failed to take screenshot: ${message}`); @@ -1808,11 +1809,7 @@ function RecordingControls(props: { } } - const path = await invoke("take_screenshot", { - target: props.target, - }); - await commands.showWindow({ ScreenshotEditor: { path } }); - await commands.closeTargetSelectOverlays(); + await takeScreenshotWithPostCapture(props.target); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(`Failed to take screenshot: ${message}`); From 9bf8d4d646be593cacf0b65eaab8e9294483b3bc Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sat, 6 Jun 2026 17:50:41 -0400 Subject: [PATCH 03/10] Add save and upload screenshot post-capture actions --- .../desktop/src-tauri/src/general_settings.rs | 2 ++ apps/desktop/src-tauri/src/lib.rs | 25 ++++++++++++++++++- .../src-tauri/src/screenshot_post_capture.rs | 17 +++++++++++++ .../(window-chrome)/settings/general.tsx | 2 ++ apps/desktop/src/utils/general-settings.ts | 4 ++- 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 3b7ac0e930e..940013f2727 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -26,6 +26,8 @@ pub enum PostScreenshotCaptureBehaviour { OpenEditor, ShowOverlay, CopyToClipboard, + Save, + Upload, } #[derive(Default, Serialize, Deserialize, Type, Debug, Clone, Copy)] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d97b37cd3c2..76d5fa01969 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3085,6 +3085,14 @@ async fn upload_screenshot( app: AppHandle, clipboard: MutableState<'_, ClipboardContext>, screenshot_path: PathBuf, +) -> Result { + upload_screenshot_internal_with_clipboard(&app, screenshot_path, Some(clipboard)).await +} + +async fn upload_screenshot_internal_with_clipboard( + app: &AppHandle, + screenshot_path: PathBuf, + clipboard: Option>, ) -> Result { let Ok(Some(auth)) = AuthStore::get(&app) else { AuthStore::set(&app, None).map_err(|e| e.to_string())?; @@ -3121,13 +3129,28 @@ async fn upload_screenshot( println!("Copying to clipboard: {share_link:?}"); - let _ = clipboard.write().await.set_text(share_link.clone()); + if let Some(clipboard) = clipboard { + let _ = clipboard.write().await.set_text(share_link.clone()); + } else { + let _ = app + .state::>() + .write() + .await + .set_text(share_link.clone()); + } notifications::send_notification(&app, notifications::NotificationType::ShareableLinkCopied); Ok(UploadResult::Success(share_link)) } +pub(crate) async fn upload_screenshot_internal( + app: &AppHandle, + screenshot_path: PathBuf, +) -> Result { + upload_screenshot_internal_with_clipboard(app, screenshot_path, None).await +} + #[tauri::command] #[specta::specta] #[instrument(skip(app))] diff --git a/apps/desktop/src-tauri/src/screenshot_post_capture.rs b/apps/desktop/src-tauri/src/screenshot_post_capture.rs index c1e5ccdc79d..d057e768568 100644 --- a/apps/desktop/src-tauri/src/screenshot_post_capture.rs +++ b/apps/desktop/src-tauri/src/screenshot_post_capture.rs @@ -36,6 +36,8 @@ pub enum ScreenshotPostCaptureAction { OpenEditor, ShowOverlay, CopyToClipboard, + Save, + Upload, } impl From for ScreenshotPostCaptureAction { @@ -44,6 +46,8 @@ impl From for ScreenshotPostCaptureAction { PostScreenshotCaptureBehaviour::OpenEditor => Self::OpenEditor, PostScreenshotCaptureBehaviour::ShowOverlay => Self::ShowOverlay, PostScreenshotCaptureBehaviour::CopyToClipboard => Self::CopyToClipboard, + PostScreenshotCaptureBehaviour::Save => Self::Save, + PostScreenshotCaptureBehaviour::Upload => Self::Upload, } } } @@ -132,6 +136,19 @@ pub async fn handle( notifications::send_notification(app, NotificationType::ScreenshotCopiedToClipboard); Ok(()) } + ScreenshotPostCaptureAction::Save => { + notifications::send_notification(app, NotificationType::ScreenshotSaved); + Ok(()) + } + ScreenshotPostCaptureAction::Upload => match crate::upload_screenshot_internal(app, path).await? { + crate::UploadResult::Success(_) => Ok(()), + crate::UploadResult::NotAuthenticated => Ok(()), + crate::UploadResult::UpgradeRequired => Ok(()), + crate::UploadResult::PlanCheckFailed => { + notifications::send_notification(app, NotificationType::ShareableLinkFailed); + Ok(()) + } + }, } } diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 92c71a44990..ffb9a17e5de 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -516,6 +516,8 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { { text: "Open editor", value: "openEditor" }, { text: "Show in overlay", value: "showOverlay" }, { text: "Copy to clipboard", value: "copyToClipboard" }, + { text: "Save only", value: "save" }, + { text: "Upload link", value: "upload" }, ]} /> Date: Sat, 6 Jun 2026 20:51:39 -0400 Subject: [PATCH 04/10] Save screenshot actions as PNG files --- .../src-tauri/src/screenshot_post_capture.rs | 42 ++++++++++++++++++- .../(window-chrome)/settings/general.tsx | 2 +- apps/desktop/src/utils/tauri.ts | 6 ++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/screenshot_post_capture.rs b/apps/desktop/src-tauri/src/screenshot_post_capture.rs index d057e768568..1626a980de0 100644 --- a/apps/desktop/src-tauri/src/screenshot_post_capture.rs +++ b/apps/desktop/src-tauri/src/screenshot_post_capture.rs @@ -137,7 +137,7 @@ pub async fn handle( Ok(()) } ScreenshotPostCaptureAction::Save => { - notifications::send_notification(app, NotificationType::ScreenshotSaved); + save_screenshot_image_file(&path).await?; Ok(()) } ScreenshotPostCaptureAction::Upload => match crate::upload_screenshot_internal(app, path).await? { @@ -223,3 +223,43 @@ async fn copy_screenshot_to_clipboard(app: &AppHandle, path: &Path) -> Result<() .set_image(img_data) .map_err(|err| format!("Failed to copy screenshot to clipboard: {err}")) } + +async fn save_screenshot_image_file(path: &Path) -> Result<(), String> { + let desktop_dir = dirs::desktop_dir() + .ok_or_else(|| "Failed to resolve Desktop directory for screenshot export".to_string())?; + + let file_stem = path + .parent() + .and_then(|parent| parent.file_stem()) + .or_else(|| path.file_stem()) + .and_then(|stem| stem.to_str()) + .unwrap_or("Screenshot"); + + let target_name = format!("{}.png", sanitize_filename::sanitize(file_stem)); + let target_path = desktop_dir.join(cap_utils::ensure_unique_filename( + &target_name, + &desktop_dir, + )?); + + let started_at = Instant::now(); + loop { + match tokio::fs::copy(path, &target_path).await { + Ok(_) => return Ok(()), + Err(err) if started_at.elapsed() < Duration::from_secs(2) => { + sleep(Duration::from_millis(50)).await; + if !path.exists() { + continue; + } + if err.kind() == std::io::ErrorKind::NotFound { + continue; + } + } + Err(err) => { + return Err(format!( + "Failed to save screenshot image to {}: {err}", + target_path.display() + )); + } + } + } +} diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index ffb9a17e5de..ca69a83b533 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -516,7 +516,7 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { { text: "Open editor", value: "openEditor" }, { text: "Show in overlay", value: "showOverlay" }, { text: "Copy to clipboard", value: "copyToClipboard" }, - { text: "Save only", value: "save" }, + { text: "Save PNG file", value: "save" }, { text: "Upload link", value: "upload" }, ]} /> diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 2bcaab62414..1d4700f82ea 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -47,6 +47,9 @@ async deleteRecording() : Promise { async takeScreenshot(target: ScreenCaptureTarget) : Promise { return await TAURI_INVOKE("take_screenshot", { target }); }, +async takeScreenshotWithPostCapture(target: ScreenCaptureTarget) : Promise { + return await TAURI_INVOKE("take_screenshot_with_post_capture", { target }); +}, async listCameras() : Promise { return await TAURI_INVOKE("list_cameras"); }, @@ -518,7 +521,7 @@ export type ExportSettings = ({ format: "Mp4" } & Mp4ExportSettings) | ({ format export type FileType = "recording" | "screenshot" export type Flags = { captions: boolean } export type FramesRendered = { renderedCount: number; totalFrames: number; type: "FramesRendered" } -export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; captureKeyboardEvents?: boolean; postDeletionBehaviour?: PostDeletionBehaviour; excludedWindows?: WindowExclusion[]; deleteInstantRecordingsAfterUpload?: boolean; instantModeMaxResolution?: number; defaultProjectNameTemplate?: string | null; crashRecoveryRecording?: boolean; maxFps?: number; transcriptionHints?: string[]; editorPreviewQuality?: EditorPreviewQuality; studioRecordingQuality?: StudioRecordingQuality; mainWindowPosition?: WindowPosition | null; cameraWindowPosition?: WindowPosition | null; cameraWindowPositionsByMonitorName?: { [key in string]: WindowPosition }; hasCompletedOnboarding?: boolean; enableTelemetry?: boolean; outOfProcessMuxer?: boolean } +export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; postScreenshotCaptureBehaviour?: PostScreenshotCaptureBehaviour; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; captureKeyboardEvents?: boolean; postDeletionBehaviour?: PostDeletionBehaviour; excludedWindows?: WindowExclusion[]; deleteInstantRecordingsAfterUpload?: boolean; instantModeMaxResolution?: number; defaultProjectNameTemplate?: string | null; crashRecoveryRecording?: boolean; maxFps?: number; transcriptionHints?: string[]; editorPreviewQuality?: EditorPreviewQuality; studioRecordingQuality?: StudioRecordingQuality; mainWindowPosition?: WindowPosition | null; cameraWindowPosition?: WindowPosition | null; cameraWindowPositionsByMonitorName?: { [key in string]: WindowPosition }; hasCompletedOnboarding?: boolean; enableTelemetry?: boolean; outOfProcessMuxer?: boolean } export type GifExportSettings = { fps: number; resolution_base: XY; quality: GifQuality | null } export type GifQuality = { /** @@ -576,6 +579,7 @@ export type PhysicalSize = { width: number; height: number } export type Plan = { upgraded: boolean; manual: boolean; last_checked: number } export type Platform = "MacOS" | "Windows" export type PostDeletionBehaviour = "doNothing" | "reopenRecordingWindow" +export type PostScreenshotCaptureBehaviour = "openEditor" | "showOverlay" | "copyToClipboard" | "save" | "upload" export type PostStudioRecordingBehaviour = "openEditor" | "showOverlay" export type Preset = { name: string; config: ProjectConfiguration } export type PresetsStore = { presets: Preset[]; default: number | null } From 1e51692938262f2d05837f00f3b4b88825c92397 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sun, 7 Jun 2026 13:59:38 -0400 Subject: [PATCH 05/10] Harden screenshot post-capture area flow --- .../desktop/src-tauri/src/deeplink_actions.rs | 131 +++++++++++++++++- apps/desktop/src-tauri/src/lib.rs | 9 +- .../src-tauri/src/recording_settings.rs | 9 +- .../src-tauri/src/screenshot_post_capture.rs | 26 ++-- .../src-tauri/src/target_select_overlay.rs | 1 + apps/desktop/src/utils/tauri.ts | 6 +- 6 files changed, 162 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index bea3355b6b5..acd7ba5435b 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -4,7 +4,8 @@ use cap_recording::{ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Url}; -use tracing::trace; +use tokio::sync::Mutex; +use tracing::{trace, warn}; use crate::{ App, ArcLock, @@ -15,6 +16,48 @@ use crate::{ windows::ShowCapWindow, }; +#[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::const_new(TemporaryScreenshotModeState { + previous_mode: None, + active_count: 0, + }); + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CaptureMode { @@ -209,8 +252,11 @@ async fn take_screenshot( screenshot_post_capture::clear_pending_action(app); } - RecordingSettingsStore::set_mode(app, RecordingMode::Screenshot)?; - tray::update_tray_icon_for_mode(app, RecordingMode::Screenshot); + if let Err(err) = begin_temporary_screenshot_mode(app).await { + screenshot_post_capture::clear_pending_action(app); + return Err(err); + } + crate::open_target_picker(app, RecordingTargetMode::Area).await; return Ok(()); } @@ -224,6 +270,52 @@ async fn take_screenshot( screenshot_post_capture::handle(app, path, action).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 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().await; + temporary_mode.begin(previous_mode) + }; + + if !should_enable_screenshot_mode { + return Ok(()); + } + + if let Err(err) = set_recording_mode(app, RecordingMode::Screenshot) { + let mut temporary_mode = TEMPORARY_SCREENSHOT_MODE.lock().await; + let _ = temporary_mode.restore(); + return Err(err); + } + + Ok(()) +} + +pub(crate) async fn restore_temporary_recording_mode(app: &AppHandle) { + let previous_mode = { + let mut temporary_mode = TEMPORARY_SCREENSHOT_MODE.lock().await; + temporary_mode.restore() + }; + + 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()); +} + #[cfg(test)] mod tests { use super::*; @@ -266,4 +358,37 @@ mod tests { Err(ActionParseFromUrlError::NotAction) )); } + + #[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); + } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 76d5fa01969..933bb88a4ef 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -4009,7 +4009,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { recording::restart_recording, recording::delete_recording, recording::take_screenshot, - screenshot_post_capture::take_screenshot_with_post_capture, recording::list_cameras, recording::get_camera_formats, recording::get_microphone_info, @@ -4025,6 +4024,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { fake_window::remove_fake_window, focus_captures_panel, get_current_recording, + screenshot_post_capture::take_screenshot_with_post_capture, export::begin_export_session, export::end_export_session, export::export_video, @@ -5200,6 +5200,13 @@ fn close_target_select_overlays(app: &AppHandle) { if !saw_overlay && let Some(focus_manager) = focus_manager { focus_manager.shutdown(app); } + + screenshot_post_capture::clear_pending_action(app); + + let app = app.clone(); + spawn_on_runtime(async move { + deeplink_actions::restore_temporary_recording_mode(&app).await; + }); } #[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/screenshot_post_capture.rs b/apps/desktop/src-tauri/src/screenshot_post_capture.rs index 1626a980de0..c525f697d92 100644 --- a/apps/desktop/src-tauri/src/screenshot_post_capture.rs +++ b/apps/desktop/src-tauri/src/screenshot_post_capture.rs @@ -1,6 +1,6 @@ use std::{ path::{Path, PathBuf}, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, PoisonError}, time::{Duration, Instant}, }; @@ -70,7 +70,7 @@ impl ScreenshotPostCaptureAction { impl PendingScreenshotPostCaptureAction { pub fn set(&self, action: ScreenshotPostCaptureAction) { - let mut pending = self.0.lock().unwrap(); + let mut pending = self.0.lock().unwrap_or_else(PoisonError::into_inner); *pending = Some(PendingAction { action, created_at: Instant::now(), @@ -78,7 +78,7 @@ impl PendingScreenshotPostCaptureAction { } pub fn take(&self) -> Option { - let mut pending = self.0.lock().unwrap(); + let mut pending = self.0.lock().unwrap_or_else(PoisonError::into_inner); let action = pending.take()?; if action.created_at.elapsed() <= PENDING_ACTION_TTL { @@ -89,7 +89,7 @@ impl PendingScreenshotPostCaptureAction { } pub fn clear(&self) { - let mut pending = self.0.lock().unwrap(); + let mut pending = self.0.lock().unwrap_or_else(PoisonError::into_inner); *pending = None; } } @@ -140,15 +140,17 @@ pub async fn handle( save_screenshot_image_file(&path).await?; Ok(()) } - ScreenshotPostCaptureAction::Upload => match crate::upload_screenshot_internal(app, path).await? { - crate::UploadResult::Success(_) => Ok(()), - crate::UploadResult::NotAuthenticated => Ok(()), - crate::UploadResult::UpgradeRequired => Ok(()), - crate::UploadResult::PlanCheckFailed => { - notifications::send_notification(app, NotificationType::ShareableLinkFailed); - Ok(()) + ScreenshotPostCaptureAction::Upload => { + match crate::upload_screenshot_internal(app, path).await? { + crate::UploadResult::Success(_) => Ok(()), + crate::UploadResult::NotAuthenticated => Ok(()), + crate::UploadResult::UpgradeRequired => Ok(()), + crate::UploadResult::PlanCheckFailed => { + notifications::send_notification(app, NotificationType::ShareableLinkFailed); + Ok(()) + } } - }, + } } } diff --git a/apps/desktop/src-tauri/src/target_select_overlay.rs b/apps/desktop/src-tauri/src/target_select_overlay.rs index 2821dd25da5..6f6966ef057 100644 --- a/apps/desktop/src-tauri/src/target_select_overlay.rs +++ b/apps/desktop/src-tauri/src/target_select_overlay.rs @@ -328,6 +328,7 @@ pub async fn close_target_select_overlays( } screenshot_post_capture::clear_pending_action(&app); + crate::deeplink_actions::restore_temporary_recording_mode(&app).await; Ok(()) } diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 1d4700f82ea..9ed90422c2f 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -47,9 +47,6 @@ async deleteRecording() : Promise { async takeScreenshot(target: ScreenCaptureTarget) : Promise { return await TAURI_INVOKE("take_screenshot", { target }); }, -async takeScreenshotWithPostCapture(target: ScreenCaptureTarget) : Promise { - return await TAURI_INVOKE("take_screenshot_with_post_capture", { target }); -}, async listCameras() : Promise { return await TAURI_INVOKE("list_cameras"); }, @@ -95,6 +92,9 @@ async focusCapturesPanel() : Promise { async getCurrentRecording() : Promise> { return await TAURI_INVOKE("get_current_recording"); }, +async takeScreenshotWithPostCapture(target: ScreenCaptureTarget) : Promise { + return await TAURI_INVOKE("take_screenshot_with_post_capture", { target }); +}, async beginExportSession() : Promise { await TAURI_INVOKE("begin_export_session"); }, From d4df9229aae48b775c6533d8bfe1f32898c37766 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sun, 7 Jun 2026 19:35:06 -0400 Subject: [PATCH 06/10] Add screenshot post-capture behavior tests --- .../src-tauri/src/screenshot_post_capture.rs | 153 +++++++++++++++++- 1 file changed, 148 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/screenshot_post_capture.rs b/apps/desktop/src-tauri/src/screenshot_post_capture.rs index c525f697d92..78925de5149 100644 --- a/apps/desktop/src-tauri/src/screenshot_post_capture.rs +++ b/apps/desktop/src-tauri/src/screenshot_post_capture.rs @@ -230,6 +230,15 @@ async fn save_screenshot_image_file(path: &Path) -> Result<(), String> { let desktop_dir = dirs::desktop_dir() .ok_or_else(|| "Failed to resolve Desktop directory for screenshot export".to_string())?; + save_screenshot_image_file_to_dir(path, &desktop_dir) + .await + .map(|_| ()) +} + +async fn save_screenshot_image_file_to_dir( + path: &Path, + target_dir: &Path, +) -> Result { let file_stem = path .parent() .and_then(|parent| parent.file_stem()) @@ -238,15 +247,12 @@ async fn save_screenshot_image_file(path: &Path) -> Result<(), String> { .unwrap_or("Screenshot"); let target_name = format!("{}.png", sanitize_filename::sanitize(file_stem)); - let target_path = desktop_dir.join(cap_utils::ensure_unique_filename( - &target_name, - &desktop_dir, - )?); + let target_path = target_dir.join(cap_utils::ensure_unique_filename(&target_name, target_dir)?); let started_at = Instant::now(); loop { match tokio::fs::copy(path, &target_path).await { - Ok(_) => return Ok(()), + Ok(_) => return Ok(target_path), Err(err) if started_at.elapsed() < Duration::from_secs(2) => { sleep(Duration::from_millis(50)).await; if !path.exists() { @@ -265,3 +271,140 @@ async fn save_screenshot_image_file(path: &Path) -> Result<(), String> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use clipboard_rs::common::RustImage; + use std::panic::AssertUnwindSafe; + + #[test] + fn pending_action_is_consumed_once() { + let pending = PendingScreenshotPostCaptureAction::default(); + + pending.set(ScreenshotPostCaptureAction::CopyToClipboard); + + assert_eq!( + pending.take(), + Some(ScreenshotPostCaptureAction::CopyToClipboard) + ); + assert_eq!(pending.take(), None); + } + + #[test] + fn pending_action_clear_discards_action() { + let pending = PendingScreenshotPostCaptureAction::default(); + + pending.set(ScreenshotPostCaptureAction::Upload); + pending.clear(); + + assert_eq!(pending.take(), None); + } + + #[test] + fn pending_action_expires() { + let pending = PendingScreenshotPostCaptureAction::default(); + *pending.0.lock().unwrap() = Some(PendingAction { + action: ScreenshotPostCaptureAction::Save, + created_at: Instant::now() - PENDING_ACTION_TTL - Duration::from_secs(1), + }); + + assert_eq!(pending.take(), None); + assert_eq!(pending.take(), None); + } + + #[test] + fn pending_action_recovers_from_poisoned_mutex() { + let pending = PendingScreenshotPostCaptureAction::default(); + let poisoned = pending.clone(); + + let _ = std::panic::catch_unwind(AssertUnwindSafe(move || { + let _guard = poisoned.0.lock().unwrap(); + panic!("poison pending screenshot action mutex"); + })); + + pending.set(ScreenshotPostCaptureAction::ShowOverlay); + + assert_eq!( + pending.take(), + Some(ScreenshotPostCaptureAction::ShowOverlay) + ); + } + + #[test] + fn image_from_pending_screenshot_supports_rgba_and_rgb() { + let rgba = image_from_pending_screenshot(PendingScreenshot { + data: vec![255, 0, 0, 255, 0, 255, 0, 255], + width: 2, + height: 1, + channels: 4, + created_at: Instant::now(), + }) + .unwrap(); + assert_eq!(rgba.get_size(), (2, 1)); + + let rgb = image_from_pending_screenshot(PendingScreenshot { + data: vec![255, 0, 0, 0, 255, 0], + width: 2, + height: 1, + channels: 3, + created_at: Instant::now(), + }) + .unwrap(); + assert_eq!(rgb.get_size(), (2, 1)); + } + + #[test] + fn image_from_pending_screenshot_rejects_unsupported_channels() { + let result = image_from_pending_screenshot(PendingScreenshot { + data: vec![0, 0], + width: 1, + height: 1, + channels: 2, + created_at: Instant::now(), + }); + + match result { + Ok(_) => panic!("expected unsupported channel count error"), + Err(err) => assert!(err.contains("Unsupported screenshot channel count: 2")), + } + } + + #[tokio::test] + async fn save_screenshot_image_file_to_dir_copies_png_with_project_name() { + let temp_dir = tempfile::tempdir().unwrap(); + let project_dir = temp_dir.path().join("Launch Clip.cap"); + let target_dir = temp_dir.path().join("Desktop"); + let source_path = project_dir.join("original.png"); + let contents = b"png bytes"; + std::fs::create_dir_all(&project_dir).unwrap(); + std::fs::create_dir_all(&target_dir).unwrap(); + std::fs::write(&source_path, contents).unwrap(); + + let saved_path = save_screenshot_image_file_to_dir(&source_path, &target_dir) + .await + .unwrap(); + + assert_eq!(saved_path.file_name().unwrap(), "Launch Clip.png"); + assert_eq!(std::fs::read(saved_path).unwrap(), contents); + } + + #[tokio::test] + async fn save_screenshot_image_file_to_dir_uses_unique_filename() { + let temp_dir = tempfile::tempdir().unwrap(); + let project_dir = temp_dir.path().join("Launch Clip.cap"); + let target_dir = temp_dir.path().join("Desktop"); + let source_path = project_dir.join("original.png"); + std::fs::create_dir_all(&project_dir).unwrap(); + std::fs::create_dir_all(&target_dir).unwrap(); + std::fs::write(&source_path, b"new").unwrap(); + std::fs::write(target_dir.join("Launch Clip.png"), b"existing").unwrap(); + + let saved_path = save_screenshot_image_file_to_dir(&source_path, &target_dir) + .await + .unwrap(); + + assert_eq!(saved_path.file_name().unwrap(), "Launch Clip (1).png"); + assert_eq!(std::fs::read(saved_path).unwrap(), b"new"); + } +} From fa5e68c297e8bdd1caaea17030f8f21857911b80 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sun, 7 Jun 2026 19:48:01 -0400 Subject: [PATCH 07/10] Fix screenshot save post-capture race --- .../src-tauri/src/screenshot_post_capture.rs | 157 +++++++++++++++--- 1 file changed, 138 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src-tauri/src/screenshot_post_capture.rs b/apps/desktop/src-tauri/src/screenshot_post_capture.rs index 78925de5149..9cb48e665ff 100644 --- a/apps/desktop/src-tauri/src/screenshot_post_capture.rs +++ b/apps/desktop/src-tauri/src/screenshot_post_capture.rs @@ -6,6 +6,7 @@ use std::{ use cap_recording::sources::screen_capture::ScreenCaptureTarget; use clipboard_rs::{Clipboard, ClipboardContext, RustImageData, common::RustImage}; +use image::ImageEncoder; use serde::{Deserialize, Serialize}; use specta::Type; use tauri::{AppHandle, Manager}; @@ -137,7 +138,7 @@ pub async fn handle( Ok(()) } ScreenshotPostCaptureAction::Save => { - save_screenshot_image_file(&path).await?; + save_screenshot_image_file(app, &path).await?; Ok(()) } ScreenshotPostCaptureAction::Upload => { @@ -167,10 +168,14 @@ pub async fn take_screenshot_with_post_capture( Ok(path) } -fn pending_screenshot_image(app: &AppHandle, path: &Path) -> Option> { +fn pending_screenshot(app: &AppHandle, path: &Path) -> Option { let key = path.parent()?.to_string_lossy().to_string(); let pending = app.try_state::()?; - pending.get(&key).map(image_from_pending_screenshot) + pending.get(&key) +} + +fn pending_screenshot_image(app: &AppHandle, path: &Path) -> Option> { + pending_screenshot(app, path).map(image_from_pending_screenshot) } fn image_from_pending_screenshot(frame: PendingScreenshot) -> Result { @@ -226,19 +231,25 @@ async fn copy_screenshot_to_clipboard(app: &AppHandle, path: &Path) -> Result<() .map_err(|err| format!("Failed to copy screenshot to clipboard: {err}")) } -async fn save_screenshot_image_file(path: &Path) -> Result<(), String> { +async fn save_screenshot_image_file(app: &AppHandle, path: &Path) -> Result<(), String> { let desktop_dir = dirs::desktop_dir() .ok_or_else(|| "Failed to resolve Desktop directory for screenshot export".to_string())?; + let target_path = screenshot_save_target_path(path, &desktop_dir)?; - save_screenshot_image_file_to_dir(path, &desktop_dir) - .await - .map(|_| ()) + if let Some(screenshot) = pending_screenshot(app, path) { + write_pending_screenshot_png(screenshot, target_path).await?; + return Ok(()); + } + + copy_screenshot_image_file_to_path(path, target_path).await } -async fn save_screenshot_image_file_to_dir( - path: &Path, - target_dir: &Path, -) -> Result { +fn screenshot_save_target_path(path: &Path, target_dir: &Path) -> Result { + let target_name = screenshot_save_target_name(path); + Ok(target_dir.join(cap_utils::ensure_unique_filename(&target_name, target_dir)?)) +} + +fn screenshot_save_target_name(path: &Path) -> String { let file_stem = path .parent() .and_then(|parent| parent.file_stem()) @@ -246,13 +257,29 @@ async fn save_screenshot_image_file_to_dir( .and_then(|stem| stem.to_str()) .unwrap_or("Screenshot"); - let target_name = format!("{}.png", sanitize_filename::sanitize(file_stem)); - let target_path = target_dir.join(cap_utils::ensure_unique_filename(&target_name, target_dir)?); + format!("{}.png", sanitize_filename::sanitize(file_stem)) +} + +#[cfg(test)] +async fn save_screenshot_image_file_to_dir( + path: &Path, + target_dir: &Path, +) -> Result { + let target_path = screenshot_save_target_path(path, target_dir)?; + copy_screenshot_image_file_to_path(path, target_path.clone()).await?; + Ok(target_path) +} + +async fn copy_screenshot_image_file_to_path( + path: &Path, + target_path: PathBuf, +) -> Result<(), String> { + read_screenshot_image(path).await?; let started_at = Instant::now(); loop { match tokio::fs::copy(path, &target_path).await { - Ok(_) => return Ok(target_path), + Ok(_) => return Ok(()), Err(err) if started_at.elapsed() < Duration::from_secs(2) => { sleep(Duration::from_millis(50)).await; if !path.exists() { @@ -272,12 +299,58 @@ async fn save_screenshot_image_file_to_dir( } } +async fn write_pending_screenshot_png( + screenshot: PendingScreenshot, + target_path: PathBuf, +) -> Result<(), String> { + let color_type = match screenshot.channels { + 4 => image::ColorType::Rgba8, + 3 => image::ColorType::Rgb8, + channels => { + return Err(format!("Unsupported screenshot channel count: {channels}")); + } + }; + + tokio::task::spawn_blocking(move || -> Result<(), String> { + let file = std::fs::File::create(&target_path).map_err(|err| { + format!( + "Failed to save screenshot image to {}: {err}", + target_path.display() + ) + })?; + let encoder = image::codecs::png::PngEncoder::new(std::io::BufWriter::new(file)); + + encoder + .write_image( + &screenshot.data, + screenshot.width, + screenshot.height, + color_type.into(), + ) + .map_err(|err| { + format!( + "Failed to save screenshot image to {}: {err}", + target_path.display() + ) + }) + }) + .await + .map_err(|err| format!("Failed to save screenshot image: {err}"))? +} + #[cfg(test)] mod tests { use super::*; use clipboard_rs::common::RustImage; + use image::GenericImageView; use std::panic::AssertUnwindSafe; + fn write_test_png(path: &Path, color: [u8; 4]) { + image::RgbaImage::from_pixel(2, 1, image::Rgba(color)) + .save(path) + .unwrap(); + } + #[test] fn pending_action_is_consumed_once() { let pending = PendingScreenshotPostCaptureAction::default(); @@ -376,17 +449,16 @@ mod tests { let project_dir = temp_dir.path().join("Launch Clip.cap"); let target_dir = temp_dir.path().join("Desktop"); let source_path = project_dir.join("original.png"); - let contents = b"png bytes"; std::fs::create_dir_all(&project_dir).unwrap(); std::fs::create_dir_all(&target_dir).unwrap(); - std::fs::write(&source_path, contents).unwrap(); + write_test_png(&source_path, [255, 0, 0, 255]); let saved_path = save_screenshot_image_file_to_dir(&source_path, &target_dir) .await .unwrap(); assert_eq!(saved_path.file_name().unwrap(), "Launch Clip.png"); - assert_eq!(std::fs::read(saved_path).unwrap(), contents); + assert_eq!(image::open(saved_path).unwrap().dimensions(), (2, 1)); } #[tokio::test] @@ -397,7 +469,7 @@ mod tests { let source_path = project_dir.join("original.png"); std::fs::create_dir_all(&project_dir).unwrap(); std::fs::create_dir_all(&target_dir).unwrap(); - std::fs::write(&source_path, b"new").unwrap(); + write_test_png(&source_path, [0, 255, 0, 255]); std::fs::write(target_dir.join("Launch Clip.png"), b"existing").unwrap(); let saved_path = save_screenshot_image_file_to_dir(&source_path, &target_dir) @@ -405,6 +477,53 @@ mod tests { .unwrap(); assert_eq!(saved_path.file_name().unwrap(), "Launch Clip (1).png"); - assert_eq!(std::fs::read(saved_path).unwrap(), b"new"); + assert_eq!(image::open(saved_path).unwrap().dimensions(), (2, 1)); + } + + #[tokio::test] + async fn save_screenshot_image_file_to_dir_waits_for_valid_png_before_copying() { + let temp_dir = tempfile::tempdir().unwrap(); + let project_dir = temp_dir.path().join("Launch Clip.cap"); + let target_dir = temp_dir.path().join("Desktop"); + let source_path = project_dir.join("original.png"); + std::fs::create_dir_all(&project_dir).unwrap(); + std::fs::create_dir_all(&target_dir).unwrap(); + std::fs::write(&source_path, []).unwrap(); + + let source_path_for_writer = source_path.clone(); + tokio::spawn(async move { + sleep(Duration::from_millis(100)).await; + write_test_png(&source_path_for_writer, [0, 0, 255, 255]); + }); + + let saved_path = save_screenshot_image_file_to_dir(&source_path, &target_dir) + .await + .unwrap(); + + assert_eq!(saved_path.file_name().unwrap(), "Launch Clip.png"); + assert!(std::fs::metadata(&saved_path).unwrap().len() > 0); + assert_eq!(image::open(saved_path).unwrap().dimensions(), (2, 1)); + } + + #[tokio::test] + async fn write_pending_screenshot_png_writes_valid_png() { + let temp_dir = tempfile::tempdir().unwrap(); + let target_path = temp_dir.path().join("Pending.png"); + + write_pending_screenshot_png( + PendingScreenshot { + data: vec![255, 0, 0, 255, 0, 255, 0, 255], + width: 2, + height: 1, + channels: 4, + created_at: Instant::now(), + }, + target_path.clone(), + ) + .await + .unwrap(); + + assert!(std::fs::metadata(&target_path).unwrap().len() > 0); + assert_eq!(image::open(target_path).unwrap().dimensions(), (2, 1)); } } From e81d55fff2aaef2d88f71fc600bedfed55d563f7 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sun, 7 Jun 2026 19:59:30 -0400 Subject: [PATCH 08/10] Add full screenshot post-capture options --- .../desktop/src-tauri/src/general_settings.rs | 11 +- .../src-tauri/src/screenshot_post_capture.rs | 394 +++++++++++++++++- .../(window-chrome)/settings/general.tsx | 74 +++- .../src/utils/general-settings.test.ts | 1 + apps/desktop/src/utils/general-settings.ts | 7 + apps/desktop/src/utils/tauri.ts | 4 +- 6 files changed, 476 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 940013f2727..6c59afefd42 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -3,7 +3,7 @@ use scap_targets::DisplayId; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; -use std::collections::BTreeMap; +use std::{collections::BTreeMap, path::PathBuf}; #[cfg(target_os = "macos")] use tauri::Listener; use tauri::{AppHandle, Wry}; @@ -24,9 +24,15 @@ pub enum PostStudioRecordingBehaviour { pub enum PostScreenshotCaptureBehaviour { #[default] OpenEditor, + DoNothing, + AskEveryTime, ShowOverlay, CopyToClipboard, + CopyFilePath, + CopyMarkdownImage, Save, + SaveToFolder, + RevealInFinder, Upload, } @@ -162,6 +168,8 @@ pub struct GeneralSettingsStore { #[serde(default)] pub post_screenshot_capture_behaviour: PostScreenshotCaptureBehaviour, #[serde(default)] + pub screenshot_save_directory: Option, + #[serde(default)] pub main_window_recording_start_behaviour: MainWindowRecordingStartBehaviour, #[serde(default = "default_true", rename = "custom_cursor_capture2")] pub custom_cursor_capture: bool, @@ -272,6 +280,7 @@ impl Default for GeneralSettingsStore { window_transparency: false, post_studio_recording_behaviour: PostStudioRecordingBehaviour::OpenEditor, post_screenshot_capture_behaviour: PostScreenshotCaptureBehaviour::OpenEditor, + screenshot_save_directory: None, main_window_recording_start_behaviour: MainWindowRecordingStartBehaviour::Close, custom_cursor_capture: true, server_url: default_server_url(), diff --git a/apps/desktop/src-tauri/src/screenshot_post_capture.rs b/apps/desktop/src-tauri/src/screenshot_post_capture.rs index 9cb48e665ff..239c7732c2a 100644 --- a/apps/desktop/src-tauri/src/screenshot_post_capture.rs +++ b/apps/desktop/src-tauri/src/screenshot_post_capture.rs @@ -9,8 +9,12 @@ use clipboard_rs::{Clipboard, ClipboardContext, RustImageData, common::RustImage use image::ImageEncoder; use serde::{Deserialize, Serialize}; use specta::Type; -use tauri::{AppHandle, Manager}; -use tokio::time::sleep; +use tauri::{AppHandle, Manager, Url}; +use tauri_plugin_dialog::{ + DialogExt, MessageDialogButtons, MessageDialogKind, MessageDialogResult, +}; +use tauri_plugin_opener::OpenerExt; +use tokio::{sync::oneshot, time::sleep}; use crate::{ ArcLock, PendingScreenshot, PendingScreenshots, @@ -35,9 +39,15 @@ struct PendingAction { pub enum ScreenshotPostCaptureAction { #[default] OpenEditor, + DoNothing, + AskEveryTime, ShowOverlay, CopyToClipboard, + CopyFilePath, + CopyMarkdownImage, Save, + SaveToFolder, + RevealInFinder, Upload, } @@ -45,9 +55,15 @@ impl From for ScreenshotPostCaptureAction { fn from(value: PostScreenshotCaptureBehaviour) -> Self { match value { PostScreenshotCaptureBehaviour::OpenEditor => Self::OpenEditor, + PostScreenshotCaptureBehaviour::DoNothing => Self::DoNothing, + PostScreenshotCaptureBehaviour::AskEveryTime => Self::AskEveryTime, PostScreenshotCaptureBehaviour::ShowOverlay => Self::ShowOverlay, PostScreenshotCaptureBehaviour::CopyToClipboard => Self::CopyToClipboard, + PostScreenshotCaptureBehaviour::CopyFilePath => Self::CopyFilePath, + PostScreenshotCaptureBehaviour::CopyMarkdownImage => Self::CopyMarkdownImage, PostScreenshotCaptureBehaviour::Save => Self::Save, + PostScreenshotCaptureBehaviour::SaveToFolder => Self::SaveToFolder, + PostScreenshotCaptureBehaviour::RevealInFinder => Self::RevealInFinder, PostScreenshotCaptureBehaviour::Upload => Self::Upload, } } @@ -117,7 +133,17 @@ pub async fn handle( path: PathBuf, action: ScreenshotPostCaptureAction, ) -> Result<(), String> { + let action = match action { + ScreenshotPostCaptureAction::AskEveryTime => match prompt_post_capture_action(app).await? { + Some(action) => action, + None => return Ok(()), + }, + action => action, + }; + match action { + ScreenshotPostCaptureAction::DoNothing => Ok(()), + ScreenshotPostCaptureAction::AskEveryTime => Ok(()), ScreenshotPostCaptureAction::OpenEditor => { ShowCapWindow::ScreenshotEditor { path } .show(app) @@ -137,8 +163,37 @@ pub async fn handle( notifications::send_notification(app, NotificationType::ScreenshotCopiedToClipboard); Ok(()) } + ScreenshotPostCaptureAction::CopyFilePath => { + wait_for_screenshot_image(path.as_path()).await?; + copy_text_to_clipboard(app, path.to_string_lossy().to_string()).await?; + notifications::send_notification(app, NotificationType::ScreenshotCopiedToClipboard); + Ok(()) + } + ScreenshotPostCaptureAction::CopyMarkdownImage => { + wait_for_screenshot_image(path.as_path()).await?; + copy_text_to_clipboard(app, markdown_image_for_path(path.as_path())?).await?; + notifications::send_notification(app, NotificationType::ScreenshotCopiedToClipboard); + Ok(()) + } ScreenshotPostCaptureAction::Save => { save_screenshot_image_file(app, &path).await?; + notifications::send_notification(app, NotificationType::ScreenshotSaved); + Ok(()) + } + ScreenshotPostCaptureAction::SaveToFolder => { + if save_screenshot_image_file_to_configured_directory(app, &path) + .await? + .is_some() + { + notifications::send_notification(app, NotificationType::ScreenshotSaved); + } + Ok(()) + } + ScreenshotPostCaptureAction::RevealInFinder => { + wait_for_screenshot_image(path.as_path()).await?; + app.opener() + .reveal_item_in_dir(path) + .map_err(|err| format!("Failed to reveal screenshot in Finder: {err}"))?; Ok(()) } ScreenshotPostCaptureAction::Upload => { @@ -168,6 +223,165 @@ pub async fn take_screenshot_with_post_capture( Ok(path) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DialogButton { + First, + Second, + Third, +} + +async fn prompt_post_capture_action( + app: &AppHandle, +) -> Result, String> { + match choose_post_capture_button( + app, + "After Screenshot", + "Choose what Cap should do with this screenshot.", + "Open editor", + "More actions", + "Do nothing", + ) + .await? + { + DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::OpenEditor)), + DialogButton::Third => Ok(None), + DialogButton::Second => match choose_post_capture_button( + app, + "After Screenshot", + "Choose what Cap should do with this screenshot.", + "Copy image", + "Save Desktop", + "More actions", + ) + .await? + { + DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::CopyToClipboard)), + DialogButton::Second => Ok(Some(ScreenshotPostCaptureAction::Save)), + DialogButton::Third => match choose_post_capture_button( + app, + "After Screenshot", + "Choose what Cap should do with this screenshot.", + "Save folder", + "Upload link", + "More actions", + ) + .await? + { + DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::SaveToFolder)), + DialogButton::Second => Ok(Some(ScreenshotPostCaptureAction::Upload)), + DialogButton::Third => match choose_post_capture_button( + app, + "After Screenshot", + "Choose what Cap should do with this screenshot.", + "Show overlay", + "Reveal in Finder", + "More actions", + ) + .await? + { + DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::ShowOverlay)), + DialogButton::Second => Ok(Some(ScreenshotPostCaptureAction::RevealInFinder)), + DialogButton::Third => match choose_post_capture_button( + app, + "After Screenshot", + "Choose what Cap should do with this screenshot.", + "Copy path", + "Copy Markdown", + "Do nothing", + ) + .await? + { + DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::CopyFilePath)), + DialogButton::Second => { + Ok(Some(ScreenshotPostCaptureAction::CopyMarkdownImage)) + } + DialogButton::Third => Ok(None), + }, + }, + }, + }, + } +} + +async fn choose_post_capture_button( + app: &AppHandle, + title: &str, + message: &str, + first: &str, + second: &str, + third: &str, +) -> Result { + let first_label = first.to_string(); + let second_label = second.to_string(); + let third_label = third.to_string(); + let (tx, rx) = oneshot::channel(); + + app.dialog() + .message(message) + .title(title) + .kind(MessageDialogKind::Info) + .buttons(MessageDialogButtons::YesNoCancelCustom( + first_label.clone(), + second_label.clone(), + third_label.clone(), + )) + .show_with_result(move |result| { + let _ = tx.send(result); + }); + + let result = rx + .await + .map_err(|err| format!("Failed to show screenshot action dialog: {err}"))?; + + Ok(dialog_result_to_button( + result, + &first_label, + &second_label, + &third_label, + )) +} + +fn dialog_result_to_button( + result: MessageDialogResult, + first: &str, + second: &str, + third: &str, +) -> DialogButton { + match result { + MessageDialogResult::Ok | MessageDialogResult::Yes => DialogButton::First, + MessageDialogResult::No => DialogButton::Second, + MessageDialogResult::Cancel => DialogButton::Third, + MessageDialogResult::Custom(label) if label == first => DialogButton::First, + MessageDialogResult::Custom(label) if label == second => DialogButton::Second, + MessageDialogResult::Custom(label) if label == third => DialogButton::Third, + MessageDialogResult::Custom(_) => DialogButton::Third, + } +} + +async fn choose_screenshot_save_directory(app: &AppHandle) -> Result, String> { + let (tx, rx) = oneshot::channel(); + + app.dialog() + .file() + .set_title("Choose Screenshot Folder") + .pick_folder(move |path| { + let _ = tx.send(path); + }); + + let selected_path = rx + .await + .map_err(|err| format!("Failed to show screenshot folder dialog: {err}"))? + .and_then(|path| path.as_path().map(Path::to_path_buf)); + + if let Some(path) = selected_path.clone() { + GeneralSettingsStore::update(app, |settings| { + settings.screenshot_save_directory = Some(path); + })?; + } + + Ok(selected_path) +} + fn pending_screenshot(app: &AppHandle, path: &Path) -> Option { let key = path.parent()?.to_string_lossy().to_string(); let pending = app.try_state::()?; @@ -209,7 +423,7 @@ async fn read_screenshot_image(path: &Path) -> Result { Ok(img_data) => return Ok(img_data), Err(e) => { if started_at.elapsed() >= Duration::from_secs(2) { - return Err(format!("Failed to copy screenshot to clipboard: {e}")); + return Err(format!("Screenshot image was not ready: {e}")); } sleep(Duration::from_millis(50)).await; } @@ -217,11 +431,17 @@ async fn read_screenshot_image(path: &Path) -> Result { } } +async fn wait_for_screenshot_image(path: &Path) -> Result<(), String> { + read_screenshot_image(path).await.map(|_| ()) +} + async fn copy_screenshot_to_clipboard(app: &AppHandle, path: &Path) -> Result<(), String> { let img_data = if let Some(img_data) = pending_screenshot_image(app, path) { img_data? } else { - read_screenshot_image(path).await? + read_screenshot_image(path) + .await + .map_err(|err| format!("Failed to copy screenshot to clipboard: {err}"))? }; app.state::>() @@ -231,17 +451,73 @@ async fn copy_screenshot_to_clipboard(app: &AppHandle, path: &Path) -> Result<() .map_err(|err| format!("Failed to copy screenshot to clipboard: {err}")) } -async fn save_screenshot_image_file(app: &AppHandle, path: &Path) -> Result<(), String> { +async fn copy_text_to_clipboard(app: &AppHandle, text: String) -> Result<(), String> { + app.state::>() + .write() + .await + .set_text(text) + .map_err(|err| format!("Failed to copy screenshot text to clipboard: {err}")) +} + +fn markdown_image_for_path(path: &Path) -> Result { + let url = Url::from_file_path(path) + .map_err(|_| format!("Failed to create file URL for {}", path.display()))?; + + Ok(format!("![Screenshot](<{}>)", url.as_str())) +} + +async fn save_screenshot_image_file(app: &AppHandle, path: &Path) -> Result { let desktop_dir = dirs::desktop_dir() .ok_or_else(|| "Failed to resolve Desktop directory for screenshot export".to_string())?; - let target_path = screenshot_save_target_path(path, &desktop_dir)?; + save_screenshot_image_file_to_directory(app, path, &desktop_dir).await +} + +async fn save_screenshot_image_file_to_configured_directory( + app: &AppHandle, + path: &Path, +) -> Result, String> { + let target_dir = match configured_screenshot_save_directory(app) { + Some(path) => path, + None => match choose_screenshot_save_directory(app).await? { + Some(path) => path, + None => return Ok(None), + }, + }; + + save_screenshot_image_file_to_directory(app, path, &target_dir) + .await + .map(Some) +} + +fn configured_screenshot_save_directory(app: &AppHandle) -> Option { + GeneralSettingsStore::get(app) + .ok() + .flatten() + .and_then(|settings| settings.screenshot_save_directory) + .filter(|path| !path.as_os_str().is_empty()) +} + +async fn save_screenshot_image_file_to_directory( + app: &AppHandle, + path: &Path, + target_dir: &Path, +) -> Result { + tokio::fs::create_dir_all(target_dir).await.map_err(|err| { + format!( + "Failed to create screenshot save directory {}: {err}", + target_dir.display() + ) + })?; + + let target_path = screenshot_save_target_path(path, target_dir)?; if let Some(screenshot) = pending_screenshot(app, path) { - write_pending_screenshot_png(screenshot, target_path).await?; - return Ok(()); + write_pending_screenshot_png(screenshot, target_path.clone()).await?; + return Ok(target_path); } - copy_screenshot_image_file_to_path(path, target_path).await + copy_screenshot_image_file_to_path(path, target_path.clone()).await?; + Ok(target_path) } fn screenshot_save_target_path(path: &Path, target_dir: &Path) -> Result { @@ -443,6 +719,106 @@ mod tests { } } + #[test] + fn post_screenshot_capture_behaviour_maps_to_actions() { + let mappings = [ + ( + PostScreenshotCaptureBehaviour::OpenEditor, + ScreenshotPostCaptureAction::OpenEditor, + ), + ( + PostScreenshotCaptureBehaviour::DoNothing, + ScreenshotPostCaptureAction::DoNothing, + ), + ( + PostScreenshotCaptureBehaviour::AskEveryTime, + ScreenshotPostCaptureAction::AskEveryTime, + ), + ( + PostScreenshotCaptureBehaviour::ShowOverlay, + ScreenshotPostCaptureAction::ShowOverlay, + ), + ( + PostScreenshotCaptureBehaviour::CopyToClipboard, + ScreenshotPostCaptureAction::CopyToClipboard, + ), + ( + PostScreenshotCaptureBehaviour::CopyFilePath, + ScreenshotPostCaptureAction::CopyFilePath, + ), + ( + PostScreenshotCaptureBehaviour::CopyMarkdownImage, + ScreenshotPostCaptureAction::CopyMarkdownImage, + ), + ( + PostScreenshotCaptureBehaviour::Save, + ScreenshotPostCaptureAction::Save, + ), + ( + PostScreenshotCaptureBehaviour::SaveToFolder, + ScreenshotPostCaptureAction::SaveToFolder, + ), + ( + PostScreenshotCaptureBehaviour::RevealInFinder, + ScreenshotPostCaptureAction::RevealInFinder, + ), + ( + PostScreenshotCaptureBehaviour::Upload, + ScreenshotPostCaptureAction::Upload, + ), + ]; + + for (behaviour, action) in mappings { + assert_eq!(ScreenshotPostCaptureAction::from(behaviour), action); + } + } + + #[test] + fn dialog_result_to_button_maps_custom_labels() { + assert_eq!( + dialog_result_to_button( + MessageDialogResult::Custom("Open editor".to_string()), + "Open editor", + "More actions", + "Do nothing", + ), + DialogButton::First + ); + assert_eq!( + dialog_result_to_button( + MessageDialogResult::Custom("More actions".to_string()), + "Open editor", + "More actions", + "Do nothing", + ), + DialogButton::Second + ); + assert_eq!( + dialog_result_to_button( + MessageDialogResult::Custom("Do nothing".to_string()), + "Open editor", + "More actions", + "Do nothing", + ), + DialogButton::Third + ); + } + + #[test] + fn markdown_image_for_path_uses_file_url() { + let path = tempfile::tempdir() + .unwrap() + .path() + .join("Launch Clip.cap") + .join("original.png"); + + let markdown = markdown_image_for_path(&path).unwrap(); + + assert!(markdown.starts_with("![Screenshot]()")); + } + #[tokio::test] async fn save_screenshot_image_file_to_dir_copies_png_with_project_name() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index ca69a83b533..08c0a12b51a 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -8,7 +8,7 @@ import { type OsType, type } from "@tauri-apps/plugin-os"; import "@total-typescript/ts-reset/filter-boolean"; import { Collapsible } from "@kobalte/core/collapsible"; import { CheckMenuItem, Menu, MenuItem } from "@tauri-apps/api/menu"; -import { confirm } from "@tauri-apps/plugin-dialog"; +import { confirm, open } from "@tauri-apps/plugin-dialog"; import { cx } from "cva"; import { createEffect, @@ -42,6 +42,7 @@ import { type StudioRecordingQuality, type WindowExclusion, } from "~/utils/tauri"; +import IconLucideFolderOpen from "~icons/lucide/folder-open"; import IconLucidePlus from "~icons/lucide/plus"; import IconLucideX from "~icons/lucide/x"; import { @@ -256,6 +257,18 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { } }; + const handleChooseScreenshotSaveDirectory = async () => { + const selected = await open({ + directory: true, + multiple: false, + title: "Choose Screenshot Save Folder", + defaultPath: settings.screenshotSaveDirectory ?? undefined, + }); + + if (typeof selected !== "string") return; + await handleChange("screenshotSaveDirectory", selected); + }; + const ostype: OsType = type(); const excludedWindows = createMemo(() => settings.excludedWindows ?? []); @@ -513,13 +526,68 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { handleChange("postScreenshotCaptureBehaviour", value) } options={[ + { text: "Ask every time", value: "askEveryTime" }, { text: "Open editor", value: "openEditor" }, { text: "Show in overlay", value: "showOverlay" }, - { text: "Copy to clipboard", value: "copyToClipboard" }, - { text: "Save PNG file", value: "save" }, + { text: "Copy image to clipboard", value: "copyToClipboard" }, + { text: "Copy file path", value: "copyFilePath" }, + { text: "Copy Markdown image", value: "copyMarkdownImage" }, + { text: "Save PNG to Desktop", value: "save" }, + { text: "Save PNG to folder", value: "saveToFolder" }, + { text: "Reveal in Finder", value: "revealInFinder" }, { text: "Upload link", value: "upload" }, + { text: "Do nothing", value: "doNothing" }, ]} /> + + +
+ + Not set + + } + > + {(directory) => ( + + {directory()} + + )} + + + + + +
+
+
{ captureKeyboardEvents: true, custom_cursor_capture2: true, postScreenshotCaptureBehaviour: "openEditor", + screenshotSaveDirectory: null, }); }); diff --git a/apps/desktop/src/utils/general-settings.ts b/apps/desktop/src/utils/general-settings.ts index dc55170f503..d6c59fdb6aa 100644 --- a/apps/desktop/src/utils/general-settings.ts +++ b/apps/desktop/src/utils/general-settings.ts @@ -2,9 +2,15 @@ import type { GeneralSettingsStore as TauriGeneralSettingsStore } from "~/utils/ export type PostScreenshotCaptureBehaviour = | "openEditor" + | "doNothing" + | "askEveryTime" | "showOverlay" | "copyToClipboard" + | "copyFilePath" + | "copyMarkdownImage" | "save" + | "saveToFolder" + | "revealInFinder" | "upload"; export type GeneralSettingsStore = TauriGeneralSettingsStore & { @@ -32,6 +38,7 @@ export function createDefaultGeneralSettings(): GeneralSettingsStore { autoZoomOnClicks: false, captureKeyboardEvents: true, postScreenshotCaptureBehaviour: "openEditor", + screenshotSaveDirectory: null, custom_cursor_capture2: true, excludedWindows: [], instantModeMaxResolution: 1920, diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 9ed90422c2f..fcc288b7468 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -521,7 +521,7 @@ export type ExportSettings = ({ format: "Mp4" } & Mp4ExportSettings) | ({ format export type FileType = "recording" | "screenshot" export type Flags = { captions: boolean } export type FramesRendered = { renderedCount: number; totalFrames: number; type: "FramesRendered" } -export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; postScreenshotCaptureBehaviour?: PostScreenshotCaptureBehaviour; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; captureKeyboardEvents?: boolean; postDeletionBehaviour?: PostDeletionBehaviour; excludedWindows?: WindowExclusion[]; deleteInstantRecordingsAfterUpload?: boolean; instantModeMaxResolution?: number; defaultProjectNameTemplate?: string | null; crashRecoveryRecording?: boolean; maxFps?: number; transcriptionHints?: string[]; editorPreviewQuality?: EditorPreviewQuality; studioRecordingQuality?: StudioRecordingQuality; mainWindowPosition?: WindowPosition | null; cameraWindowPosition?: WindowPosition | null; cameraWindowPositionsByMonitorName?: { [key in string]: WindowPosition }; hasCompletedOnboarding?: boolean; enableTelemetry?: boolean; outOfProcessMuxer?: boolean } +export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; postScreenshotCaptureBehaviour?: PostScreenshotCaptureBehaviour; screenshotSaveDirectory?: string | null; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; captureKeyboardEvents?: boolean; postDeletionBehaviour?: PostDeletionBehaviour; excludedWindows?: WindowExclusion[]; deleteInstantRecordingsAfterUpload?: boolean; instantModeMaxResolution?: number; defaultProjectNameTemplate?: string | null; crashRecoveryRecording?: boolean; maxFps?: number; transcriptionHints?: string[]; editorPreviewQuality?: EditorPreviewQuality; studioRecordingQuality?: StudioRecordingQuality; mainWindowPosition?: WindowPosition | null; cameraWindowPosition?: WindowPosition | null; cameraWindowPositionsByMonitorName?: { [key in string]: WindowPosition }; hasCompletedOnboarding?: boolean; enableTelemetry?: boolean; outOfProcessMuxer?: boolean } export type GifExportSettings = { fps: number; resolution_base: XY; quality: GifQuality | null } export type GifQuality = { /** @@ -579,7 +579,7 @@ export type PhysicalSize = { width: number; height: number } export type Plan = { upgraded: boolean; manual: boolean; last_checked: number } export type Platform = "MacOS" | "Windows" export type PostDeletionBehaviour = "doNothing" | "reopenRecordingWindow" -export type PostScreenshotCaptureBehaviour = "openEditor" | "showOverlay" | "copyToClipboard" | "save" | "upload" +export type PostScreenshotCaptureBehaviour = "openEditor" | "doNothing" | "askEveryTime" | "showOverlay" | "copyToClipboard" | "copyFilePath" | "copyMarkdownImage" | "save" | "saveToFolder" | "revealInFinder" | "upload" export type PostStudioRecordingBehaviour = "openEditor" | "showOverlay" export type Preset = { name: string; config: ProjectConfiguration } export type PresetsStore = { presets: Preset[]; default: number | null } From e5b649f11ddd7162dcc5606ad3dad1552d48fde0 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Sun, 7 Jun 2026 22:04:15 -0400 Subject: [PATCH 09/10] Align screenshot post-capture saving --- .../desktop/src-tauri/src/general_settings.rs | 111 ++++++++- apps/desktop/src-tauri/src/lib.rs | 2 +- .../src-tauri/src/screenshot_post_capture.rs | 228 +++++++++++++----- .../(window-chrome)/settings/general.tsx | 31 ++- .../src/utils/general-settings.test.ts | 23 ++ apps/desktop/src/utils/general-settings.ts | 23 +- apps/desktop/src/utils/tauri.ts | 9 +- 7 files changed, 357 insertions(+), 70 deletions(-) diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 6c59afefd42..bb7f1cb3d00 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -19,7 +19,7 @@ pub enum PostStudioRecordingBehaviour { ShowOverlay, } -#[derive(Default, Serialize, Deserialize, Type, Debug, Clone, Copy)] +#[derive(Default, Serialize, Deserialize, Type, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum PostScreenshotCaptureBehaviour { #[default] @@ -36,6 +36,15 @@ pub enum PostScreenshotCaptureBehaviour { Upload, } +#[derive(Default, Serialize, Deserialize, Type, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ScreenshotSaveDestination { + #[default] + Desktop, + ChosenFolder, + AppLibraryOnly, +} + #[derive(Default, Serialize, Deserialize, Type, Debug, Clone, Copy)] #[serde(rename_all = "camelCase")] pub enum MainWindowRecordingStartBehaviour { @@ -168,6 +177,8 @@ pub struct GeneralSettingsStore { #[serde(default)] pub post_screenshot_capture_behaviour: PostScreenshotCaptureBehaviour, #[serde(default)] + pub screenshot_save_destination: ScreenshotSaveDestination, + #[serde(default)] pub screenshot_save_directory: Option, #[serde(default)] pub main_window_recording_start_behaviour: MainWindowRecordingStartBehaviour, @@ -280,6 +291,7 @@ impl Default for GeneralSettingsStore { window_transparency: false, post_studio_recording_behaviour: PostStudioRecordingBehaviour::OpenEditor, post_screenshot_capture_behaviour: PostScreenshotCaptureBehaviour::OpenEditor, + screenshot_save_destination: ScreenshotSaveDestination::Desktop, screenshot_save_directory: None, main_window_recording_start_behaviour: MainWindowRecordingStartBehaviour::Close, custom_cursor_capture: true, @@ -322,8 +334,11 @@ impl GeneralSettingsStore { match app.store("store").map(|s| s.get("general_settings")) { Ok(Some(store)) => { // Handle potential deserialization errors gracefully - match serde_json::from_value(store) { - Ok(settings) => Ok(Some(settings)), + match serde_json::from_value::(store.clone()) { + Ok(mut settings) => { + settings.normalize_legacy_screenshot_settings(&store); + Ok(Some(settings)) + } Err(e) => Err(format!("Failed to deserialize general settings store: {e}")), } } @@ -331,6 +346,33 @@ impl GeneralSettingsStore { } } + fn normalize_legacy_screenshot_settings(&mut self, store: &serde_json::Value) { + let Some(post_capture_behaviour) = store + .get("postScreenshotCaptureBehaviour") + .and_then(|value| value.as_str()) + else { + return; + }; + + let has_save_destination = store.get("screenshotSaveDestination").is_some(); + + match post_capture_behaviour { + "save" => { + if !has_save_destination { + self.screenshot_save_destination = ScreenshotSaveDestination::Desktop; + } + self.post_screenshot_capture_behaviour = PostScreenshotCaptureBehaviour::DoNothing; + } + "saveToFolder" => { + if !has_save_destination { + self.screenshot_save_destination = ScreenshotSaveDestination::ChosenFolder; + } + self.post_screenshot_capture_behaviour = PostScreenshotCaptureBehaviour::DoNothing; + } + _ => {} + } + } + // i don't trust anyone to not overwrite the whole store lols pub fn update(app: &AppHandle, update: impl FnOnce(&mut Self)) -> Result<(), String> { let Ok(store) = app.store("store") else { @@ -451,3 +493,66 @@ fn bundled_muxer_bin_name() -> &'static str { pub fn get_default_excluded_windows() -> Vec { default_excluded_windows() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_legacy_screenshot_save_to_desktop_destination() { + let store = json!({ + "postScreenshotCaptureBehaviour": "save", + }); + let mut settings: GeneralSettingsStore = serde_json::from_value(store.clone()).unwrap(); + + settings.normalize_legacy_screenshot_settings(&store); + + assert_eq!( + settings.post_screenshot_capture_behaviour, + PostScreenshotCaptureBehaviour::DoNothing + ); + assert_eq!( + settings.screenshot_save_destination, + ScreenshotSaveDestination::Desktop + ); + } + + #[test] + fn normalizes_legacy_screenshot_save_to_folder_destination() { + let store = json!({ + "postScreenshotCaptureBehaviour": "saveToFolder", + }); + let mut settings: GeneralSettingsStore = serde_json::from_value(store.clone()).unwrap(); + + settings.normalize_legacy_screenshot_settings(&store); + + assert_eq!( + settings.post_screenshot_capture_behaviour, + PostScreenshotCaptureBehaviour::DoNothing + ); + assert_eq!( + settings.screenshot_save_destination, + ScreenshotSaveDestination::ChosenFolder + ); + } + + #[test] + fn legacy_screenshot_save_normalization_preserves_existing_destination() { + let store = json!({ + "postScreenshotCaptureBehaviour": "save", + "screenshotSaveDestination": "appLibraryOnly", + }); + let mut settings: GeneralSettingsStore = serde_json::from_value(store.clone()).unwrap(); + + settings.normalize_legacy_screenshot_settings(&store); + + assert_eq!( + settings.post_screenshot_capture_behaviour, + PostScreenshotCaptureBehaviour::DoNothing + ); + assert_eq!( + settings.screenshot_save_destination, + ScreenshotSaveDestination::AppLibraryOnly + ); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 933bb88a4ef..4b85aef9513 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -4009,6 +4009,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { recording::restart_recording, recording::delete_recording, recording::take_screenshot, + screenshot_post_capture::take_screenshot_with_post_capture, recording::list_cameras, recording::get_camera_formats, recording::get_microphone_info, @@ -4024,7 +4025,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { fake_window::remove_fake_window, focus_captures_panel, get_current_recording, - screenshot_post_capture::take_screenshot_with_post_capture, export::begin_export_session, export::end_export_session, export::export_video, diff --git a/apps/desktop/src-tauri/src/screenshot_post_capture.rs b/apps/desktop/src-tauri/src/screenshot_post_capture.rs index 239c7732c2a..93e6b93623d 100644 --- a/apps/desktop/src-tauri/src/screenshot_post_capture.rs +++ b/apps/desktop/src-tauri/src/screenshot_post_capture.rs @@ -18,7 +18,9 @@ use tokio::{sync::oneshot, time::sleep}; use crate::{ ArcLock, PendingScreenshot, PendingScreenshots, - general_settings::{GeneralSettingsStore, PostScreenshotCaptureBehaviour}, + general_settings::{ + GeneralSettingsStore, PostScreenshotCaptureBehaviour, ScreenshotSaveDestination, + }, notifications::{self, NotificationType}, windows::ShowCapWindow, }; @@ -34,6 +36,34 @@ struct PendingAction { created_at: Instant, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct SavedScreenshot { + path: PathBuf, + destination: ScreenshotSaveDestination, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ScreenshotPostCaptureContext { + source_path: PathBuf, + saved_screenshot: Option, +} + +impl ScreenshotPostCaptureContext { + fn action_target_path(&self) -> &Path { + self.saved_screenshot + .as_ref() + .map(|saved| saved.path.as_path()) + .unwrap_or(self.source_path.as_path()) + } + + fn saved_to(&self, destination: ScreenshotSaveDestination) -> Option { + self.saved_screenshot + .as_ref() + .filter(|saved| saved.destination == destination) + .map(|saved| saved.path.clone()) + } +} + #[derive(Default, Serialize, Deserialize, Type, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum ScreenshotPostCaptureAction { @@ -133,22 +163,31 @@ pub async fn handle( path: PathBuf, action: ScreenshotPostCaptureAction, ) -> Result<(), String> { + let context = prepare_screenshot_post_capture_context(app, path).await; let action = match action { ScreenshotPostCaptureAction::AskEveryTime => match prompt_post_capture_action(app).await? { Some(action) => action, - None => return Ok(()), + None => ScreenshotPostCaptureAction::DoNothing, }, action => action, }; + if matches!(action, ScreenshotPostCaptureAction::DoNothing) + && context.saved_screenshot.is_some() + { + notifications::send_notification(app, NotificationType::ScreenshotSaved); + } + match action { ScreenshotPostCaptureAction::DoNothing => Ok(()), ScreenshotPostCaptureAction::AskEveryTime => Ok(()), ScreenshotPostCaptureAction::OpenEditor => { - ShowCapWindow::ScreenshotEditor { path } - .show(app) - .await - .map_err(|e| e.to_string())?; + ShowCapWindow::ScreenshotEditor { + path: context.source_path, + } + .show(app) + .await + .map_err(|e| e.to_string())?; Ok(()) } ScreenshotPostCaptureAction::ShowOverlay => { @@ -159,45 +198,58 @@ pub async fn handle( Ok(()) } ScreenshotPostCaptureAction::CopyToClipboard => { - copy_screenshot_to_clipboard(app, &path).await?; + copy_screenshot_to_clipboard(app, &context.source_path).await?; notifications::send_notification(app, NotificationType::ScreenshotCopiedToClipboard); Ok(()) } ScreenshotPostCaptureAction::CopyFilePath => { - wait_for_screenshot_image(path.as_path()).await?; - copy_text_to_clipboard(app, path.to_string_lossy().to_string()).await?; + let target_path = context.action_target_path(); + wait_for_screenshot_image(target_path).await?; + copy_text_to_clipboard(app, target_path.to_string_lossy().to_string()).await?; notifications::send_notification(app, NotificationType::ScreenshotCopiedToClipboard); Ok(()) } ScreenshotPostCaptureAction::CopyMarkdownImage => { - wait_for_screenshot_image(path.as_path()).await?; - copy_text_to_clipboard(app, markdown_image_for_path(path.as_path())?).await?; + let target_path = context.action_target_path(); + wait_for_screenshot_image(target_path).await?; + copy_text_to_clipboard(app, markdown_image_for_path(target_path)?).await?; notifications::send_notification(app, NotificationType::ScreenshotCopiedToClipboard); Ok(()) } ScreenshotPostCaptureAction::Save => { - save_screenshot_image_file(app, &path).await?; + if context + .saved_to(ScreenshotSaveDestination::Desktop) + .is_none() + { + save_screenshot_image_file(app, &context.source_path).await?; + } notifications::send_notification(app, NotificationType::ScreenshotSaved); Ok(()) } ScreenshotPostCaptureAction::SaveToFolder => { - if save_screenshot_image_file_to_configured_directory(app, &path) - .await? - .is_some() - { + let saved_path = match context.saved_to(ScreenshotSaveDestination::ChosenFolder) { + Some(path) => Some(path), + None => { + save_screenshot_image_file_to_configured_directory(app, &context.source_path) + .await? + } + }; + + if saved_path.is_some() { notifications::send_notification(app, NotificationType::ScreenshotSaved); } Ok(()) } ScreenshotPostCaptureAction::RevealInFinder => { - wait_for_screenshot_image(path.as_path()).await?; + let target_path = context.action_target_path().to_path_buf(); + wait_for_screenshot_image(target_path.as_path()).await?; app.opener() - .reveal_item_in_dir(path) + .reveal_item_in_dir(target_path) .map_err(|err| format!("Failed to reveal screenshot in Finder: {err}"))?; Ok(()) } ScreenshotPostCaptureAction::Upload => { - match crate::upload_screenshot_internal(app, path).await? { + match crate::upload_screenshot_internal(app, context.source_path).await? { crate::UploadResult::Success(_) => Ok(()), crate::UploadResult::NotAuthenticated => Ok(()), crate::UploadResult::UpgradeRequired => Ok(()), @@ -210,6 +262,25 @@ pub async fn handle( } } +async fn prepare_screenshot_post_capture_context( + app: &AppHandle, + path: PathBuf, +) -> ScreenshotPostCaptureContext { + let saved_screenshot = match save_screenshot_for_configured_destination(app, &path).await { + Ok(saved_screenshot) => saved_screenshot, + Err(err) => { + tracing::warn!(error = %err, "Failed to auto-save screenshot after capture"); + notifications::send_notification(app, NotificationType::ScreenshotSaveFailed); + None + } + }; + + ScreenshotPostCaptureContext { + source_path: path, + saved_screenshot, + } +} + #[tauri::command(async)] #[specta::specta] #[tracing::instrument(skip(app))] @@ -236,67 +307,51 @@ async fn prompt_post_capture_action( match choose_post_capture_button( app, "After Screenshot", - "Choose what Cap should do with this screenshot.", + "Choose what Cap should do next.", "Open editor", + "Copy image", "More actions", - "Do nothing", ) .await? { DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::OpenEditor)), - DialogButton::Third => Ok(None), - DialogButton::Second => match choose_post_capture_button( + DialogButton::Second => Ok(Some(ScreenshotPostCaptureAction::CopyToClipboard)), + DialogButton::Third => match choose_post_capture_button( app, "After Screenshot", - "Choose what Cap should do with this screenshot.", - "Copy image", - "Save Desktop", + "Choose what Cap should do next.", + "Upload link", + "Show overlay", "More actions", ) .await? { - DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::CopyToClipboard)), - DialogButton::Second => Ok(Some(ScreenshotPostCaptureAction::Save)), + DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::Upload)), + DialogButton::Second => Ok(Some(ScreenshotPostCaptureAction::ShowOverlay)), DialogButton::Third => match choose_post_capture_button( app, "After Screenshot", - "Choose what Cap should do with this screenshot.", - "Save folder", - "Upload link", + "Choose what Cap should do next.", + "Reveal in Finder", + "Copy path", "More actions", ) .await? { - DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::SaveToFolder)), - DialogButton::Second => Ok(Some(ScreenshotPostCaptureAction::Upload)), + DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::RevealInFinder)), + DialogButton::Second => Ok(Some(ScreenshotPostCaptureAction::CopyFilePath)), DialogButton::Third => match choose_post_capture_button( app, "After Screenshot", - "Choose what Cap should do with this screenshot.", - "Show overlay", - "Reveal in Finder", - "More actions", + "Choose what Cap should do next.", + "Copy Markdown", + "Do nothing", + "Cancel", ) .await? { - DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::ShowOverlay)), - DialogButton::Second => Ok(Some(ScreenshotPostCaptureAction::RevealInFinder)), - DialogButton::Third => match choose_post_capture_button( - app, - "After Screenshot", - "Choose what Cap should do with this screenshot.", - "Copy path", - "Copy Markdown", - "Do nothing", - ) - .await? - { - DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::CopyFilePath)), - DialogButton::Second => { - Ok(Some(ScreenshotPostCaptureAction::CopyMarkdownImage)) - } - DialogButton::Third => Ok(None), - }, + DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::CopyMarkdownImage)), + DialogButton::Second | DialogButton::Third => Ok(None), }, }, }, @@ -466,6 +521,33 @@ fn markdown_image_for_path(path: &Path) -> Result { Ok(format!("![Screenshot](<{}>)", url.as_str())) } +async fn save_screenshot_for_configured_destination( + app: &AppHandle, + path: &Path, +) -> Result, String> { + let destination = configured_screenshot_save_destination(app); + + match destination { + ScreenshotSaveDestination::Desktop => save_screenshot_image_file(app, path) + .await + .map(|path| Some(SavedScreenshot { path, destination })), + ScreenshotSaveDestination::ChosenFolder => { + save_screenshot_image_file_to_configured_directory(app, path) + .await + .map(|path| path.map(|path| SavedScreenshot { path, destination })) + } + ScreenshotSaveDestination::AppLibraryOnly => Ok(None), + } +} + +fn configured_screenshot_save_destination(app: &AppHandle) -> ScreenshotSaveDestination { + GeneralSettingsStore::get(app) + .ok() + .flatten() + .map(|settings| settings.screenshot_save_destination) + .unwrap_or_default() +} + async fn save_screenshot_image_file(app: &AppHandle, path: &Path) -> Result { let desktop_dir = dirs::desktop_dir() .ok_or_else(|| "Failed to resolve Desktop directory for screenshot export".to_string())?; @@ -819,6 +901,42 @@ mod tests { assert!(markdown.ends_with(">)")); } + #[test] + fn post_capture_context_prefers_saved_path_for_file_actions() { + let temp_dir = tempfile::tempdir().unwrap(); + let source_path = temp_dir.path().join("Launch Clip.cap").join("original.png"); + let saved_path = temp_dir.path().join("Desktop").join("Launch Clip.png"); + let context = ScreenshotPostCaptureContext { + source_path: source_path.clone(), + saved_screenshot: Some(SavedScreenshot { + path: saved_path.clone(), + destination: ScreenshotSaveDestination::Desktop, + }), + }; + + assert_eq!(context.action_target_path(), saved_path.as_path()); + assert_eq!( + context.saved_to(ScreenshotSaveDestination::Desktop), + Some(saved_path) + ); + assert_eq!( + context.saved_to(ScreenshotSaveDestination::ChosenFolder), + None + ); + } + + #[test] + fn post_capture_context_falls_back_to_source_path_without_export() { + let temp_dir = tempfile::tempdir().unwrap(); + let source_path = temp_dir.path().join("Launch Clip.cap").join("original.png"); + let context = ScreenshotPostCaptureContext { + source_path: source_path.clone(), + saved_screenshot: None, + }; + + assert_eq!(context.action_target_path(), source_path.as_path()); + } + #[tokio::test] async fn save_screenshot_image_file_to_dir_copies_png_with_project_name() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 08c0a12b51a..6d6c41d561f 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -30,6 +30,7 @@ import { deriveGeneralSettings, type GeneralSettingsStore, type PostScreenshotCaptureBehaviour, + type ScreenshotSaveDestination, } from "~/utils/general-settings"; import { type AppTheme, @@ -269,6 +270,16 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { await handleChange("screenshotSaveDirectory", selected); }; + const handleScreenshotSaveDestinationChange = async ( + value: ScreenshotSaveDestination, + ) => { + await handleChange("screenshotSaveDestination", value); + + if (value === "chosenFolder" && !settings.screenshotSaveDirectory) { + await handleChooseScreenshotSaveDirectory(); + } + }; + const ostype: OsType = type(); const excludedWindows = createMemo(() => settings.excludedWindows ?? []); @@ -377,6 +388,7 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { | MainWindowRecordingStartBehaviour | PostStudioRecordingBehaviour | PostScreenshotCaptureBehaviour + | ScreenshotSaveDestination | PostDeletionBehaviour | StudioRecordingQuality | number, @@ -532,19 +544,26 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { { text: "Copy image to clipboard", value: "copyToClipboard" }, { text: "Copy file path", value: "copyFilePath" }, { text: "Copy Markdown image", value: "copyMarkdownImage" }, - { text: "Save PNG to Desktop", value: "save" }, - { text: "Save PNG to folder", value: "saveToFolder" }, { text: "Reveal in Finder", value: "revealInFinder" }, { text: "Upload link", value: "upload" }, { text: "Do nothing", value: "doNothing" }, ]} /> - + +
{ captureKeyboardEvents: true, custom_cursor_capture2: true, postScreenshotCaptureBehaviour: "openEditor", + screenshotSaveDestination: "desktop", screenshotSaveDirectory: null, }); }); + it("normalizes legacy screenshot save actions into save destinations", () => { + expect( + deriveGeneralSettings({ + enableNativeCameraPreview: false, + postScreenshotCaptureBehaviour: "save", + }), + ).toMatchObject({ + postScreenshotCaptureBehaviour: "doNothing", + screenshotSaveDestination: "desktop", + }); + + expect( + deriveGeneralSettings({ + enableNativeCameraPreview: false, + postScreenshotCaptureBehaviour: "saveToFolder", + }), + ).toMatchObject({ + postScreenshotCaptureBehaviour: "doNothing", + screenshotSaveDestination: "chosenFolder", + }); + }); + it("preserves explicit disabled recording enhancements", () => { expect( deriveGeneralSettings({ diff --git a/apps/desktop/src/utils/general-settings.ts b/apps/desktop/src/utils/general-settings.ts index d6c59fdb6aa..9863b7781a0 100644 --- a/apps/desktop/src/utils/general-settings.ts +++ b/apps/desktop/src/utils/general-settings.ts @@ -13,9 +13,15 @@ export type PostScreenshotCaptureBehaviour = | "revealInFinder" | "upload"; +export type ScreenshotSaveDestination = + | "desktop" + | "chosenFolder" + | "appLibraryOnly"; + export type GeneralSettingsStore = TauriGeneralSettingsStore & { captureKeyboardEvents?: boolean; postScreenshotCaptureBehaviour?: PostScreenshotCaptureBehaviour; + screenshotSaveDestination?: ScreenshotSaveDestination; transcriptionHints?: string[]; enableTelemetry?: boolean; outOfProcessMuxer?: boolean; @@ -38,6 +44,7 @@ export function createDefaultGeneralSettings(): GeneralSettingsStore { autoZoomOnClicks: false, captureKeyboardEvents: true, postScreenshotCaptureBehaviour: "openEditor", + screenshotSaveDestination: "desktop", screenshotSaveDirectory: null, custom_cursor_capture2: true, excludedWindows: [], @@ -52,10 +59,24 @@ export function createDefaultGeneralSettings(): GeneralSettingsStore { export function deriveGeneralSettings( store: GeneralSettingsStore | null | undefined, ): GeneralSettingsStore { - return { + const settings = { ...createDefaultGeneralSettings(), ...(store ?? {}), }; + + if (settings.postScreenshotCaptureBehaviour === "save") { + settings.postScreenshotCaptureBehaviour = "doNothing"; + settings.screenshotSaveDestination = + store?.screenshotSaveDestination ?? "desktop"; + } + + if (settings.postScreenshotCaptureBehaviour === "saveToFolder") { + settings.postScreenshotCaptureBehaviour = "doNothing"; + settings.screenshotSaveDestination = + store?.screenshotSaveDestination ?? "chosenFolder"; + } + + return settings; } export function normalizeTranscriptionHints( diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index fcc288b7468..132961d32b7 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -47,6 +47,9 @@ async deleteRecording() : Promise { async takeScreenshot(target: ScreenCaptureTarget) : Promise { return await TAURI_INVOKE("take_screenshot", { target }); }, +async takeScreenshotWithPostCapture(target: ScreenCaptureTarget) : Promise { + return await TAURI_INVOKE("take_screenshot_with_post_capture", { target }); +}, async listCameras() : Promise { return await TAURI_INVOKE("list_cameras"); }, @@ -92,9 +95,6 @@ async focusCapturesPanel() : Promise { async getCurrentRecording() : Promise> { return await TAURI_INVOKE("get_current_recording"); }, -async takeScreenshotWithPostCapture(target: ScreenCaptureTarget) : Promise { - return await TAURI_INVOKE("take_screenshot_with_post_capture", { target }); -}, async beginExportSession() : Promise { await TAURI_INVOKE("begin_export_session"); }, @@ -521,7 +521,7 @@ export type ExportSettings = ({ format: "Mp4" } & Mp4ExportSettings) | ({ format export type FileType = "recording" | "screenshot" export type Flags = { captions: boolean } export type FramesRendered = { renderedCount: number; totalFrames: number; type: "FramesRendered" } -export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; postScreenshotCaptureBehaviour?: PostScreenshotCaptureBehaviour; screenshotSaveDirectory?: string | null; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; captureKeyboardEvents?: boolean; postDeletionBehaviour?: PostDeletionBehaviour; excludedWindows?: WindowExclusion[]; deleteInstantRecordingsAfterUpload?: boolean; instantModeMaxResolution?: number; defaultProjectNameTemplate?: string | null; crashRecoveryRecording?: boolean; maxFps?: number; transcriptionHints?: string[]; editorPreviewQuality?: EditorPreviewQuality; studioRecordingQuality?: StudioRecordingQuality; mainWindowPosition?: WindowPosition | null; cameraWindowPosition?: WindowPosition | null; cameraWindowPositionsByMonitorName?: { [key in string]: WindowPosition }; hasCompletedOnboarding?: boolean; enableTelemetry?: boolean; outOfProcessMuxer?: boolean } +export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; postScreenshotCaptureBehaviour?: PostScreenshotCaptureBehaviour; screenshotSaveDestination?: ScreenshotSaveDestination; screenshotSaveDirectory?: string | null; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; captureKeyboardEvents?: boolean; postDeletionBehaviour?: PostDeletionBehaviour; excludedWindows?: WindowExclusion[]; deleteInstantRecordingsAfterUpload?: boolean; instantModeMaxResolution?: number; defaultProjectNameTemplate?: string | null; crashRecoveryRecording?: boolean; maxFps?: number; transcriptionHints?: string[]; editorPreviewQuality?: EditorPreviewQuality; studioRecordingQuality?: StudioRecordingQuality; mainWindowPosition?: WindowPosition | null; cameraWindowPosition?: WindowPosition | null; cameraWindowPositionsByMonitorName?: { [key in string]: WindowPosition }; hasCompletedOnboarding?: boolean; enableTelemetry?: boolean; outOfProcessMuxer?: boolean } export type GifExportSettings = { fps: number; resolution_base: XY; quality: GifQuality | null } export type GifQuality = { /** @@ -613,6 +613,7 @@ export type ScreenMovementSpring = { stiffness: number; damping: number; mass: n export type ScreenshotOcrLine = { text: string; confidence: number | null; bounds: ScreenshotOcrRegion } export type ScreenshotOcrRegion = { x: number; y: number; width: number; height: number } export type ScreenshotOcrResult = { text: string; lines: ScreenshotOcrLine[]; engine: string } +export type ScreenshotSaveDestination = "desktop" | "chosenFolder" | "appLibraryOnly" export type SegmentRecordings = { display: Video; camera: Video | null; mic: Audio | null; system_audio: Audio | null } export type SerializedEditorInstance = { framesSocketUrl: string; recordingDuration: number; savedProjectConfig: ProjectConfiguration; recordings: ProjectRecordingsMeta; path: string } export type SerializedScreenshotEditorInstance = { framesSocketUrl: string; path: string; config: ProjectConfiguration | null; prettyName: string; imageWidth: number; imageHeight: number } From 6396ee547f59a7394e2dcb7e806ebf5c954dcb71 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Tue, 9 Jun 2026 08:05:49 -0400 Subject: [PATCH 10/10] Add screenshot editor copy close setting --- apps/desktop/src-tauri/src/general_settings.rs | 3 +++ .../src/routes/(window-chrome)/settings/general.tsx | 8 ++++++++ .../src/routes/screenshot-editor/useScreenshotExport.ts | 7 +++++++ apps/desktop/src/utils/general-settings.test.ts | 1 + apps/desktop/src/utils/general-settings.ts | 2 ++ 5 files changed, 21 insertions(+) diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 40a38dae1e7..328e2c4d5d5 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -199,6 +199,8 @@ pub struct GeneralSettingsStore { #[serde(default)] pub screenshot_save_directory: Option, #[serde(default)] + pub close_screenshot_editor_after_copy: bool, + #[serde(default)] pub main_window_recording_start_behaviour: MainWindowRecordingStartBehaviour, #[serde( default = "default_custom_cursor_capture", @@ -326,6 +328,7 @@ impl Default for GeneralSettingsStore { post_screenshot_capture_behaviour: PostScreenshotCaptureBehaviour::OpenEditor, screenshot_save_destination: ScreenshotSaveDestination::Desktop, screenshot_save_directory: None, + close_screenshot_editor_after_copy: false, main_window_recording_start_behaviour: MainWindowRecordingStartBehaviour::Close, custom_cursor_capture: cap_recording::DEFAULT_CUSTOM_CURSOR_CAPTURE, server_url: default_server_url(), diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 6694362a120..b152b694539 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -677,6 +677,14 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) {
+ + handleChange("closeScreenshotEditorAfterCopy", value) + } + /> { postScreenshotCaptureBehaviour: "openEditor", screenshotSaveDestination: "desktop", screenshotSaveDirectory: null, + closeScreenshotEditorAfterCopy: false, }); }); diff --git a/apps/desktop/src/utils/general-settings.ts b/apps/desktop/src/utils/general-settings.ts index 9863b7781a0..9e6d0e66bb6 100644 --- a/apps/desktop/src/utils/general-settings.ts +++ b/apps/desktop/src/utils/general-settings.ts @@ -22,6 +22,7 @@ export type GeneralSettingsStore = TauriGeneralSettingsStore & { captureKeyboardEvents?: boolean; postScreenshotCaptureBehaviour?: PostScreenshotCaptureBehaviour; screenshotSaveDestination?: ScreenshotSaveDestination; + closeScreenshotEditorAfterCopy?: boolean; transcriptionHints?: string[]; enableTelemetry?: boolean; outOfProcessMuxer?: boolean; @@ -46,6 +47,7 @@ export function createDefaultGeneralSettings(): GeneralSettingsStore { postScreenshotCaptureBehaviour: "openEditor", screenshotSaveDestination: "desktop", screenshotSaveDirectory: null, + closeScreenshotEditorAfterCopy: false, custom_cursor_capture2: true, excludedWindows: [], instantModeMaxResolution: 1920,