Add deeplink support for pause, resume, mic and camera switching (#1540) - #2051
Add deeplink support for pause, resume, mic and camera switching (#1540)#2051BMNTR wants to merge 10 commits into
Conversation
| let action: Self = serde_json::from_str(json_value) | ||
| .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?; | ||
| Ok(action) | ||
| } |
There was a problem hiding this comment.
P1: Sensitive recording controls exposed in production via unauthenticated deep links
Sensitive recording controls now exposed via unauthenticated deep links in production builds.
Add authorization checks or user confirmation before executing sensitive controls via deep links.
AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.
<file name="apps/desktop/src-tauri/src/deeplink_actions.rs">
<violation number="1" location="apps/desktop/src-tauri/src/deeplink_actions.rs:177">
<priority>P1</priority>
<title>Sensitive recording controls exposed in production via unauthenticated deep links</title>
<evidence>The PR removes #[cfg(debug_assertions)] guards from PauseRecording, ResumeRecording, OpenCamera, and SetCameraPreviewState, and adds direct URL path handlers (cap://pause, cap://resume, cap://switch-mic, cap://switch-camera) that allow any application or webpage to trigger these sensitive controls without authentication. The original debug guards indicate these actions were intentionally restricted from production builds.</evidence>
<recommendation>Add an authorization check before executing sensitive recording controls via deep links, such as requiring user confirmation or a signed/validated token in the URL. Alternatively, gate these actions behind debug assertions or a developer mode toggle unless there is explicit user consent for each invocation.</recommendation>
</violation>
</file>
| Some("stop" | "stop-recording") => Ok(Self::StopRecording), | ||
| Some("pause" | "pause-recording") => Ok(Self::PauseRecording), | ||
| Some("resume" | "resume-recording") => Ok(Self::ResumeRecording), | ||
| Some("switch-mic" | "switch-microphone") => { | ||
| let params = url | ||
| .query_pairs() | ||
| .collect::<std::collections::HashMap<_, _>>(); | ||
| let mic_label = params | ||
| .get("mic_label") | ||
| .or_else(|| params.get("label")) | ||
| .map(|s| s.to_string()); | ||
| Ok(Self::SwitchMicrophone { mic_label }) | ||
| } | ||
| Some("open-camera" | "switch-camera") => { | ||
| let params = url | ||
| .query_pairs() | ||
| .collect::<std::collections::HashMap<_, _>>(); | ||
| let device_id = params | ||
| .get("id") | ||
| .or_else(|| params.get("camera")) | ||
| .map(|s| s.to_string()) | ||
| .ok_or(ActionParseFromUrlError::Invalid)?; | ||
| Ok(Self::OpenCamera { | ||
| camera: DeviceOrModelID::DeviceID(device_id), | ||
| }) | ||
| } |
There was a problem hiding this comment.
Direct-link scheme is unregistered
When Raycast invokes a documented direct URL such as cap://pause or cap://switch-mic?label=..., the operating system cannot route it to Cap because the application registers only the cap-desktop scheme, so none of these new action handlers executes.
Knowledge Base Used: Desktop Tauri App (Rust Backend)
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 178-203
Comment:
**Direct-link scheme is unregistered**
When Raycast invokes a documented direct URL such as `cap://pause` or `cap://switch-mic?label=...`, the operating system cannot route it to Cap because the application registers only the `cap-desktop` scheme, so none of these new action handlers executes.
**Knowledge Base Used:** [Desktop Tauri App (Rust Backend)](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/desktop-tauri-app.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| .await | ||
| .map_err(|e| e.to_string()) | ||
| } | ||
| DeepLinkAction::OpenCamera { camera } => { |
There was a problem hiding this comment.
Since this arm is no longer #[cfg(debug_assertions)], double-check release builds still compile: app.emit(...) is provided by tauri::Emitter, but the use tauri::Emitter; import above is still debug-only. Easiest fix is to un-gate that import or switch to UFCS (tauri::Emitter::emit(app, ...)) so the trait doesn’t need to be in scope.
Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com>
Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com>
| SetCameraPreviewState { | ||
| state: CameraPreviewState, | ||
| SetCameraPreviewState { | ||
| state: crate::camera::CameraPreviewState, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
This SetCameraPreviewState variant looks duplicated (and the braces don’t balance), so it won’t compile.
| SetCameraPreviewState { | |
| state: CameraPreviewState, | |
| SetCameraPreviewState { | |
| state: crate::camera::CameraPreviewState, | |
| }, | |
| }, | |
| SetCameraPreviewState { | |
| state: crate::camera::CameraPreviewState, | |
| }, |
Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com>
| "deep-link": { | ||
| "desktop": { | ||
| "schemes": ["cap-desktop"] | ||
| "schemes": ["cap-desktop", "cap"] |
There was a problem hiding this comment.
Just sanity-checking production: this repo also has tauri.prod.conf.json. If release builds use that file instead of merging with tauri.conf.json, the new cap scheme won’t be registered in production and cap://... links still won’t route. Might be worth either confirming config merge behavior in the build pipeline, or duplicating the deep-link plugin config into the prod config.
| } | ||
|
|
||
| #[cfg(debug_assertions)] | ||
| #[test] |
There was a problem hiding this comment.
Since the new public contract is the direct host routes (e.g. cap://pause, cap://switch-mic?label=..., cap://switch-camera?id=...), it’d be nice to add a couple small tests for those alongside the existing cap-desktop://action?... coverage.
8d7e097 to
1a5b943
Compare
fecb7e4 to
5509218
Compare
| #[cfg(debug_assertions)] | ||
| SetCameraPreviewState { | ||
| state: CameraPreviewState, | ||
| state: crate::camera::CameraPreviewState, |
There was a problem hiding this comment.
Minor CI/clippy thing: now that this uses crate::camera::CameraPreviewState directly, the #[cfg(debug_assertions)] use crate::camera::CameraPreviewState; import near the top becomes unused in cargo clippy -- -D warnings (debug profile). Might be worth deleting that import (or un-gating + using it).
26db394 to
bf82662
Compare
| .map_err(|_| ActionParseFromUrlError::Invalid); | ||
| } | ||
|
|
||
| match url.domain() { | ||
| Some("action") => {} | ||
| Some(_) => return Err(ActionParseFromUrlError::NotAction), | ||
| None => return Err(ActionParseFromUrlError::Invalid), | ||
| fn try_from(url: &Url) -> Result<Self, Self::Error> { | ||
| #[cfg(target_os = "macos")] | ||
| if url.scheme() == "file" { | ||
| return url | ||
| .to_file_path() | ||
| .map(|project_path| Self::OpenEditor { project_path }) | ||
| .map_err(|_| ActionParseFromUrlError::Invalid); | ||
| } | ||
|
|
||
| let params = url | ||
| .query_pairs() | ||
| .collect::<std::collections::HashMap<_, _>>(); | ||
| let json_value = params | ||
| .get("value") | ||
| .ok_or(ActionParseFromUrlError::Invalid)?; | ||
| let action: Self = serde_json::from_str(json_value) | ||
| .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?; | ||
| Ok(action) | ||
| fn try_from(url: &Url) -> Result<Self, Self::Error> { | ||
| #[cfg(target_os = "macos")] | ||
| if url.scheme() == "file" { | ||
| return url | ||
| .to_file_path() | ||
| .map(|project_path| Self::OpenEditor { project_path }) | ||
| .map_err(|_| ActionParseFromUrlError::Invalid); | ||
| } | ||
|
|
||
| if !matches!(url.scheme(), "cap" | "cap-desktop") { | ||
| return Err(ActionParseFromUrlError::NotAction); | ||
| } | ||
| return Err(ActionParseFromUrlError::NotAction); | ||
| } | ||
| return Err(ActionParseFromUrlError::NotAction); | ||
| } | ||
|
|
||
| match url.host_str() { | ||
| Some("action") => { | ||
| let params = url | ||
| .query_pairs() | ||
| .collect::<std::collections::HashMap<_, _>>(); | ||
| let json_value = params | ||
| .get("value") | ||
| .ok_or(ActionParseFromUrlError::Invalid)?; | ||
| let action: Self = serde_json::from_str(json_value) | ||
| .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?; | ||
| Ok(action) | ||
| } | ||
| Some("stop" | "stop-recording") => Ok(Self::StopRecording), | ||
| Some("pause" | "pause-recording") => Ok(Self::PauseRecording), | ||
| Some("resume" | "resume-recording") => Ok(Self::ResumeRecording), | ||
| Some("switch-mic" | "switch-microphone") => { | ||
| let params = url | ||
| .query_pairs() | ||
| .collect::<std::collections::HashMap<_, _>>(); | ||
| let mic_label = params | ||
| .get("mic_label") | ||
| .or_else(|| params.get("label")) | ||
| .and_then(|s| (!s.is_empty()).then(|| s.to_string())); | ||
| Ok(Self::SwitchMicrophone { mic_label }) | ||
| } | ||
| Some("open-camera" | "switch-camera") => { | ||
| let params = url | ||
| .query_pairs() | ||
| .collect::<std::collections::HashMap<_, _>>(); | ||
| let device_id = params | ||
| .get("id") | ||
| .or_else(|| params.get("camera")) | ||
| .and_then(|s| (!s.is_empty()).then(|| s.to_string())) | ||
| .ok_or(ActionParseFromUrlError::Invalid)?; | ||
| Ok(Self::OpenCamera { | ||
| camera: DeviceOrModelID::DeviceID(device_id), | ||
| }) | ||
| } | ||
| Some(_) => Err(ActionParseFromUrlError::NotAction), | ||
| None => Err(ActionParseFromUrlError::Invalid), | ||
| } |
There was a problem hiding this comment.
Looks like fn try_from got duplicated a few times here (and there are a couple stray } / return Err(...) lines), so this TryFrom<&Url> impl won’t compile. I think this just needs to be collapsed back to a single try_from:
| .map_err(|_| ActionParseFromUrlError::Invalid); | |
| } | |
| match url.domain() { | |
| Some("action") => {} | |
| Some(_) => return Err(ActionParseFromUrlError::NotAction), | |
| None => return Err(ActionParseFromUrlError::Invalid), | |
| fn try_from(url: &Url) -> Result<Self, Self::Error> { | |
| #[cfg(target_os = "macos")] | |
| if url.scheme() == "file" { | |
| return url | |
| .to_file_path() | |
| .map(|project_path| Self::OpenEditor { project_path }) | |
| .map_err(|_| ActionParseFromUrlError::Invalid); | |
| } | |
| let params = url | |
| .query_pairs() | |
| .collect::<std::collections::HashMap<_, _>>(); | |
| let json_value = params | |
| .get("value") | |
| .ok_or(ActionParseFromUrlError::Invalid)?; | |
| let action: Self = serde_json::from_str(json_value) | |
| .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?; | |
| Ok(action) | |
| fn try_from(url: &Url) -> Result<Self, Self::Error> { | |
| #[cfg(target_os = "macos")] | |
| if url.scheme() == "file" { | |
| return url | |
| .to_file_path() | |
| .map(|project_path| Self::OpenEditor { project_path }) | |
| .map_err(|_| ActionParseFromUrlError::Invalid); | |
| } | |
| if !matches!(url.scheme(), "cap" | "cap-desktop") { | |
| return Err(ActionParseFromUrlError::NotAction); | |
| } | |
| return Err(ActionParseFromUrlError::NotAction); | |
| } | |
| return Err(ActionParseFromUrlError::NotAction); | |
| } | |
| match url.host_str() { | |
| Some("action") => { | |
| let params = url | |
| .query_pairs() | |
| .collect::<std::collections::HashMap<_, _>>(); | |
| let json_value = params | |
| .get("value") | |
| .ok_or(ActionParseFromUrlError::Invalid)?; | |
| let action: Self = serde_json::from_str(json_value) | |
| .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?; | |
| Ok(action) | |
| } | |
| Some("stop" | "stop-recording") => Ok(Self::StopRecording), | |
| Some("pause" | "pause-recording") => Ok(Self::PauseRecording), | |
| Some("resume" | "resume-recording") => Ok(Self::ResumeRecording), | |
| Some("switch-mic" | "switch-microphone") => { | |
| let params = url | |
| .query_pairs() | |
| .collect::<std::collections::HashMap<_, _>>(); | |
| let mic_label = params | |
| .get("mic_label") | |
| .or_else(|| params.get("label")) | |
| .and_then(|s| (!s.is_empty()).then(|| s.to_string())); | |
| Ok(Self::SwitchMicrophone { mic_label }) | |
| } | |
| Some("open-camera" | "switch-camera") => { | |
| let params = url | |
| .query_pairs() | |
| .collect::<std::collections::HashMap<_, _>>(); | |
| let device_id = params | |
| .get("id") | |
| .or_else(|| params.get("camera")) | |
| .and_then(|s| (!s.is_empty()).then(|| s.to_string())) | |
| .ok_or(ActionParseFromUrlError::Invalid)?; | |
| Ok(Self::OpenCamera { | |
| camera: DeviceOrModelID::DeviceID(device_id), | |
| }) | |
| } | |
| Some(_) => Err(ActionParseFromUrlError::NotAction), | |
| None => Err(ActionParseFromUrlError::Invalid), | |
| } | |
| fn try_from(url: &Url) -> Result<Self, Self::Error> { | |
| #[cfg(target_os = "macos")] | |
| if url.scheme() == "file" { | |
| return url | |
| .to_file_path() | |
| .map(|project_path| Self::OpenEditor { project_path }) | |
| .map_err(|_| ActionParseFromUrlError::Invalid); | |
| } | |
| if !matches!(url.scheme(), "cap" | "cap-desktop") { | |
| return Err(ActionParseFromUrlError::NotAction); | |
| } | |
| match url.host_str() { | |
| Some("action") => { | |
| let params = url | |
| .query_pairs() | |
| .collect::<std::collections::HashMap<_, _>>(); | |
| let json_value = params | |
| .get("value") | |
| .ok_or(ActionParseFromUrlError::Invalid)?; | |
| let action: Self = serde_json::from_str(json_value) | |
| .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?; | |
| Ok(action) | |
| } | |
| Some("stop" | "stop-recording") => Ok(Self::StopRecording), | |
| Some("pause" | "pause-recording") => Ok(Self::PauseRecording), | |
| Some("resume" | "resume-recording") => Ok(Self::ResumeRecording), | |
| Some("switch-mic" | "switch-microphone") => { | |
| let params = url | |
| .query_pairs() | |
| .collect::<std::collections::HashMap<_, _>>(); | |
| let mic_label = params | |
| .get("mic_label") | |
| .or_else(|| params.get("label")) | |
| .and_then(|s| (!s.is_empty()).then(|| s.to_string())); | |
| Ok(Self::SwitchMicrophone { mic_label }) | |
| } | |
| Some("open-camera" | "switch-camera") => { | |
| let params = url | |
| .query_pairs() | |
| .collect::<std::collections::HashMap<_, _>>(); | |
| let device_id = params | |
| .get("id") | |
| .or_else(|| params.get("camera")) | |
| .and_then(|s| (!s.is_empty()).then(|| s.to_string())) | |
| .ok_or(ActionParseFromUrlError::Invalid)?; | |
| Ok(Self::OpenCamera { | |
| camera: DeviceOrModelID::DeviceID(device_id), | |
| }) | |
| } | |
| Some(_) => Err(ActionParseFromUrlError::NotAction), | |
| None => Err(ActionParseFromUrlError::Invalid), | |
| } | |
| } |
There was a problem hiding this comment.
impl TryFrom<&Url> for DeepLinkAction looks accidentally corrupted right now (multiple nested fn try_from(...) declarations + stray return Err(...) blocks). As-is it won’t compile.
I’d collapse it back to a single fn try_from(url: &Url) -> Result<...> body (keeping the macOS file:// early return) and then keep the scheme/host routing logic inside that one function.
730e3e2 to
d1230f4
Compare
| #[cfg(debug_assertions)] | ||
| SetCameraPreviewState { | ||
| state: CameraPreviewState, | ||
| state: crate::camera::CameraPreviewState, |
There was a problem hiding this comment.
Now that the enum uses crate::camera::CameraPreviewState directly, the #[cfg(debug_assertions)] use crate::camera::CameraPreviewState; import at the top becomes unused; worth removing if this crate denies warnings in CI.
| .await | ||
| .map_err(|e| e.to_string()) | ||
| } | ||
| DeepLinkAction::OpenCamera { camera } => { |
There was a problem hiding this comment.
Since OpenCamera is now reachable in release builds, note this arm calls app.emit(...) further down. That requires the tauri::Emitter trait to be in scope, but the import at the top is still #[cfg(debug_assertions)] — release builds will fail unless you un-gate that import (or switch to tauri::Emitter::emit(app, ...)).
Exposes deep link action handlers for recording controls and input switching to enable Raycast integration, resolving #1540.
Changes
PauseRecording,ResumeRecording,OpenCamera, andSetCameraPreviewStatefromdebug_assertionsso they work in production release builds.SwitchMicrophonedeep link action to switch active microphone dynamically.cap://pause,cap://resume,cap://switch-mic?label=...,cap://switch-camera?id=...).Tested deep link URL parsing and action dispatching locally.
/claim #1540
Greptile Summary
Adds production deep-link support for recording controls and input selection.
Confidence Score: 3/5
This PR should not merge until the documented direct-link scheme is registered or the integration URLs are changed to the existing registered scheme.
The new handlers are reachable through
cap-desktop://..., but callers using the statedcap://...contract will never reach the application or execute any recording control.Files Needing Attention: apps/desktop/src-tauri/src/deeplink_actions.rs and apps/desktop/src-tauri/tauri.conf.json
Important Files Changed
cap://direct links are not backed by a registered scheme.Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "fix: expose deeplinks for pause, resume,..." | Re-trigger Greptile
Context used: