diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a1170284877..acd7ba5435b 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -4,9 +4,59 @@ 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, recording::StartRecordingInputs, windows::ShowCapWindow}; +use crate::{ + App, ArcLock, + recording::StartRecordingInputs, + recording_settings::{RecordingSettingsStore, RecordingTargetMode}, + screenshot_post_capture::{self, ScreenshotPostCaptureAction}, + tray, + 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")] @@ -15,6 +65,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 +90,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 +133,7 @@ pub fn handle(app_handle: &AppHandle, urls: Vec) { }); } +#[derive(Debug)] pub enum ActionParseFromUrlError { ParseFailed(String), Invalid, @@ -89,9 +153,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 +218,177 @@ 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); + } + + 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(()); + } + }; + + 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 +} + +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::*; + + #[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) + )); + } + + #[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/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 2cf259e2c8e..328e2c4d5d5 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}; @@ -19,6 +19,32 @@ pub enum PostStudioRecordingBehaviour { ShowOverlay, } +#[derive(Default, Serialize, Deserialize, Type, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum PostScreenshotCaptureBehaviour { + #[default] + OpenEditor, + DoNothing, + AskEveryTime, + ShowOverlay, + CopyToClipboard, + CopyFilePath, + CopyMarkdownImage, + Save, + SaveToFolder, + RevealInFinder, + 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 { @@ -167,6 +193,14 @@ pub struct GeneralSettingsStore { #[serde(default)] pub post_studio_recording_behaviour: PostStudioRecordingBehaviour, #[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 close_screenshot_editor_after_copy: bool, + #[serde(default)] pub main_window_recording_start_behaviour: MainWindowRecordingStartBehaviour, #[serde( default = "default_custom_cursor_capture", @@ -291,6 +325,10 @@ impl Default for GeneralSettingsStore { last_version: None, window_transparency: false, post_studio_recording_behaviour: PostStudioRecordingBehaviour::OpenEditor, + 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(), @@ -332,8 +370,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}")), } } @@ -341,6 +382,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 { @@ -467,6 +535,64 @@ pub fn get_default_excluded_windows() -> Vec { 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 + ); + } + fn title_exclusion(title: &str) -> WindowExclusion { WindowExclusion { bundle_identifier: None, diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs index eccd9e700bf..177949695d2 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -1,6 +1,7 @@ use crate::{ RequestOpenRecordingPicker, RequestStartRecording, recording, recording_settings::{RecordingSettingsStore, RecordingTargetMode}, + screenshot_post_capture::{self, ScreenshotPostCaptureAction}, tray, windows::ShowCapWindow, }; @@ -209,31 +210,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 78914f8ce2c..b8e2d9783b3 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -33,6 +33,7 @@ mod recording_settings; mod recording_telemetry; mod recovery; mod screenshot_editor; +mod screenshot_post_capture; mod target_select_overlay; mod thumbnails; mod tray; @@ -3273,6 +3274,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())?; @@ -3309,13 +3318,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))] @@ -4179,6 +4203,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::import_current_desktop_background, recording::list_cameras, recording::get_camera_formats, @@ -4481,6 +4506,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)] @@ -5415,6 +5441,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 new file mode 100644 index 00000000000..93e6b93623d --- /dev/null +++ b/apps/desktop/src-tauri/src/screenshot_post_capture.rs @@ -0,0 +1,1023 @@ +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Mutex, PoisonError}, + time::{Duration, Instant}, +}; + +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, 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, + general_settings::{ + GeneralSettingsStore, PostScreenshotCaptureBehaviour, ScreenshotSaveDestination, + }, + 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(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 { + #[default] + OpenEditor, + DoNothing, + AskEveryTime, + ShowOverlay, + CopyToClipboard, + CopyFilePath, + CopyMarkdownImage, + Save, + SaveToFolder, + RevealInFinder, + Upload, +} + +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, + } + } +} + +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 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_or_else(PoisonError::into_inner); + *pending = Some(PendingAction { + action, + created_at: Instant::now(), + }); + } + + pub fn take(&self) -> Option { + let mut pending = self.0.lock().unwrap_or_else(PoisonError::into_inner); + 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_or_else(PoisonError::into_inner); + *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( + app: &AppHandle, + 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 => 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: context.source_path, + } + .show(app) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + ScreenshotPostCaptureAction::ShowOverlay => { + ShowCapWindow::RecordingsOverlay + .show(app) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + ScreenshotPostCaptureAction::CopyToClipboard => { + copy_screenshot_to_clipboard(app, &context.source_path).await?; + notifications::send_notification(app, NotificationType::ScreenshotCopiedToClipboard); + Ok(()) + } + ScreenshotPostCaptureAction::CopyFilePath => { + 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 => { + 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 => { + 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 => { + 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 => { + 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(target_path) + .map_err(|err| format!("Failed to reveal screenshot in Finder: {err}"))?; + Ok(()) + } + ScreenshotPostCaptureAction::Upload => { + match crate::upload_screenshot_internal(app, context.source_path).await? { + crate::UploadResult::Success(_) => Ok(()), + crate::UploadResult::NotAuthenticated => Ok(()), + crate::UploadResult::UpgradeRequired => Ok(()), + crate::UploadResult::PlanCheckFailed => { + notifications::send_notification(app, NotificationType::ShareableLinkFailed); + Ok(()) + } + } + } + } +} + +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))] +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) +} + +#[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 next.", + "Open editor", + "Copy image", + "More actions", + ) + .await? + { + DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::OpenEditor)), + DialogButton::Second => Ok(Some(ScreenshotPostCaptureAction::CopyToClipboard)), + DialogButton::Third => match choose_post_capture_button( + app, + "After Screenshot", + "Choose what Cap should do next.", + "Upload link", + "Show overlay", + "More actions", + ) + .await? + { + 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 next.", + "Reveal in Finder", + "Copy path", + "More actions", + ) + .await? + { + 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 next.", + "Copy Markdown", + "Do nothing", + "Cancel", + ) + .await? + { + DialogButton::First => Ok(Some(ScreenshotPostCaptureAction::CopyMarkdownImage)), + DialogButton::Second | 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::()?; + 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 { + 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 + .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!("Screenshot image was not ready: {e}")); + } + sleep(Duration::from_millis(50)).await; + } + } + } +} + +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 + .map_err(|err| format!("Failed to copy screenshot to clipboard: {err}"))? + }; + + app.state::>() + .write() + .await + .set_image(img_data) + .map_err(|err| format!("Failed to copy screenshot to clipboard: {err}")) +} + +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_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())?; + 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.clone()).await?; + return Ok(target_path); + } + + 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 { + 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()) + .or_else(|| path.file_stem()) + .and_then(|stem| stem.to_str()) + .unwrap_or("Screenshot"); + + 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(()), + 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() + )); + } + } + } +} + +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(); + + 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")), + } + } + + #[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]()")); + } + + #[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(); + 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(); + 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!(image::open(saved_path).unwrap().dimensions(), (2, 1)); + } + + #[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(); + 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) + .await + .unwrap(); + + assert_eq!(saved_path.file_name().unwrap(), "Launch Clip (1).png"); + 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)); + } +} diff --git a/apps/desktop/src-tauri/src/target_select_overlay.rs b/apps/desktop/src-tauri/src/target_select_overlay.rs index 82eac1701f0..6f6966ef057 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,9 @@ pub async fn close_target_select_overlays( state.destroy(&display_id, app.global_shortcut()); } + screenshot_post_capture::clear_pending_action(&app); + crate::deeplink_actions::restore_temporary_recording_mode(&app).await; + Ok(()) } 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 65340727935..b152b694539 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, @@ -31,6 +31,8 @@ import { clientEnv } from "~/utils/env"; import { deriveGeneralSettings, type GeneralSettingsStore, + type PostScreenshotCaptureBehaviour, + type ScreenshotSaveDestination, } from "~/utils/general-settings"; import { type AppTheme, @@ -44,6 +46,7 @@ import { type WindowExclusion, } from "~/utils/tauri"; import IconLucideAlertTriangle from "~icons/lucide/alert-triangle"; +import IconLucideFolderOpen from "~icons/lucide/folder-open"; import IconLucidePlus from "~icons/lucide/plus"; import IconLucideX from "~icons/lucide/x"; import { @@ -309,6 +312,28 @@ 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 handleScreenshotSaveDestinationChange = async ( + value: ScreenshotSaveDestination, + ) => { + await handleChange("screenshotSaveDestination", value); + + if (value === "chosenFolder" && !settings.screenshotSaveDirectory) { + await handleChooseScreenshotSaveDirectory(); + } + }; + const ostype: OsType = type(); const excludedWindows = createMemo(() => settings.excludedWindows ?? []); const missingDefaultExclusions = createMemo(() => @@ -424,6 +449,8 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { T extends | MainWindowRecordingStartBehaviour | PostStudioRecordingBehaviour + | PostScreenshotCaptureBehaviour + | ScreenshotSaveDestination | PostDeletionBehaviour | StudioRecordingQuality | number, @@ -573,6 +600,91 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { { text: "Show in overlay", value: "showOverlay" }, ]} /> + + handleChange("postScreenshotCaptureBehaviour", value) + } + options={[ + { text: "Ask every time", value: "askEveryTime" }, + { text: "Open editor", value: "openEditor" }, + { text: "Show in overlay", value: "showOverlay" }, + { text: "Copy image to clipboard", value: "copyToClipboard" }, + { text: "Copy file path", value: "copyFilePath" }, + { text: "Copy Markdown image", value: "copyMarkdownImage" }, + { text: "Reveal in Finder", value: "revealInFinder" }, + { text: "Upload link", value: "upload" }, + { text: "Do nothing", value: "doNothing" }, + ]} + /> + + + +
+ + Not set + + } + > + {(directory) => ( + + {directory()} + + )} + + + + + +
+
+
+ + handleChange("closeScreenshotEditorAfterCopy", value) + } + /> ("take_screenshot_with_post_capture", { target }); + await commands.closeTargetSelectOverlays(); +} + const capitalize = (str: string) => { return str.charAt(0).toUpperCase() + str.slice(1); }; @@ -1068,11 +1073,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}`); @@ -1823,11 +1824,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}`); diff --git a/apps/desktop/src/utils/general-settings.test.ts b/apps/desktop/src/utils/general-settings.test.ts index d8ae6c01c38..89db0236702 100644 --- a/apps/desktop/src/utils/general-settings.test.ts +++ b/apps/desktop/src/utils/general-settings.test.ts @@ -42,6 +42,32 @@ describe("general-settings", () => { autoZoomOnClicks: false, captureKeyboardEvents: true, custom_cursor_capture2: true, + postScreenshotCaptureBehaviour: "openEditor", + screenshotSaveDestination: "desktop", + screenshotSaveDirectory: null, + closeScreenshotEditorAfterCopy: false, + }); + }); + + 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", }); }); diff --git a/apps/desktop/src/utils/general-settings.ts b/apps/desktop/src/utils/general-settings.ts index 4479462cc3d..9e6d0e66bb6 100644 --- a/apps/desktop/src/utils/general-settings.ts +++ b/apps/desktop/src/utils/general-settings.ts @@ -1,7 +1,28 @@ import type { GeneralSettingsStore as TauriGeneralSettingsStore } from "~/utils/tauri"; +export type PostScreenshotCaptureBehaviour = + | "openEditor" + | "doNothing" + | "askEveryTime" + | "showOverlay" + | "copyToClipboard" + | "copyFilePath" + | "copyMarkdownImage" + | "save" + | "saveToFolder" + | "revealInFinder" + | "upload"; + +export type ScreenshotSaveDestination = + | "desktop" + | "chosenFolder" + | "appLibraryOnly"; + export type GeneralSettingsStore = TauriGeneralSettingsStore & { captureKeyboardEvents?: boolean; + postScreenshotCaptureBehaviour?: PostScreenshotCaptureBehaviour; + screenshotSaveDestination?: ScreenshotSaveDestination; + closeScreenshotEditorAfterCopy?: boolean; transcriptionHints?: string[]; enableTelemetry?: boolean; outOfProcessMuxer?: boolean; @@ -23,6 +44,10 @@ export function createDefaultGeneralSettings(): GeneralSettingsStore { enableNativeCameraPreview: false, autoZoomOnClicks: false, captureKeyboardEvents: true, + postScreenshotCaptureBehaviour: "openEditor", + screenshotSaveDestination: "desktop", + screenshotSaveDirectory: null, + closeScreenshotEditorAfterCopy: false, custom_cursor_capture2: true, excludedWindows: [], instantModeMaxResolution: 1920, @@ -36,10 +61,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 2160787f708..c87a2448c7b 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -56,6 +56,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 importCurrentDesktopBackground(projectPath: string) : Promise { return await TAURI_INVOKE("import_current_desktop_background", { projectPath }); }, @@ -568,7 +571,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; 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 = { /** @@ -626,6 +629,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" | "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 } @@ -659,6 +663,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 }