-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat(desktop): add deeplink control actions #1804
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,36 @@ pub enum DeepLinkAction { | |
| OpenSettings { | ||
| page: Option<String>, | ||
| }, | ||
| PauseRecording, | ||
| ResumeRecording, | ||
| TogglePauseRecording, | ||
| RestartRecording, | ||
| TakeScreenshot { | ||
| capture_mode: CaptureMode, | ||
| }, | ||
| SetMicrophone { | ||
| mic_label: Option<String>, | ||
| }, | ||
| SetCamera { | ||
| camera: Option<DeviceOrModelID>, | ||
| }, | ||
| RefreshRaycastDeviceCache, | ||
| } | ||
|
|
||
| #[derive(Serialize)] | ||
| #[serde(rename_all = "camelCase")] | ||
| struct RaycastDeviceCache { | ||
| displays: Vec<String>, | ||
| windows: Vec<String>, | ||
| microphones: Vec<String>, | ||
| cameras: Vec<RaycastCamera>, | ||
| } | ||
|
|
||
| #[derive(Serialize)] | ||
| #[serde(rename_all = "camelCase")] | ||
| struct RaycastCamera { | ||
| name: String, | ||
| camera: DeviceOrModelID, | ||
| } | ||
|
|
||
| pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) { | ||
|
|
@@ -70,6 +101,7 @@ pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) { | |
| }); | ||
| } | ||
|
|
||
| #[derive(Debug)] | ||
| pub enum ActionParseFromUrlError { | ||
| ParseFailed(String), | ||
| Invalid, | ||
|
|
@@ -88,9 +120,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 +153,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)?; | ||
|
|
||
| let inputs = StartRecordingInputs { | ||
| mode, | ||
|
|
@@ -147,6 +169,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)?; | ||
| crate::recording::take_screenshot(app.clone(), capture_target) | ||
| .await | ||
| .map(|_| ()) | ||
| } | ||
| 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, None).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 +205,181 @@ impl DeepLinkAction { | |
| } | ||
| } | ||
| } | ||
|
|
||
| fn capture_target_from_mode(capture_mode: CaptureMode) -> Result<ScreenCaptureTarget, String> { | ||
| 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)), | ||
| } | ||
| } | ||
|
|
||
| async fn refresh_raycast_device_cache(app: &AppHandle) -> Result<(), String> { | ||
| 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(); | ||
| let cache = RaycastDeviceCache { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 224-240
Comment:
**Blocking OS APIs on async task thread**
`list_displays()`, `list_windows()`, `MicrophoneFeed::list()`, and `cap_camera::list_cameras()` are all synchronous, potentially slow OS API calls executed directly on a Tokio async task thread. Camera and audio enumeration in particular can stall the entire Tauri runtime while they run. Since this action is designed to be called frequently from Raycast, the risk of stalls is higher than it is for `StartRecording`. Consider wrapping the enumeration block in `tokio::task::spawn_blocking(|| { ... }).await` to keep the runtime free.
How can I resolve this? If you propose a fix, please make it concise.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 7f1c1bc: the synchronous display/window/microphone/camera enumeration now runs inside tokio::task::spawn_blocking, then the device cache write happens asynchronously after the blocking result returns. |
||
| displays, | ||
| windows, | ||
| microphones, | ||
| cameras, | ||
| }; | ||
| let cache_path = app | ||
| .path() | ||
| .app_data_dir() | ||
| .map_err(|err| err.to_string())? | ||
| .join("raycast-device-cache.json"); | ||
| let json = serde_json::to_vec_pretty(&cache).map_err(|err| err.to_string())?; | ||
| if let Some(parent) = cache_path.parent() { | ||
| tokio::fs::create_dir_all(parent) | ||
| .await | ||
| .map_err(|err| err.to_string())?; | ||
| } | ||
| tokio::fs::write(cache_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!({ | ||
| "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!({ | ||
| "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) | ||
| )); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TakeScreenshotsilently drops the output pathtake_screenshotreturnsResult<PathBuf, String>and this arm discards thePathBufwith.map(|_| ()). That's fine for the deeplink contract, but there's no signal back to the Raycast caller whether the capture succeeded or where the file landed. Worth confirming whether Raycast needs the resulting path or whether fire-and-forget is intentional here. Is fire-and-forget acceptable for TakeScreenshot from Raycast, or does the extension need the output path?Prompt To Fix With AI
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in 7f1c1bc: TakeScreenshot now records the returned screenshot path to raycast-last-screenshot.json in the desktop app data directory, using the same JSON writer pattern as the Raycast device cache.