diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a117028487..2764f95a59 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -1,5 +1,6 @@ use cap_recording::{ - RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget, + MicrophoneFeed, RecordingMode, feeds::camera::DeviceOrModelID, + sources::screen_capture::ScreenCaptureTarget, }; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -8,14 +9,14 @@ use tracing::trace; use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum CaptureMode { Screen(String), Window(String), } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum DeepLinkAction { StartRecording { @@ -32,6 +33,44 @@ pub enum DeepLinkAction { OpenSettings { page: Option, }, + PauseRecording, + ResumeRecording, + TogglePauseRecording, + RestartRecording, + TakeScreenshot { + capture_mode: CaptureMode, + }, + #[serde(alias = "switch_microphone")] + SetMicrophone { + mic_label: Option, + }, + #[serde(alias = "switch_camera")] + SetCamera { + camera: Option, + }, + RefreshRaycastDeviceCache, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RaycastDeviceCache { + displays: Vec, + windows: Vec, + microphones: Vec, + cameras: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RaycastScreenshotResult { + path: PathBuf, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RaycastCamera { + name: String, + camera: DeviceOrModelID, } pub fn handle(app_handle: &AppHandle, urls: Vec) { @@ -70,6 +109,7 @@ pub fn handle(app_handle: &AppHandle, urls: Vec) { }); } +#[derive(Debug)] pub enum ActionParseFromUrlError { ParseFailed(String), Invalid, @@ -88,9 +128,10 @@ impl TryFrom<&Url> for DeepLinkAction { .map_err(|_| ActionParseFromUrlError::Invalid); } - match url.domain() { + match url.host_str() { Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction), - _ => Err(ActionParseFromUrlError::Invalid), + Some(_) => Ok(()), + None => Err(ActionParseFromUrlError::Invalid), }?; let params = url @@ -120,18 +161,7 @@ impl DeepLinkAction { crate::set_camera_input(app.clone(), state.clone(), camera, None).await?; crate::set_mic_input(state.clone(), mic_label).await?; - let capture_target: ScreenCaptureTarget = match capture_mode { - CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays() - .into_iter() - .find(|(s, _)| s.name == name) - .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id }) - .ok_or(format!("No screen with name \"{}\"", &name))?, - CaptureMode::Window(name) => cap_recording::screen_capture::list_windows() - .into_iter() - .find(|(w, _)| w.name == name) - .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) - .ok_or(format!("No window with name \"{}\"", &name))?, - }; + let capture_target = capture_target_from_mode(capture_mode).await?; let inputs = StartRecordingInputs { mode, @@ -147,6 +177,33 @@ impl DeepLinkAction { DeepLinkAction::StopRecording => { crate::recording::stop_recording(app.clone(), app.state()).await } + DeepLinkAction::PauseRecording => { + crate::recording::pause_recording(app.clone(), app.state()).await + } + DeepLinkAction::ResumeRecording => { + crate::recording::resume_recording(app.clone(), app.state()).await + } + DeepLinkAction::TogglePauseRecording => { + crate::recording::toggle_pause_recording(app.clone(), app.state()).await + } + DeepLinkAction::RestartRecording => crate::recording::restart_recording( + app.clone(), + app.state(), + ) + .await + .map(|_| ()), + DeepLinkAction::TakeScreenshot { capture_mode } => { + let capture_target = capture_target_from_mode(capture_mode).await?; + let path = crate::recording::take_screenshot(app.clone(), capture_target).await?; + write_raycast_screenshot_result(app, path).await + } + DeepLinkAction::SetMicrophone { mic_label } => { + crate::set_mic_input(app.state(), mic_label).await + } + DeepLinkAction::SetCamera { camera } => { + crate::set_camera_input(app.clone(), app.state(), camera, Some(true)).await + } + DeepLinkAction::RefreshRaycastDeviceCache => refresh_raycast_device_cache(app).await, DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } @@ -156,3 +213,228 @@ impl DeepLinkAction { } } } + +async fn capture_target_from_mode(capture_mode: CaptureMode) -> Result { + tokio::task::spawn_blocking(move || match capture_mode { + CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays() + .into_iter() + .find(|(screen, _)| screen.name == name) + .map(|(screen, _)| ScreenCaptureTarget::Display { id: screen.id }) + .ok_or(format!("No screen with name \"{}\"", &name)), + CaptureMode::Window(name) => cap_recording::screen_capture::list_windows() + .into_iter() + .find(|(window, _)| window.name == name) + .map(|(window, _)| ScreenCaptureTarget::Window { id: window.id }) + .ok_or(format!("No window with name \"{}\"", &name)), + }) + .await + .map_err(|err| err.to_string())? +} + +async fn refresh_raycast_device_cache(app: &AppHandle) -> Result<(), String> { + let cache = tokio::task::spawn_blocking(|| { + let displays = cap_recording::screen_capture::list_displays() + .into_iter() + .map(|(display, _)| display.name) + .collect(); + let windows = cap_recording::screen_capture::list_windows() + .into_iter() + .map(|(window, _)| window.name) + .collect(); + let microphones = MicrophoneFeed::list().keys().cloned().collect(); + let cameras = cap_camera::list_cameras() + .map(|camera| RaycastCamera { + name: camera.display_name().to_string(), + camera: DeviceOrModelID::from_info(&camera), + }) + .collect(); + + RaycastDeviceCache { + displays, + windows, + microphones, + cameras, + } + }) + .await + .map_err(|err| err.to_string())?; + + write_raycast_json(app, "raycast-device-cache.json", &cache).await +} + +async fn write_raycast_screenshot_result(app: &AppHandle, path: PathBuf) -> Result<(), String> { + write_raycast_json( + app, + "raycast-last-screenshot.json", + &RaycastScreenshotResult { path }, + ) + .await +} + +async fn write_raycast_json( + app: &AppHandle, + file_name: &str, + value: &T, +) -> Result<(), String> { + let path = app + .path() + .app_data_dir() + .map_err(|err| err.to_string())? + .join(file_name); + let json = serde_json::to_vec_pretty(value).map_err(|err| err.to_string())?; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|err| err.to_string())?; + } + tokio::fs::write(path, json) + .await + .map_err(|err| err.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_action(value: serde_json::Value) -> DeepLinkAction { + let mut url = Url::parse("cap-desktop://action").unwrap(); + url.query_pairs_mut() + .append_pair("value", &value.to_string()); + + DeepLinkAction::try_from(&url).unwrap() + } + + #[test] + fn parses_action_host_deeplinks() { + assert_eq!( + parse_action(serde_json::json!("pause_recording")), + DeepLinkAction::PauseRecording + ); + assert_eq!( + parse_action(serde_json::json!("resume_recording")), + DeepLinkAction::ResumeRecording + ); + assert_eq!( + parse_action(serde_json::json!("toggle_pause_recording")), + DeepLinkAction::TogglePauseRecording + ); + assert_eq!( + parse_action(serde_json::json!("restart_recording")), + DeepLinkAction::RestartRecording + ); + assert_eq!( + parse_action(serde_json::json!("refresh_raycast_device_cache")), + DeepLinkAction::RefreshRaycastDeviceCache + ); + } + + #[test] + fn parses_nullable_input_selection_payloads() { + assert_eq!( + parse_action(serde_json::json!({ + "set_microphone": { + "mic_label": "MacBook Pro Microphone" + } + })), + DeepLinkAction::SetMicrophone { + mic_label: Some("MacBook Pro Microphone".to_string()) + } + ); + assert_eq!( + parse_action(serde_json::json!({ + "switch_microphone": { + "mic_label": "Desk Mic" + } + })), + DeepLinkAction::SetMicrophone { + mic_label: Some("Desk Mic".to_string()) + } + ); + assert_eq!( + parse_action(serde_json::json!({ + "set_microphone": { + "mic_label": null + } + })), + DeepLinkAction::SetMicrophone { mic_label: None } + ); + assert_eq!( + parse_action(serde_json::json!({ + "set_camera": { + "camera": { + "DeviceID": "camera-device-id" + } + } + })), + DeepLinkAction::SetCamera { + camera: Some(DeviceOrModelID::DeviceID("camera-device-id".to_string())) + } + ); + assert_eq!( + parse_action(serde_json::json!({ + "switch_camera": { + "camera": { + "ModelID": "AppleCamera-123" + } + } + })), + DeepLinkAction::SetCamera { + camera: Some(DeviceOrModelID::ModelID("AppleCamera-123".to_string())) + } + ); + assert_eq!( + parse_action(serde_json::json!({ + "set_camera": { + "camera": null + } + })), + DeepLinkAction::SetCamera { camera: None } + ); + } + + #[test] + fn parses_capture_payloads() { + assert_eq!( + parse_action(serde_json::json!({ + "take_screenshot": { + "capture_mode": { + "screen": "Built-in Display" + } + } + })), + DeepLinkAction::TakeScreenshot { + capture_mode: CaptureMode::Screen("Built-in Display".to_string()) + } + ); + assert_eq!( + parse_action(serde_json::json!({ + "start_recording": { + "capture_mode": { + "window": "Cap" + }, + "camera": null, + "mic_label": null, + "capture_system_audio": false, + "mode": "studio" + } + })), + DeepLinkAction::StartRecording { + capture_mode: CaptureMode::Window("Cap".to_string()), + camera: None, + mic_label: None, + capture_system_audio: false, + mode: RecordingMode::Studio, + } + ); + } + + #[test] + fn rejects_non_action_hosts_without_blocking_auth_links() { + let url = Url::parse("cap-desktop://signin?token=abc").unwrap(); + + assert!(matches!( + DeepLinkAction::try_from(&url), + Err(ActionParseFromUrlError::NotAction) + )); + } +}