diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a1170284877..470921b6ee4 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, + hotkeys::{self, HotkeyAction}, + recording::StartRecordingInputs, + recording_settings::{RecordingSettingsStore, RecordingTargetMode}, + 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)] +#[serde(rename_all = "snake_case")] +pub enum ScreenshotTarget { + CurrentDisplay, + CurrentWindow, + Area, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DeepLinkAction { @@ -25,13 +83,44 @@ pub enum DeepLinkAction { capture_system_audio: bool, mode: RecordingMode, }, + StartRecordingWithCurrentSettings { + mode: RecordingMode, + #[serde(default)] + mic_label: Option, + #[serde(default)] + use_mic: Option, + #[serde(default)] + camera_label: Option, + #[serde(default)] + use_camera: Option, + #[serde(default)] + capture_system_audio: Option, + }, + RunHotkeyAction { + action: HotkeyAction, + }, StopRecording, + RestartRecording, + TogglePauseRecording, + CycleRecordingMode, + SetRecordingMode { + mode: RecordingMode, + }, + OpenMain, + OpenRecordingPicker { + target_mode: Option, + }, OpenEditor { project_path: PathBuf, }, OpenSettings { page: Option, }, + OpenRecordings, + OpenScreenshots, + TakeScreenshot { + target: ScreenshotTarget, + }, } pub fn handle(app_handle: &AppHandle, urls: Vec) { @@ -70,6 +159,7 @@ pub fn handle(app_handle: &AppHandle, urls: Vec) { }); } +#[derive(Debug)] pub enum ActionParseFromUrlError { ParseFailed(String), Invalid, @@ -89,9 +179,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() @@ -105,6 +196,135 @@ impl TryFrom<&Url> for DeepLinkAction { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_action_deeplink() { + let mut url = Url::parse("cap-desktop://action").unwrap(); + url.query_pairs_mut().append_pair( + "value", + &serde_json::to_string(&DeepLinkAction::RunHotkeyAction { + action: HotkeyAction::ScreenshotArea, + }) + .unwrap(), + ); + + let action = DeepLinkAction::try_from(&url).unwrap(); + + match action { + DeepLinkAction::RunHotkeyAction { action } => { + assert_eq!(action, HotkeyAction::ScreenshotArea); + } + _ => panic!("expected RunHotkeyAction"), + } + } + + #[test] + fn ignores_non_action_deeplink() { + let url = Url::parse("cap-desktop://login?value=ignored").unwrap(); + + assert!(matches!( + DeepLinkAction::try_from(&url), + Err(ActionParseFromUrlError::NotAction) + )); + } + + #[test] + fn parses_current_settings_recording_without_overrides() { + let mut url = Url::parse("cap-desktop://action").unwrap(); + url.query_pairs_mut().append_pair( + "value", + r#"{"start_recording_with_current_settings":{"mode":"studio"}}"#, + ); + + let action = DeepLinkAction::try_from(&url).unwrap(); + + match action { + DeepLinkAction::StartRecordingWithCurrentSettings { + mode, + mic_label, + use_mic, + camera_label, + use_camera, + capture_system_audio, + } => { + assert_eq!(mode, RecordingMode::Studio); + assert_eq!(mic_label, None); + assert_eq!(use_mic, None); + assert_eq!(camera_label, None); + assert_eq!(use_camera, None); + assert_eq!(capture_system_audio, None); + } + _ => panic!("expected StartRecordingWithCurrentSettings"), + } + } + + #[test] + fn parses_current_settings_recording_overrides() { + let mut url = Url::parse("cap-desktop://action").unwrap(); + url.query_pairs_mut().append_pair( + "value", + r#"{"start_recording_with_current_settings":{"mode":"instant","mic_label":"Studio Mic","use_mic":true,"camera_label":"Desk Camera","use_camera":true,"capture_system_audio":false}}"#, + ); + + let action = DeepLinkAction::try_from(&url).unwrap(); + + match action { + DeepLinkAction::StartRecordingWithCurrentSettings { + mode, + mic_label, + use_mic, + camera_label, + use_camera, + capture_system_audio, + } => { + assert_eq!(mode, RecordingMode::Instant); + assert_eq!(mic_label, Some("Studio Mic".to_string())); + assert_eq!(use_mic, Some(true)); + assert_eq!(camera_label, Some("Desk Camera".to_string())); + assert_eq!(use_camera, Some(true)); + assert_eq!(capture_system_audio, Some(false)); + } + _ => panic!("expected StartRecordingWithCurrentSettings"), + } + } + + #[test] + fn temporary_screenshot_mode_restores_after_last_nested_flow() { + let mut state = TemporaryScreenshotModeState::default(); + + assert!(state.begin(Some(RecordingMode::Studio))); + assert!(!state.begin(Some(RecordingMode::Screenshot))); + assert_eq!(state.active_count, 2); + + assert_eq!(state.restore(), None); + assert_eq!(state.restore(), Some(Some(RecordingMode::Studio))); + assert_eq!(state.restore(), None); + } + + #[test] + fn temporary_screenshot_mode_preserves_missing_previous_mode() { + let mut state = TemporaryScreenshotModeState::default(); + + assert!(state.begin(None)); + + assert_eq!(state.restore(), Some(None)); + assert_eq!(state.restore(), None); + } + + #[test] + fn temporary_screenshot_mode_noops_when_already_screenshot() { + let mut state = TemporaryScreenshotModeState::default(); + + assert!(!state.begin(Some(RecordingMode::Screenshot))); + + assert_eq!(state.active_count, 0); + assert_eq!(state.restore(), None); + } +} + impl DeepLinkAction { pub async fn execute(self, app: &AppHandle) -> Result<(), String> { match self { @@ -144,15 +364,233 @@ impl DeepLinkAction { .await .map(|_| ()) } + DeepLinkAction::StartRecordingWithCurrentSettings { + mode, + mic_label, + use_mic, + camera_label, + use_camera, + capture_system_audio, + } => { + start_recording_with_current_settings( + app, + mode, + StartRecordingOverrides { + mic_label, + use_mic, + camera_label, + use_camera, + capture_system_audio, + }, + ) + .await + } + DeepLinkAction::RunHotkeyAction { action } => { + hotkeys::handle_action(app.clone(), action).await + } DeepLinkAction::StopRecording => { crate::recording::stop_recording(app.clone(), app.state()).await } + DeepLinkAction::RestartRecording => { + hotkeys::handle_action(app.clone(), HotkeyAction::RestartRecording).await + } + DeepLinkAction::TogglePauseRecording => { + hotkeys::handle_action(app.clone(), HotkeyAction::TogglePauseRecording).await + } + DeepLinkAction::CycleRecordingMode => { + hotkeys::handle_action(app.clone(), HotkeyAction::CycleRecordingMode).await + } + DeepLinkAction::SetRecordingMode { mode } => set_recording_mode(app, mode), + DeepLinkAction::OpenMain => { + crate::show_window( + app.clone(), + ShowCapWindow::Main { + init_target_mode: None, + }, + ) + .await + } + DeepLinkAction::OpenRecordingPicker { target_mode } => { + open_recording_picker(app, target_mode).await + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } DeepLinkAction::OpenSettings { page } => { crate::show_window(app.clone(), ShowCapWindow::Settings { page }).await } + DeepLinkAction::OpenRecordings => open_settings_page(app, "recordings").await, + DeepLinkAction::OpenScreenshots => open_settings_page(app, "screenshots").await, + DeepLinkAction::TakeScreenshot { target } => take_screenshot(app, target).await, + } + } +} + +async fn start_recording_with_current_settings( + app: &AppHandle, + mode: RecordingMode, + overrides: StartRecordingOverrides, +) -> Result<(), String> { + let settings = RecordingSettingsStore::get(app) + .ok() + .flatten() + .unwrap_or_default(); + let state = app.state::>(); + + let mic_label = match overrides.use_mic { + Some(false) => None, + _ => overrides.mic_label.or(settings.mic_name), + }; + + let camera_id = match overrides.use_camera { + Some(false) => None, + _ => match overrides.camera_label { + Some(label) => Some(camera_id_for_label(&label)?), + None => settings.camera_id, + }, + }; + + crate::set_mic_input(state.clone(), mic_label).await?; + crate::set_camera_input(app.clone(), state.clone(), camera_id, None).await?; + + let capture_target = settings.target.unwrap_or_else(|| { + use scap_targets::Display; + + ScreenCaptureTarget::Display { + id: Display::primary().id(), + } + }); + + crate::recording::start_recording( + app.clone(), + state, + StartRecordingInputs { + capture_target, + mode, + capture_system_audio: overrides + .capture_system_audio + .unwrap_or(settings.system_audio), + organization_id: settings.organization_id, + }, + ) + .await + .map(|_| ()) +} + +#[derive(Debug, Default)] +struct StartRecordingOverrides { + mic_label: Option, + use_mic: Option, + camera_label: Option, + use_camera: Option, + capture_system_audio: Option, +} + +fn camera_id_for_label(label: &str) -> Result { + cap_camera::list_cameras() + .find(|camera| camera.display_name() == label) + .map(|camera| DeviceOrModelID::from_info(&camera)) + .ok_or_else(|| format!("No camera with label \"{}\"", label)) +} + +async fn open_recording_picker( + app: &AppHandle, + target_mode: Option, +) -> Result<(), String> { + if let Some(target_mode) = target_mode { + crate::open_target_picker(app, target_mode).await; + return Ok(()); + } + + crate::show_window( + app.clone(), + ShowCapWindow::Main { + init_target_mode: None, + }, + ) + .await +} + +async fn open_settings_page(app: &AppHandle, page: &str) -> Result<(), String> { + crate::show_window( + app.clone(), + ShowCapWindow::Settings { + page: Some(page.to_owned()), + }, + ) + .await +} + +fn set_recording_mode(app: &AppHandle, mode: RecordingMode) -> Result<(), String> { + 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()); +} + +async fn take_screenshot(app: &AppHandle, target: ScreenshotTarget) -> Result<(), String> { + let capture_target = match target { + ScreenshotTarget::CurrentDisplay => { + use scap_targets::Display; + + let display = Display::get_containing_cursor().unwrap_or_else(Display::primary); + ScreenCaptureTarget::Display { id: display.id() } } + ScreenshotTarget::CurrentWindow => { + use scap_targets::Window; + + let window = Window::get_topmost_at_cursor() + .ok_or_else(|| "No window found under cursor".to_string())?; + ScreenCaptureTarget::Window { id: window.id() } + } + ScreenshotTarget::Area => { + begin_temporary_screenshot_mode(app).await?; + crate::open_target_picker(app, RecordingTargetMode::Area).await; + return Ok(()); + } + }; + + match crate::recording::take_screenshot(app.clone(), capture_target).await { + Ok(path) => crate::show_window(app.clone(), ShowCapWindow::ScreenshotEditor { path }).await, + Err(e) => Err(format!("Failed to take screenshot: {e}")), } } diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs index eccd9e700bf..1c50d4e448f 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -111,7 +111,7 @@ pub fn init(app: &AppHandle) { for (action, hotkey) in &store.hotkeys { if &Shortcut::from(*hotkey) == shortcut { - tokio::spawn(handle_hotkey(app.clone(), *action)); + tokio::spawn(handle_action(app.clone(), *action)); } } }) @@ -136,7 +136,7 @@ pub fn init(app: &AppHandle) { app.manage(Mutex::new(store)); } -async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), String> { +pub async fn handle_action(app: AppHandle, action: HotkeyAction) -> Result<(), String> { match action { HotkeyAction::StartStudioRecording => { let _ = RequestStartRecording { diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 0480cbbc7e5..39b408678f4 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,6 +1,8 @@ #![recursion_limit = "256"] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#[cfg(target_os = "macos")] +use std::ffi::CStr; use std::sync::Arc; use cap_desktop_lib::DynLoggingLayer; @@ -56,7 +58,7 @@ fn main() { let path = dirs::home_dir() .unwrap() .join("Library/Logs") - .join("so.cap.desktop"); + .join(macos_log_bundle_identifier().unwrap_or_else(|| "so.cap.desktop".to_string())); #[cfg(not(target_os = "macos"))] let path = dirs::data_local_dir() @@ -158,6 +160,33 @@ fn main() { .block_on(cap_desktop_lib::run(handle, logs_dir)); } +#[cfg(target_os = "macos")] +fn macos_log_bundle_identifier() -> Option { + use cocoa::base::{id, nil}; + use cocoa::foundation::NSAutoreleasePool; + use objc::{class, msg_send, sel, sel_impl}; + + unsafe { + let _pool = NSAutoreleasePool::new(nil); + let bundle: id = msg_send![class!(NSBundle), mainBundle]; + if bundle == nil { + return None; + } + + let identifier: id = msg_send![bundle, bundleIdentifier]; + if identifier == nil { + return None; + } + + let utf8: *const std::os::raw::c_char = msg_send![identifier, UTF8String]; + if utf8.is_null() { + return None; + } + + Some(CStr::from_ptr(utf8).to_string_lossy().into_owned()) + } +} + fn install_panic_hook(logs_dir: std::path::PathBuf) { let prev = std::panic::take_hook(); let panics_log = logs_dir.join("panics.log"); diff --git a/apps/desktop/src-tauri/src/recording_settings.rs b/apps/desktop/src-tauri/src/recording_settings.rs index c8935289818..bb5c11620a1 100644 --- a/apps/desktop/src-tauri/src/recording_settings.rs +++ b/apps/desktop/src-tauri/src/recording_settings.rs @@ -48,10 +48,17 @@ impl RecordingSettingsStore { } pub fn set_mode(app: &AppHandle, mode: RecordingMode) -> Result<(), String> { + Self::set_mode_option(app, Some(mode)) + } + + pub(crate) fn set_mode_option( + app: &AppHandle, + mode: Option, + ) -> Result<(), String> { let store = app.store("store").map_err(|e| e.to_string())?; let mut settings = Self::get(app)?.unwrap_or_default(); - settings.mode = Some(mode); + settings.mode = mode; store.set(Self::KEY, serde_json::json!(settings)); store.save().map_err(|e| e.to_string()) diff --git a/apps/desktop/src-tauri/src/target_select_overlay.rs b/apps/desktop/src-tauri/src/target_select_overlay.rs index 82eac1701f0..a31ca6fcec2 100644 --- a/apps/desktop/src-tauri/src/target_select_overlay.rs +++ b/apps/desktop/src-tauri/src/target_select_overlay.rs @@ -326,6 +326,8 @@ pub async fn close_target_select_overlays( state.destroy(&display_id, app.global_shortcut()); } + crate::deeplink_actions::restore_temporary_recording_mode(&app).await; + Ok(()) } diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index a2ad65fd569..ad732dca7c6 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -40,7 +40,7 @@ use crate::{ target_select_overlay::WindowFocusManager, window_exclusion::WindowExclusion, }; -use cap_recording::{feeds, sources::screen_capture::ScreenCaptureTarget}; +use cap_recording::{RecordingMode, feeds, sources::screen_capture::ScreenCaptureTarget}; #[cfg(target_os = "macos")] const DEFAULT_TRAFFIC_LIGHTS_INSET: LogicalPosition = LogicalPosition::new(12.0, 12.0); @@ -300,6 +300,37 @@ pub(crate) async fn restore_main_window_inputs(app: &AppHandle) { .ok() .flatten() .unwrap_or_default(); + + if matches!(settings.mode, Some(RecordingMode::Screenshot)) { + if let Err(err) = crate::set_mic_input(state.clone(), None).await { + warn!("Failed to suspend microphone input for screenshot mode: {err}"); + } + + let operation_lock = app.state::(); + let _operation_guard = operation_lock.lock().await; + + let (camera_feed, had_camera_input) = { + let app_state = &mut *state.write().await; + let had_camera_input = + app_state.selected_camera_id.is_some() || app_state.camera_in_use; + app_state.selected_camera_id = None; + app_state.camera_in_use = false; + app_state.camera_cleanup_done = true; + app_state.camera_preview.pause(); + (app_state.camera_feed.clone(), had_camera_input) + }; + + if had_camera_input && let Err(err) = camera_feed.ask(feeds::camera::RemoveInput).await { + warn!("Failed to suspend camera input for screenshot mode: {err}"); + } + + if let Some(window) = CapWindowId::Camera.get(app) { + let _ = window.hide(); + } + + return; + } + let stored_camera_id = settings.camera_id.clone(); if let Err(err) = crate::set_mic_input(state.clone(), settings.mic_name).await { diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index 1ef0a048ec2..32e8b1077c0 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -63,6 +63,7 @@ import { listWindows, listWindowsWithThumbnails, } from "~/utils/queries"; +import { createRecordingInputHandlers } from "~/utils/screenshot-recording-inputs"; import { type CaptureDisplay, type CaptureDisplayWithThumbnail, @@ -2058,8 +2059,55 @@ function Page() { const setCamera = createCameraMutation(); + const { + restoreRecordingInputs, + suspendRecordingInputsForScreenshot, + syncRecordingInputsForMode, + } = createRecordingInputHandlers({ + setMicInput: (name) => setMicInput.mutateAsync(name), + setCameraInput: (args) => setCamera.mutateAsync(args), + }); + + let recordingInputsToRestore: { + micName: string | null; + cameraID: DeviceOrModelID | null; + } = { + micName: rawOptions.micName ?? null, + cameraID: rawOptions.cameraID ? { ...rawOptions.cameraID } : null, + }; + + const rememberRecordingInputsToRestore = (inputs: { + micName: string | null; + cameraID: DeviceOrModelID | null; + }) => { + recordingInputsToRestore = { + micName: inputs.micName, + cameraID: inputs.cameraID ? { ...inputs.cameraID } : null, + }; + return recordingInputsToRestore; + }; + createUpdateCheck(); + createEffect((wasScreenshotMode) => { + const isScreenshotMode = rawOptions.mode === "screenshot"; + + if (isScreenshotMode && !wasScreenshotMode) { + rememberRecordingInputsToRestore({ + micName: rawOptions.micName ?? null, + cameraID: rawOptions.cameraID ?? null, + }); + void suspendRecordingInputsForScreenshot(); + } else if (!isScreenshotMode && wasScreenshotMode) { + void restoreRecordingInputs( + recordingInputsToRestore.micName, + recordingInputsToRestore.cameraID, + ); + } + + return isScreenshotMode; + }, rawOptions.mode === "screenshot"); + onMount(async () => { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); @@ -2091,17 +2139,18 @@ function Page() { void emit("main-window-ready"); scheduleTargetListPrewarm(); - if (rawOptions.micName) { - setMicInput - .mutateAsync(rawOptions.micName) - .catch((error) => console.error("Failed to set mic input:", error)); - } - - if (rawOptions.cameraID) { - setCamera - .mutateAsync({ model: rawOptions.cameraID }) - .catch((error) => console.error("Failed to set camera input:", error)); - } + const storedSettings = await recordingSettingsStore.get().catch((error) => { + console.error("Failed to read recording settings:", error); + return null; + }); + const recordingInputs = rememberRecordingInputsToRestore({ + micName: storedSettings?.micName ?? rawOptions.micName ?? null, + cameraID: storedSettings?.cameraId ?? rawOptions.cameraID ?? null, + }); + await syncRecordingInputsForMode({ + mode: rawOptions.mode, + ...recordingInputs, + }); const unlistenFocus = currentWindow.onFocusChanged( ({ payload: focused }) => { @@ -2528,19 +2577,23 @@ function Page() { name="Area" class="flex-1" /> - { - toggleTargetMode("camera"); - }} - name="Camera Only" - class="flex-1" - /> + + { + toggleTargetMode("camera"); + }} + name="Camera Only" + class="flex-1" + /> + - + + + ); diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 4064596a572..48e65443868 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -56,7 +56,11 @@ import { } from "~/components/Cropper"; import ModeSelect from "~/components/ModeSelect"; import SelectionHint from "~/components/selection-hint"; -import { authStore, generalSettingsStore } from "~/store"; +import { + authStore, + generalSettingsStore, + recordingSettingsStore, +} from "~/store"; import { getCameraWindow } from "~/utils/camera-window"; import { createDevicesQuery } from "~/utils/devices"; import { @@ -64,6 +68,7 @@ import { createOptionsQuery, createOrganizationsQuery, } from "~/utils/queries"; +import { createRecordingInputHandlers } from "~/utils/screenshot-recording-inputs"; import { type CameraInfo, commands, @@ -1637,25 +1642,69 @@ function RecordingControls(props: { })); const setCamera = createCameraMutation(); - onMount(async () => { - if (rawOptions.micName) { - setMicInput - .mutateAsync(rawOptions.micName) - .catch((error) => console.error("Failed to set mic input:", error)); - } + const { + restoreRecordingInputs, + suspendRecordingInputsForScreenshot, + syncRecordingInputsForMode, + } = createRecordingInputHandlers({ + setMicInput: (name) => setMicInput.mutateAsync(name), + setCameraInput: (args) => setCamera.mutateAsync(args), + skipCameraWindow: () => props.target.variant === "cameraOnly", + }); - const isCameraOnly = props.target.variant === "cameraOnly"; - if (rawOptions.cameraID && "ModelID" in rawOptions.cameraID) - await setCamera.mutateAsync({ - model: { ModelID: rawOptions.cameraID.ModelID }, - skipCameraWindow: isCameraOnly, - }); - else if (rawOptions.cameraID && "DeviceID" in rawOptions.cameraID) - await setCamera.mutateAsync({ - model: { DeviceID: rawOptions.cameraID.DeviceID }, - skipCameraWindow: isCameraOnly, + let recordingInputsToRestore: { + micName: string | null; + cameraID: DeviceOrModelID | null; + } = { + micName: rawOptions.micName ?? null, + cameraID: rawOptions.cameraID ? { ...rawOptions.cameraID } : null, + }; + + const rememberRecordingInputsToRestore = (inputs: { + micName: string | null; + cameraID: DeviceOrModelID | null; + }) => { + recordingInputsToRestore = { + micName: inputs.micName, + cameraID: inputs.cameraID ? { ...inputs.cameraID } : null, + }; + return recordingInputsToRestore; + }; + + createEffect((wasScreenshotMode) => { + const isScreenshotMode = rawOptions.mode === "screenshot"; + + if (isScreenshotMode && !wasScreenshotMode) { + rememberRecordingInputsToRestore({ + micName: rawOptions.micName ?? null, + cameraID: rawOptions.cameraID ?? null, }); + void suspendRecordingInputsForScreenshot(); + } else if (!isScreenshotMode && wasScreenshotMode) { + void restoreRecordingInputs( + recordingInputsToRestore.micName, + recordingInputsToRestore.cameraID, + ); + } + return isScreenshotMode; + }, rawOptions.mode === "screenshot"); + + onMount(async () => { + const storedSettings = await recordingSettingsStore.get().catch((error) => { + console.error("Failed to read recording settings:", error); + return null; + }); + const recordingInputs = rememberRecordingInputsToRestore({ + micName: storedSettings?.micName ?? rawOptions.micName ?? null, + cameraID: storedSettings?.cameraId ?? rawOptions.cameraID ?? null, + }); + await syncRecordingInputsForMode({ + mode: rawOptions.mode, + ...recordingInputs, + }); + + const isCameraOnly = props.target.variant === "cameraOnly"; if (isCameraOnly) { const win = await getCameraWindow(); if (win) win.close(); @@ -1812,11 +1861,19 @@ function RecordingControls(props: { target: props.target, }); await commands.showWindow({ ScreenshotEditor: { path } }); - await commands.closeTargetSelectOverlays(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(`Failed to take screenshot: ${message}`); console.error("Failed to take screenshot", e); + } finally { + await commands + .closeTargetSelectOverlays() + .catch((error) => + console.error( + "Failed to close target select overlays after screenshot:", + error, + ), + ); } return; } @@ -1842,7 +1899,7 @@ function RecordingControls(props: { - + @@ -1878,7 +1935,7 @@ function RecordingControls(props: { - +
{ + it("restores an empty-string mic label", async () => { + const setMicInput = vi.fn().mockResolvedValue(undefined); + const setCameraInput = vi.fn().mockResolvedValue(undefined); + const cameraID: DeviceOrModelID = { DeviceID: "camera" }; + const { restoreRecordingInputs } = createRecordingInputHandlers({ + setMicInput, + setCameraInput, + }); + + await restoreRecordingInputs("", cameraID); + + expect(setMicInput).toHaveBeenCalledWith(""); + expect(setCameraInput).toHaveBeenCalledWith({ + model: cameraID, + skipCameraWindow: undefined, + }); + }); +}); diff --git a/apps/desktop/src/utils/screenshot-recording-inputs.ts b/apps/desktop/src/utils/screenshot-recording-inputs.ts new file mode 100644 index 00000000000..83c552dd4f7 --- /dev/null +++ b/apps/desktop/src/utils/screenshot-recording-inputs.ts @@ -0,0 +1,96 @@ +import type { DeviceOrModelID, RecordingMode } from "~/utils/tauri"; + +type SetCameraInput = (args: { + model: DeviceOrModelID | null; + skipCameraWindow?: boolean; +}) => Promise; + +type RecordingInputHandlers = { + restoreRecordingInputs: ( + micName: string | null, + cameraID: DeviceOrModelID | null, + ) => Promise; + suspendRecordingInputsForScreenshot: () => Promise; + syncRecordingInputsForMode: (args: { + mode: RecordingMode; + micName: string | null; + cameraID: DeviceOrModelID | null; + }) => Promise; +}; + +type SkipCameraWindow = boolean | (() => boolean); + +export function createRecordingInputHandlers({ + setMicInput, + setCameraInput, + skipCameraWindow, +}: { + setMicInput: (name: string | null) => Promise; + setCameraInput: SetCameraInput; + skipCameraWindow?: SkipCameraWindow; +}): RecordingInputHandlers { + const getSkipCameraWindow = () => + typeof skipCameraWindow === "function" + ? skipCameraWindow() + : skipCameraWindow; + + const suspendRecordingInputsForScreenshot = async () => { + await Promise.all([ + setMicInput(null).catch((error) => + console.error( + "Failed to suspend mic input for screenshot mode:", + error, + ), + ), + setCameraInput({ + model: null, + skipCameraWindow: getSkipCameraWindow(), + }).catch((error) => + console.error( + "Failed to suspend camera input for screenshot mode:", + error, + ), + ), + ]); + }; + + const restoreRecordingInputs = async ( + micName: string | null, + cameraID: DeviceOrModelID | null, + ) => { + if (micName != null) { + await setMicInput(micName).catch((error) => + console.error("Failed to set mic input:", error), + ); + } + + if (cameraID != null) { + await setCameraInput({ + model: cameraID, + skipCameraWindow: getSkipCameraWindow(), + }).catch((error) => console.error("Failed to set camera input:", error)); + } + }; + + const syncRecordingInputsForMode = async ({ + mode, + micName, + cameraID, + }: { + mode: RecordingMode; + micName: string | null; + cameraID: DeviceOrModelID | null; + }) => { + if (mode === "screenshot") { + await suspendRecordingInputsForScreenshot(); + } else { + await restoreRecordingInputs(micName, cameraID); + } + }; + + return { + restoreRecordingInputs, + suspendRecordingInputsForScreenshot, + syncRecordingInputsForMode, + }; +} diff --git a/scripts/cap-desktop-action-deeplinks.js b/scripts/cap-desktop-action-deeplinks.js new file mode 100644 index 00000000000..3b8ea8498e8 --- /dev/null +++ b/scripts/cap-desktop-action-deeplinks.js @@ -0,0 +1,170 @@ +import { spawnSync } from "node:child_process"; + +const actions = [ + { + name: "open-main", + payload: "open_main", + expected: "Opens or focuses the main Cap window.", + }, + { + name: "screenshot-area", + payload: { take_screenshot: { target: "area" } }, + expected: + "Opens the target selector in screenshot area mode. After selection, opens the screenshot editor without starting camera or mic preview.", + }, + { + name: "screenshot-current-display", + payload: { take_screenshot: { target: "current_display" } }, + expected: + "Captures the display under the cursor and opens the screenshot editor.", + }, + { + name: "screenshot-current-window", + payload: { take_screenshot: { target: "current_window" } }, + expected: + "Captures the window under the cursor and opens the screenshot editor. If no window is found, the app logs an error and no editor opens.", + }, + { + name: "hotkey-screenshot-area", + payload: { run_hotkey_action: { action: "screenshotArea" } }, + expected: + "Runs the same path as the Screenshot Area hotkey and opens the screenshot area selector.", + }, + { + name: "open-screenshots", + payload: "open_screenshots", + expected: "Opens Settings to the screenshots page.", + }, + { + name: "open-recordings", + payload: "open_recordings", + expected: "Opens Settings to the recordings page.", + }, + { + name: "recording-picker", + payload: { open_recording_picker: { target_mode: null } }, + expected: "Opens or focuses the main Cap window.", + }, + { + name: "recording-picker-display", + payload: { open_recording_picker: { target_mode: "display" } }, + expected: "Opens the target selector in display mode.", + }, + { + name: "recording-picker-window", + payload: { open_recording_picker: { target_mode: "window" } }, + expected: "Opens the target selector in window mode.", + }, + { + name: "recording-picker-area", + payload: { open_recording_picker: { target_mode: "area" } }, + expected: "Opens the target selector in area mode.", + }, + { + name: "start-studio-current-settings", + payload: { start_recording_with_current_settings: { mode: "studio" } }, + expected: + "Starts a Studio recording using saved target, mic, camera, system-audio, and organization settings.", + }, + { + name: "start-instant-current-settings", + payload: { start_recording_with_current_settings: { mode: "instant" } }, + expected: + "Starts an Instant recording using saved target, mic, camera, system-audio, and organization settings.", + }, + { + name: "stop-recording", + payload: "stop_recording", + expected: "Stops the active recording.", + }, + { + name: "toggle-pause-recording", + payload: "toggle_pause_recording", + expected: "Pauses or resumes the active recording.", + }, + { + name: "restart-recording", + payload: "restart_recording", + expected: "Restarts the active recording.", + }, + { + name: "cycle-recording-mode", + payload: "cycle_recording_mode", + expected: + "Cycles saved mode through Studio, Instant, and Screenshot, and updates the tray icon.", + }, + { + name: "set-screenshot-mode", + payload: { set_recording_mode: { mode: "screenshot" } }, + expected: "Saves Screenshot as the current mode and updates the tray icon.", + }, + { + name: "open-settings-screenshots", + payload: { open_settings: { page: "screenshots" } }, + expected: "Opens Settings to the screenshots page.", + }, +]; + +const args = process.argv.slice(2); +const flags = new Set(args.filter((arg) => arg.startsWith("--"))); +const actionName = args.find((arg) => !arg.startsWith("--")); + +function urlFor(payload) { + return `cap-desktop://action?value=${encodeURIComponent(JSON.stringify(payload))}`; +} + +function printAction(action) { + const url = urlFor(action.payload); + console.log(action.name); + console.log(` payload: ${JSON.stringify(action.payload)}`); + console.log(` url: ${url}`); + console.log(` command: open '${url}'`); + console.log(` expected: ${action.expected}`); +} + +function printHelp() { + console.log( + "Usage: node scripts/cap-desktop-action-deeplinks.js [name] [--open]", + ); + console.log(""); + console.log("With no name, prints every test command."); + console.log("With --open, opens the selected deeplink on macOS."); + console.log(""); + console.log("Names:"); + for (const action of actions) { + console.log(` ${action.name}`); + } +} + +if (flags.has("--help")) { + printHelp(); +} else if (!actionName) { + for (const action of actions) { + printAction(action); + console.log(""); + } +} else { + const action = actions.find((candidate) => candidate.name === actionName); + + if (!action) { + console.error(`Unknown deeplink action: ${actionName}`); + console.error( + "Run `node scripts/cap-desktop-action-deeplinks.js --help` for valid names.", + ); + process.exitCode = 1; + } else { + printAction(action); + + if (flags.has("--open")) { + if (process.platform !== "darwin") { + console.error("--open currently uses the macOS `open` command."); + process.exitCode = 1; + } else { + const result = spawnSync("open", [urlFor(action.payload)], { + stdio: "inherit", + }); + process.exitCode = result.status ?? 1; + } + } + } +}