Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
287 changes: 287 additions & 0 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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(())
Copy link

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.

}
Comment on lines +208 to +214
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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 AI
This 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same stdout issue - output won't reach Raycast extension

println! in deeplink handler can't be captured by external process using open command

Prompt To Fix With AI
This 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())
}
Expand All @@ -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,
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_default: idx == 0 depends on iterator order (MicrophoneFeed::list() / list_cameras()), which may not reflect the actual system default. Might be better to compute the real default (if available) or omit this field to avoid misleading clients.

.collect();

Comment on lines +248 to +256
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Prompt To Fix With AI
This 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Prompt To Fix With AI
This 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"));
}
}
Loading