From 3a4c4cd5ef8de3e963b1f7b0660074990eade191 Mon Sep 17 00:00:00 2001 From: BountyGrid Date: Tue, 25 Aug 2026 14:33:06 -0700 Subject: [PATCH 1/2] feat(desktop): add pause, resume, screenshot, and device deeplinks (#1540) --- .../desktop/src-tauri/src/deeplink_actions.rs | 483 ++++++++++++++++-- 1 file changed, 449 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index 6eb7f4c8939..c78afe47ddc 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -2,6 +2,7 @@ use cap_recording::{ RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget, }; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Url}; use tracing::trace; @@ -46,10 +47,16 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, - #[cfg(debug_assertions)] PauseRecording, - #[cfg(debug_assertions)] ResumeRecording, + TogglePauseRecording, + TakeScreenshot, + SetCamera { + device_id: String, + }, + SetMicrophone { + device_id: String, + }, #[cfg(debug_assertions)] OpenCamera { camera: DeviceOrModelID, @@ -164,25 +171,138 @@ impl TryFrom<&Url> for DeepLinkAction { .map_err(|_| ActionParseFromUrlError::Invalid); } - match url.domain() { - Some("action") => {} - Some(_) => return Err(ActionParseFromUrlError::NotAction), - None => return Err(ActionParseFromUrlError::Invalid), + if url.scheme() != "cap-desktop" { + return Err(ActionParseFromUrlError::NotAction); + } + + let host = url.host_str().unwrap_or_default(); + + // Non-action endpoints such as login/signin flows + if host == "login" || host == "signin" || host == "auth" || host == "oauth" { + return Err(ActionParseFromUrlError::NotAction); + } + + let params: HashMap<_, _> = url.query_pairs().collect(); + + if host == "action" { + if let Some(json_value) = params.get("value") { + let action: Self = serde_json::from_str(json_value) + .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?; + return Ok(action); + } + + let path_action = url.path().trim_matches('/'); + if !path_action.is_empty() { + return Self::from_action_name(path_action, ¶ms); + } + + return Err(ActionParseFromUrlError::Invalid); } - let params = url - .query_pairs() - .collect::>(); - let json_value = params - .get("value") - .ok_or(ActionParseFromUrlError::Invalid)?; - let action: Self = serde_json::from_str(json_value) - .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?; - Ok(action) + if !host.is_empty() { + Self::from_action_name(host, ¶ms) + } else { + let path_action = url.path().trim_matches('/'); + if !path_action.is_empty() { + Self::from_action_name(path_action, ¶ms) + } else { + Err(ActionParseFromUrlError::Invalid) + } + } } } impl DeepLinkAction { + fn from_action_name( + action_name: &str, + params: &HashMap, std::borrow::Cow<'_, str>>, + ) -> Result { + // Check if value parameter contains a valid DeepLinkAction JSON + if let Some(json_value) = params.get("value") { + if let Ok(action) = serde_json::from_str::(json_value) { + return Ok(action); + } + } + + let normalized = action_name.to_ascii_lowercase().replace('-', "_"); + match normalized.as_str() { + "pause_recording" | "pause" => Ok(Self::PauseRecording), + "resume_recording" | "resume" => Ok(Self::ResumeRecording), + "toggle_pause_recording" | "toggle_pause" => Ok(Self::TogglePauseRecording), + "take_screenshot" | "screenshot" => Ok(Self::TakeScreenshot), + "stop_recording" | "stop" => Ok(Self::StopRecording), + "set_camera" | "camera" => { + let device_id = params + .get("device_id") + .or_else(|| params.get("deviceId")) + .or_else(|| params.get("camera_id")) + .or_else(|| params.get("cameraId")) + .or_else(|| params.get("id")) + .or_else(|| params.get("device")) + .or_else(|| params.get("camera")) + .or_else(|| params.get("value")) + .map(|s| s.to_string()); + + if let Some(device_id) = device_id { + Ok(Self::SetCamera { device_id }) + } else { + Err(ActionParseFromUrlError::Invalid) + } + } + "set_microphone" | "set_mic" | "microphone" | "mic" => { + let device_id = params + .get("device_id") + .or_else(|| params.get("deviceId")) + .or_else(|| params.get("mic_id")) + .or_else(|| params.get("micId")) + .or_else(|| params.get("mic_label")) + .or_else(|| params.get("micLabel")) + .or_else(|| params.get("id")) + .or_else(|| params.get("device")) + .or_else(|| params.get("microphone")) + .or_else(|| params.get("mic")) + .or_else(|| params.get("value")) + .map(|s| s.to_string()); + + if let Some(device_id) = device_id { + Ok(Self::SetMicrophone { device_id }) + } else { + Err(ActionParseFromUrlError::Invalid) + } + } + "open_settings" | "settings" => { + let page = params + .get("page") + .or_else(|| params.get("value")) + .map(|s| s.to_string()); + Ok(Self::OpenSettings { page }) + } + "open_editor" | "editor" => { + let project_path = params + .get("project_path") + .or_else(|| params.get("projectPath")) + .or_else(|| params.get("path")) + .or_else(|| params.get("value")) + .map(|s| PathBuf::from(s.as_ref())); + + if let Some(project_path) = project_path { + Ok(Self::OpenEditor { project_path }) + } else { + Err(ActionParseFromUrlError::Invalid) + } + } + "start_recording" | "start" => { + if let Some(json_value) = params.get("value") { + serde_json::from_str(json_value) + .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string())) + } else { + Err(ActionParseFromUrlError::Invalid) + } + } + _ => Err(ActionParseFromUrlError::NotAction), + } + } + pub async fn execute(self, app: &AppHandle) -> Result<(), String> { match self { DeepLinkAction::StartRecording { @@ -244,14 +364,48 @@ impl DeepLinkAction { DeepLinkAction::StopRecording => { crate::recording::stop_recording(app.clone(), app.state()).await } - #[cfg(debug_assertions)] DeepLinkAction::PauseRecording => { crate::recording::pause_recording(app.clone(), app.state()).await } - #[cfg(debug_assertions)] DeepLinkAction::ResumeRecording => { crate::recording::resume_recording(app.clone(), app.state()).await } + DeepLinkAction::TogglePauseRecording => { + crate::recording::toggle_pause_recording(app.clone(), app.state()).await + } + DeepLinkAction::TakeScreenshot => { + use scap_targets::Display; + + let display = Display::get_containing_cursor().unwrap_or_else(Display::primary); + let target = ScreenCaptureTarget::Display { id: display.id() }; + + match crate::recording::take_screenshot(app.clone(), target.clone()).await { + Ok(path) => { + if crate::automation::should_open_screenshot_editor(app, &target) { + let _ = ShowCapWindow::ScreenshotEditor { path }.show(app).await; + } + Ok(()) + } + Err(err) => Err(format!("Failed to take screenshot: {err}")), + } + } + DeepLinkAction::SetCamera { device_id } => { + let camera = if device_id.is_empty() || device_id.eq_ignore_ascii_case("none") { + None + } else { + Some(DeviceOrModelID::DeviceID(device_id)) + }; + crate::set_camera_input(app.clone(), app.state::>(), camera, None) + .await + } + DeepLinkAction::SetMicrophone { device_id } => { + let mic = if device_id.is_empty() || device_id.eq_ignore_ascii_case("none") { + None + } else { + Some(device_id) + }; + crate::set_mic_input(app.state::>(), mic).await + } #[cfg(debug_assertions)] DeepLinkAction::OpenCamera { camera } => { crate::set_camera_input( @@ -303,11 +457,282 @@ mod tests { #[test] fn parses_stop_recording_action_url() { let url = Url::parse("cap-desktop://action?value=%22stop_recording%22").unwrap(); - assert_eq!( DeepLinkAction::try_from(&url), Ok(DeepLinkAction::StopRecording) ); + + let direct_url = Url::parse("cap-desktop://stop_recording").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&direct_url), + Ok(DeepLinkAction::StopRecording) + ); + + let hyphen_url = Url::parse("cap-desktop://stop-recording").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&hyphen_url), + Ok(DeepLinkAction::StopRecording) + ); + + let short_url = Url::parse("cap-desktop://stop").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&short_url), + Ok(DeepLinkAction::StopRecording) + ); + } + + #[test] + fn parses_pause_and_resume_action_urls() { + let pause_url = Url::parse("cap-desktop://action?value=%22pause_recording%22").unwrap(); + let resume_url = Url::parse("cap-desktop://action?value=%22resume_recording%22").unwrap(); + + assert_eq!( + DeepLinkAction::try_from(&pause_url), + Ok(DeepLinkAction::PauseRecording) + ); + assert_eq!( + DeepLinkAction::try_from(&resume_url), + Ok(DeepLinkAction::ResumeRecording) + ); + + let direct_pause = Url::parse("cap-desktop://pause_recording").unwrap(); + let direct_resume = Url::parse("cap-desktop://resume_recording").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&direct_pause), + Ok(DeepLinkAction::PauseRecording) + ); + assert_eq!( + DeepLinkAction::try_from(&direct_resume), + Ok(DeepLinkAction::ResumeRecording) + ); + + let hyphen_pause = Url::parse("cap-desktop://pause-recording").unwrap(); + let hyphen_resume = Url::parse("cap-desktop://resume-recording").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&hyphen_pause), + Ok(DeepLinkAction::PauseRecording) + ); + assert_eq!( + DeepLinkAction::try_from(&hyphen_resume), + Ok(DeepLinkAction::ResumeRecording) + ); + + let short_pause = Url::parse("cap-desktop://pause").unwrap(); + let short_resume = Url::parse("cap-desktop://resume").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&short_pause), + Ok(DeepLinkAction::PauseRecording) + ); + assert_eq!( + DeepLinkAction::try_from(&short_resume), + Ok(DeepLinkAction::ResumeRecording) + ); + } + + #[test] + fn parses_toggle_pause_action_urls() { + let json_url = + Url::parse("cap-desktop://action?value=%22toggle_pause_recording%22").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&json_url), + Ok(DeepLinkAction::TogglePauseRecording) + ); + + let direct_url = Url::parse("cap-desktop://toggle_pause_recording").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&direct_url), + Ok(DeepLinkAction::TogglePauseRecording) + ); + + let hyphen_url = Url::parse("cap-desktop://toggle-pause-recording").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&hyphen_url), + Ok(DeepLinkAction::TogglePauseRecording) + ); + + let short_url = Url::parse("cap-desktop://toggle_pause").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&short_url), + Ok(DeepLinkAction::TogglePauseRecording) + ); + } + + #[test] + fn parses_take_screenshot_action_urls() { + let json_url = Url::parse("cap-desktop://action?value=%22take_screenshot%22").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&json_url), + Ok(DeepLinkAction::TakeScreenshot) + ); + + let direct_url = Url::parse("cap-desktop://take_screenshot").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&direct_url), + Ok(DeepLinkAction::TakeScreenshot) + ); + + let hyphen_url = Url::parse("cap-desktop://take-screenshot").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&hyphen_url), + Ok(DeepLinkAction::TakeScreenshot) + ); + + let short_url = Url::parse("cap-desktop://screenshot").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&short_url), + Ok(DeepLinkAction::TakeScreenshot) + ); + } + + #[test] + fn parses_set_camera_action_urls() { + let value = serde_json::json!({ + "set_camera": { + "device_id": "camera-facetime-1" + } + }) + .to_string(); + let json_url = Url::parse_with_params("cap-desktop://action", &[("value", value)]).unwrap(); + assert_eq!( + DeepLinkAction::try_from(&json_url), + Ok(DeepLinkAction::SetCamera { + device_id: "camera-facetime-1".to_string() + }) + ); + + let direct_url = + Url::parse("cap-desktop://set_camera?device_id=camera-facetime-1").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&direct_url), + Ok(DeepLinkAction::SetCamera { + device_id: "camera-facetime-1".to_string() + }) + ); + + let hyphen_url = Url::parse("cap-desktop://set-camera?deviceId=camera-facetime-1").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&hyphen_url), + Ok(DeepLinkAction::SetCamera { + device_id: "camera-facetime-1".to_string() + }) + ); + + let camera_alias_url = + Url::parse("cap-desktop://camera?camera_id=camera-facetime-1").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&camera_alias_url), + Ok(DeepLinkAction::SetCamera { + device_id: "camera-facetime-1".to_string() + }) + ); + } + + #[test] + fn parses_set_microphone_action_urls() { + let value = serde_json::json!({ + "set_microphone": { + "device_id": "mic-shure-mv7" + } + }) + .to_string(); + let json_url = Url::parse_with_params("cap-desktop://action", &[("value", value)]).unwrap(); + assert_eq!( + DeepLinkAction::try_from(&json_url), + Ok(DeepLinkAction::SetMicrophone { + device_id: "mic-shure-mv7".to_string() + }) + ); + + let direct_url = + Url::parse("cap-desktop://set_microphone?device_id=mic-shure-mv7").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&direct_url), + Ok(DeepLinkAction::SetMicrophone { + device_id: "mic-shure-mv7".to_string() + }) + ); + + let hyphen_url = + Url::parse("cap-desktop://set-microphone?mic_label=mic-shure-mv7").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&hyphen_url), + Ok(DeepLinkAction::SetMicrophone { + device_id: "mic-shure-mv7".to_string() + }) + ); + + let mic_alias_url = Url::parse("cap-desktop://set_mic?id=mic-shure-mv7").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&mic_alias_url), + Ok(DeepLinkAction::SetMicrophone { + device_id: "mic-shure-mv7".to_string() + }) + ); + } + + #[test] + fn parses_open_editor_and_settings_action_urls() { + let editor_url = + Url::parse("cap-desktop://open_editor?project_path=%2Fpath%2Fto%2Fproject").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&editor_url), + Ok(DeepLinkAction::OpenEditor { + project_path: PathBuf::from("/path/to/project") + }) + ); + + let settings_url = Url::parse("cap-desktop://settings?page=shortcuts").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&settings_url), + Ok(DeepLinkAction::OpenSettings { + page: Some("shortcuts".to_string()) + }) + ); + } + + #[test] + fn parses_action_path_format() { + let path_url = Url::parse("cap-desktop://action/pause_recording").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&path_url), + Ok(DeepLinkAction::PauseRecording) + ); + + let camera_path_url = + Url::parse("cap-desktop://action/set_camera?device_id=cam-123").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&camera_path_url), + Ok(DeepLinkAction::SetCamera { + device_id: "cam-123".to_string() + }) + ); + } + + #[test] + fn handles_invalid_and_malformed_action_urls() { + let malformed_json_url = Url::parse("cap-desktop://action?value={invalid").unwrap(); + assert!(matches!( + DeepLinkAction::try_from(&malformed_json_url), + Err(ActionParseFromUrlError::ParseFailed(_)) + )); + + let missing_camera_id_url = Url::parse("cap-desktop://set_camera").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&missing_camera_id_url), + Err(ActionParseFromUrlError::Invalid) + ); + + let missing_mic_id_url = Url::parse("cap-desktop://set_microphone").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&missing_mic_id_url), + Err(ActionParseFromUrlError::Invalid) + ); + + let empty_action_url = Url::parse("cap-desktop://action").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&empty_action_url), + Err(ActionParseFromUrlError::Invalid) + ); } #[cfg(debug_assertions)] @@ -358,22 +783,6 @@ mod tests { ); } - #[cfg(debug_assertions)] - #[test] - fn parses_pause_and_resume_action_urls() { - let pause_url = Url::parse("cap-desktop://action?value=%22pause_recording%22").unwrap(); - let resume_url = Url::parse("cap-desktop://action?value=%22resume_recording%22").unwrap(); - - assert_eq!( - DeepLinkAction::try_from(&pause_url), - Ok(DeepLinkAction::PauseRecording) - ); - assert_eq!( - DeepLinkAction::try_from(&resume_url), - Ok(DeepLinkAction::ResumeRecording) - ); - } - #[cfg(debug_assertions)] #[test] fn parses_area_recording_action_url() { @@ -510,5 +919,11 @@ mod tests { DeepLinkAction::try_from(&url), Err(ActionParseFromUrlError::NotAction) ); + + let signin_url = Url::parse("cap-desktop://signin?token=abc").unwrap(); + assert_eq!( + DeepLinkAction::try_from(&signin_url), + Err(ActionParseFromUrlError::NotAction) + ); } } From 70508d1770cc5644365195781fbd15324402b106 Mon Sep 17 00:00:00 2001 From: BountyGrid Date: Tue, 25 Aug 2026 14:50:05 -0700 Subject: [PATCH 2/2] style(desktop): refine deeplink device handlers and clean parser comments --- apps/desktop/src-tauri/src/deeplink_actions.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index c78afe47ddc..8eac38d20b1 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -176,8 +176,6 @@ impl TryFrom<&Url> for DeepLinkAction { } let host = url.host_str().unwrap_or_default(); - - // Non-action endpoints such as login/signin flows if host == "login" || host == "signin" || host == "auth" || host == "oauth" { return Err(ActionParseFromUrlError::NotAction); } @@ -217,7 +215,6 @@ impl DeepLinkAction { action_name: &str, params: &HashMap, std::borrow::Cow<'_, str>>, ) -> Result { - // Check if value parameter contains a valid DeepLinkAction JSON if let Some(json_value) = params.get("value") { if let Ok(action) = serde_json::from_str::(json_value) { return Ok(action); @@ -393,7 +390,8 @@ impl DeepLinkAction { let camera = if device_id.is_empty() || device_id.eq_ignore_ascii_case("none") { None } else { - Some(DeviceOrModelID::DeviceID(device_id)) + let id = DeviceOrModelID::DeviceID(device_id); + Some(id) }; crate::set_camera_input(app.clone(), app.state::>(), camera, None) .await @@ -402,7 +400,10 @@ impl DeepLinkAction { let mic = if device_id.is_empty() || device_id.eq_ignore_ascii_case("none") { None } else { - Some(device_id) + let mic_names = cap_recording::feeds::microphone::MicrophoneFeed::list_names(); + let matched = crate::find_mic_by_label_or_fuzzy(&mic_names, &device_id) + .unwrap_or(device_id); + Some(matched) }; crate::set_mic_input(app.state::>(), mic).await }