Skip to content
Open
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
316 changes: 299 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,44 @@ pub enum DeepLinkAction {
OpenSettings {
page: Option<String>,
},
PauseRecording,
ResumeRecording,
TogglePauseRecording,
RestartRecording,
TakeScreenshot {
capture_mode: CaptureMode,
},
#[serde(alias = "switch_microphone")]
SetMicrophone {
mic_label: Option<String>,
},
#[serde(alias = "switch_camera")]
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 RaycastScreenshotResult {
path: PathBuf,
}

#[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 +109,7 @@ pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) {
});
}

#[derive(Debug)]
pub enum ActionParseFromUrlError {
ParseFailed(String),
Invalid,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Comment on lines +195 to +199
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 calls on async thread in TakeScreenshot

capture_target_from_mode calls list_displays() / list_windows() — synchronous system APIs — directly on the Tokio runtime thread. refresh_raycast_device_cache wraps the identical calls in spawn_blocking specifically to avoid stalling the runtime, but the same protection is missing here. On a system with many windows, list_windows() can take tens of milliseconds and will block the entire runtime thread for the duration.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 195-199

Comment:
**Blocking OS calls on async thread in `TakeScreenshot`**

`capture_target_from_mode` calls `list_displays()` / `list_windows()` — synchronous system APIs — directly on the Tokio runtime thread. `refresh_raycast_device_cache` wraps the identical calls in `spawn_blocking` specifically to avoid stalling the runtime, but the same protection is missing here. On a system with many windows, `list_windows()` can take tens of milliseconds and will block the entire runtime thread for the duration.

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

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())
}
Expand All @@ -156,3 +213,228 @@ impl DeepLinkAction {
}
}
}

async fn capture_target_from_mode(capture_mode: CaptureMode) -> Result<ScreenCaptureTarget, String> {
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<T: Serialize>(
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)
));
}
}