-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: Add Raycast Extension with Enhanced Deeplink Support (#1540) #1570
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
base: main
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -26,6 +26,17 @@ pub enum DeepLinkAction { | |
| mode: RecordingMode, | ||
| }, | ||
| StopRecording, | ||
| PauseRecording, | ||
| ResumeRecording, | ||
| TogglePauseRecording, | ||
| SwitchMicrophone { | ||
| mic_label: String, | ||
| }, | ||
| SwitchCamera { | ||
| camera: DeviceOrModelID, | ||
| }, | ||
| ListMicrophones, | ||
| ListCameras, | ||
| OpenEditor { | ||
| project_path: PathBuf, | ||
| }, | ||
|
|
@@ -146,6 +157,68 @@ 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::SwitchMicrophone { mic_label } => { | ||
| use cap_recording::feeds::microphone::MicrophoneFeed; | ||
|
|
||
| let available_mics = MicrophoneFeed::list(); | ||
| if !available_mics.contains_key(&mic_label) { | ||
| let available: Vec<String> = available_mics.keys().cloned().collect(); | ||
| return Err(format!( | ||
| "Microphone '{}' not found. Available microphones: {}", | ||
| mic_label, | ||
| available.join(", ") | ||
| )); | ||
| } | ||
|
|
||
| crate::set_mic_input(app.state(), Some(mic_label)).await | ||
| } | ||
| DeepLinkAction::SwitchCamera { camera } => { | ||
| let available_cameras: Vec<_> = cap_camera::list_cameras().collect(); | ||
| let camera_exists = available_cameras.iter().any(|c| { | ||
| c.device_id() == camera.device_id() | ||
| || camera | ||
| .model_id() | ||
| .map_or(false, |mid| Some(mid) == c.model_id()) | ||
| }); | ||
|
|
||
| if !camera_exists { | ||
| let available: Vec<String> = available_cameras | ||
| .iter() | ||
| .map(|c| format!("{} ({})", c.display_name(), c.device_id())) | ||
| .collect(); | ||
| return Err(format!( | ||
| "Camera not found. Available cameras: {}", | ||
| available.join(", ") | ||
| )); | ||
| } | ||
|
|
||
| crate::set_camera_input(app.clone(), app.state(), Some(camera), None) | ||
| .await | ||
| .map(|_| ()) | ||
| } | ||
| DeepLinkAction::ListMicrophones => { | ||
| let mics = list_available_microphones()?; | ||
| let json = serde_json::to_string(&mics) | ||
| .map_err(|e| format!("Failed to serialize microphones: {}", e))?; | ||
| println!("{}", json); | ||
| Ok(()) | ||
| } | ||
|
Comment on lines
+208
to
+214
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. stdout from deeplink handler won't reach external caller when Raycast calls
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 208:214
Comment:
stdout from deeplink handler won't reach external caller
when Raycast calls `open "cap-desktop://..."`, the `open` command doesn't capture stdout from the Cap app. `println!` output is lost. consider:
- returning via Tauri command instead of deeplink
- writing to temp file that caller can read
- emitting Tauri event with device list
<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>
How can I resolve this? If you propose a fix, please make it concise. |
||
| DeepLinkAction::ListCameras => { | ||
| let cameras = list_available_cameras()?; | ||
| let json = serde_json::to_string(&cameras) | ||
| .map_err(|e| format!("Failed to serialize cameras: {}", e))?; | ||
| println!("{}", json); | ||
| Ok(()) | ||
| } | ||
|
Comment on lines
+215
to
+221
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. same stdout issue - output won't reach Raycast extension
Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 215:221
Comment:
same stdout issue - output won't reach Raycast extension
`println!` in deeplink handler can't be captured by external process using `open` command
How can I resolve this? If you propose a fix, please make it concise. |
||
| DeepLinkAction::OpenEditor { project_path } => { | ||
| crate::open_project_from_path(Path::new(&project_path), app.clone()) | ||
| } | ||
|
|
@@ -155,3 +228,217 @@ impl DeepLinkAction { | |
| } | ||
| } | ||
| } | ||
|
|
||
| #[derive(Debug, Serialize, Deserialize)] | ||
| pub struct MicrophoneInfo { | ||
| pub label: String, | ||
| pub is_default: bool, | ||
| } | ||
|
|
||
| #[derive(Debug, Serialize, Deserialize)] | ||
| pub struct CameraInfo { | ||
| pub id: String, | ||
| pub name: String, | ||
| pub is_default: bool, | ||
| } | ||
|
|
||
| fn list_available_microphones() -> Result<Vec<MicrophoneInfo>, String> { | ||
| use cap_recording::feeds::microphone::MicrophoneFeed; | ||
|
|
||
| let mics: Vec<MicrophoneInfo> = MicrophoneFeed::list() | ||
| .into_iter() | ||
| .enumerate() | ||
| .map(|(idx, (label, _))| MicrophoneInfo { | ||
| label, | ||
| is_default: idx == 0, | ||
| }) | ||
|
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.
|
||
| .collect(); | ||
|
|
||
|
Comment on lines
+248
to
+256
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: 248:256
Comment:
`is_default` logic assumes first item is default but may be incorrect
`MicrophoneFeed::list()` returns `HashMap` which has undefined iteration order in Rust. first item from `.into_iter().enumerate()` is arbitrary, not necessarily the default mic. check if there's a proper API to get default device
How can I resolve this? If you propose a fix, please make it concise. |
||
| Ok(mics) | ||
| } | ||
|
|
||
| fn list_available_cameras() -> Result<Vec<CameraInfo>, String> { | ||
| let cameras: Vec<CameraInfo> = cap_camera::list_cameras() | ||
| .enumerate() | ||
| .map(|(idx, camera)| CameraInfo { | ||
| id: camera.device_id().to_string(), | ||
| name: camera.display_name().to_string(), | ||
| is_default: idx == 0, | ||
| }) | ||
| .collect(); | ||
|
|
||
| Ok(cameras) | ||
|
Comment on lines
+260
to
+270
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.
similar to microphones, first camera from iterator might not be the actual default. verify if Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 260:270
Comment:
`is_default` based on first iterator item may be incorrect
similar to microphones, first camera from iterator might not be the actual default. verify if `cap_camera::list_cameras()` returns cameras in a specific order or if there's an API to identify default
How can I resolve this? If you propose a fix, please make it concise. |
||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn test_pause_recording_deeplink_parsing() { | ||
| let json = r#"{"pause_recording":{}}"#; | ||
| let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); | ||
| let url = Url::parse(&url_str).unwrap(); | ||
|
|
||
| let action = DeepLinkAction::try_from(&url).unwrap(); | ||
| assert!(matches!(action, DeepLinkAction::PauseRecording)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_resume_recording_deeplink_parsing() { | ||
| let json = r#"{"resume_recording":{}}"#; | ||
| let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); | ||
| let url = Url::parse(&url_str).unwrap(); | ||
|
|
||
| let action = DeepLinkAction::try_from(&url).unwrap(); | ||
| assert!(matches!(action, DeepLinkAction::ResumeRecording)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_toggle_pause_recording_deeplink_parsing() { | ||
| let json = r#"{"toggle_pause_recording":{}}"#; | ||
| let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); | ||
| let url = Url::parse(&url_str).unwrap(); | ||
|
|
||
| let action = DeepLinkAction::try_from(&url).unwrap(); | ||
| assert!(matches!(action, DeepLinkAction::TogglePauseRecording)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_switch_microphone_deeplink_parsing() { | ||
| let json = r#"{"switch_microphone":{"mic_label":"Test Microphone"}}"#; | ||
| let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); | ||
| let url = Url::parse(&url_str).unwrap(); | ||
|
|
||
| let action = DeepLinkAction::try_from(&url).unwrap(); | ||
| match action { | ||
| DeepLinkAction::SwitchMicrophone { mic_label } => { | ||
| assert_eq!(mic_label, "Test Microphone"); | ||
| } | ||
| _ => panic!("Expected SwitchMicrophone action"), | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_switch_camera_deeplink_parsing() { | ||
| let json = r#"{"switch_camera":{"camera":{"device_id":"test-camera-id"}}}"#; | ||
| let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); | ||
| let url = Url::parse(&url_str).unwrap(); | ||
|
|
||
| let action = DeepLinkAction::try_from(&url).unwrap(); | ||
| match action { | ||
| DeepLinkAction::SwitchCamera { camera } => { | ||
| assert_eq!(camera.device_id(), "test-camera-id"); | ||
| } | ||
| _ => panic!("Expected SwitchCamera action"), | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_list_microphones_deeplink_parsing() { | ||
| let json = r#"{"list_microphones":{}}"#; | ||
| let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); | ||
| let url = Url::parse(&url_str).unwrap(); | ||
|
|
||
| let action = DeepLinkAction::try_from(&url).unwrap(); | ||
| assert!(matches!(action, DeepLinkAction::ListMicrophones)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_list_cameras_deeplink_parsing() { | ||
| let json = r#"{"list_cameras":{}}"#; | ||
| let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); | ||
| let url = Url::parse(&url_str).unwrap(); | ||
|
|
||
| let action = DeepLinkAction::try_from(&url).unwrap(); | ||
| assert!(matches!(action, DeepLinkAction::ListCameras)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_invalid_json_returns_error() { | ||
| let invalid_json = r#"{"invalid_json"#; | ||
| let url_str = format!( | ||
| "cap-desktop://action?value={}", | ||
| urlencoding::encode(invalid_json) | ||
| ); | ||
| let url = Url::parse(&url_str).unwrap(); | ||
|
|
||
| let result = DeepLinkAction::try_from(&url); | ||
| assert!(result.is_err()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_missing_value_parameter_returns_error() { | ||
| let url = Url::parse("cap-desktop://action").unwrap(); | ||
| let result = DeepLinkAction::try_from(&url); | ||
| assert!(result.is_err()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_non_action_domain_returns_not_action_error() { | ||
| let url = Url::parse("cap-desktop://other?value=test").unwrap(); | ||
| let result = DeepLinkAction::try_from(&url); | ||
| assert!(result.is_err()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_deeplink_round_trip_pause() { | ||
| let original = DeepLinkAction::PauseRecording; | ||
| let json = serde_json::to_string(&original).unwrap(); | ||
| let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(&json)); | ||
| let url = Url::parse(&url_str).unwrap(); | ||
| let parsed = DeepLinkAction::try_from(&url).unwrap(); | ||
|
|
||
| assert!(matches!(parsed, DeepLinkAction::PauseRecording)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_deeplink_round_trip_switch_microphone() { | ||
| let original = DeepLinkAction::SwitchMicrophone { | ||
| mic_label: "Test Mic".to_string(), | ||
| }; | ||
| let json = serde_json::to_string(&original).unwrap(); | ||
| let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(&json)); | ||
| let url = Url::parse(&url_str).unwrap(); | ||
| let parsed = DeepLinkAction::try_from(&url).unwrap(); | ||
|
|
||
| match parsed { | ||
| DeepLinkAction::SwitchMicrophone { mic_label } => { | ||
| assert_eq!(mic_label, "Test Mic"); | ||
| } | ||
| _ => panic!("Expected SwitchMicrophone action"), | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_url_encoding_special_characters() { | ||
| let mic_label = "Test Mic (Built-in)"; | ||
| let original = DeepLinkAction::SwitchMicrophone { | ||
| mic_label: mic_label.to_string(), | ||
| }; | ||
| let json = serde_json::to_string(&original).unwrap(); | ||
| let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(&json)); | ||
| let url = Url::parse(&url_str).unwrap(); | ||
| let parsed = DeepLinkAction::try_from(&url).unwrap(); | ||
|
|
||
| match parsed { | ||
| DeepLinkAction::SwitchMicrophone { | ||
| mic_label: parsed_label, | ||
| } => { | ||
| assert_eq!(parsed_label, mic_label); | ||
| } | ||
| _ => panic!("Expected SwitchMicrophone action"), | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_serialization_maintains_snake_case() { | ||
| let action = DeepLinkAction::PauseRecording; | ||
| let json = serde_json::to_string(&action).unwrap(); | ||
| assert!(json.contains("pause_recording")); | ||
|
|
||
| let action = DeepLinkAction::TogglePauseRecording; | ||
| let json = serde_json::to_string(&action).unwrap(); | ||
| assert!(json.contains("toggle_pause_recording")); | ||
| } | ||
| } | ||
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.
Printing JSON to stdout is unlikely to be observable by the deeplink caller (and a packaged Tauri app may not have a visible stdout). If this is meant to feed the Raycast extension, consider writing to a deterministic location (app data dir / temp file / clipboard) that Raycast can read.