Skip to content
Closed
Changes from 1 commit
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
261 changes: 244 additions & 17 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
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};
Expand All @@ -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 {
Expand All @@ -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>) {
Expand Down Expand Up @@ -70,6 +101,7 @@ pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) {
});
}

#[derive(Debug)]
pub enum ActionParseFromUrlError {
ParseFailed(String),
Invalid,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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(|_| ())
}
Comment on lines +192 to +196
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 TakeScreenshot silently drops the output path

take_screenshot returns Result<PathBuf, String> and this arm discards the PathBuf with .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
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 186-191

Comment:
**`TakeScreenshot` silently drops the output path**

`take_screenshot` returns `Result<PathBuf, String>` and this arm discards the `PathBuf` with `.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?

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

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.

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())
}
Expand All @@ -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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 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.

Prompt To Fix With AI
This 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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)
));
}
}