Skip to content

Add deeplink support for pause, resume, mic and camera switching (#1540) - #2051

Open
BMNTR wants to merge 10 commits into
CapSoftware:mainfrom
BMNTR:fix/deeplink-actions-raycast-support
Open

Add deeplink support for pause, resume, mic and camera switching (#1540)#2051
BMNTR wants to merge 10 commits into
CapSoftware:mainfrom
BMNTR:fix/deeplink-actions-raycast-support

Conversation

@BMNTR

@BMNTR BMNTR commented Jul 29, 2026

Copy link
Copy Markdown

Exposes deep link action handlers for recording controls and input switching to enable Raycast integration, resolving #1540.

Changes

  • Un-gated PauseRecording, ResumeRecording, OpenCamera, and SetCameraPreviewState from debug_assertions so they work in production release builds.
  • Added SwitchMicrophone deep link action to switch active microphone dynamically.
  • Supported both action-value JSON parameters and direct path URL schemes (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.

  • Enables pause, resume, camera opening, and camera-preview actions in release builds.
  • Adds microphone switching and direct-host URL parsing for recording and input actions.

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 stated cap://... 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

Filename Overview
apps/desktop/src-tauri/src/deeplink_actions.rs Adds the new action variants, URL routes, and execution paths, but the documented cap:// direct links are not backed by a registered scheme.
Prompt To Fix All With AI
### Issue 1
apps/desktop/src-tauri/src/deeplink_actions.rs:178-203
**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.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix: expose deeplinks for pause, resume,..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

@superagent-security superagent-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Superagent found 1 security concern(s).

let action: Self = serde_json::from_str(json_value)
.map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?;
Ok(action)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>

@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label Jul 29, 2026
Comment on lines +178 to +203
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),
})
}

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.

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

Comment thread apps/desktop/src-tauri/src/deeplink_actions.rs Outdated
.await
.map_err(|e| e.to_string())
}
DeepLinkAction::OpenCamera { camera } => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread apps/desktop/src-tauri/src/deeplink_actions.rs
BMNTR and others added 2 commits July 29, 2026 19:15
Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com>
Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com>
@superagent-security superagent-security Bot added pr:flagged PR flagged for review by security analysis. and removed pr:flagged PR flagged for review by security analysis. labels Jul 29, 2026
Comment on lines 57 to 61
SetCameraPreviewState {
state: CameraPreviewState,
SetCameraPreviewState {
state: crate::camera::CameraPreviewState,
},
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This SetCameraPreviewState variant looks duplicated (and the braces don’t balance), so it won’t compile.

Suggested change
SetCameraPreviewState {
state: CameraPreviewState,
SetCameraPreviewState {
state: crate::camera::CameraPreviewState,
},
},
SetCameraPreviewState {
state: crate::camera::CameraPreviewState,
},

Comment thread apps/desktop/src-tauri/src/deeplink_actions.rs Outdated
BMNTR and others added 2 commits July 29, 2026 19:18
"deep-link": {
"desktop": {
"schemes": ["cap-desktop"]
"schemes": ["cap-desktop", "cap"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread apps/desktop/src-tauri/src/deeplink_actions.rs Outdated
Comment thread apps/desktop/src-tauri/src/deeplink_actions.rs
}

#[cfg(debug_assertions)]
#[test]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@superagent-security superagent-security Bot removed the pr:flagged PR flagged for review by security analysis. label Jul 29, 2026
@BMNTR
BMNTR force-pushed the fix/deeplink-actions-raycast-support branch from 8d7e097 to 1a5b943 Compare July 29, 2026 12:26
Comment thread apps/desktop/src-tauri/src/deeplink_actions.rs
@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label Jul 29, 2026
Comment thread apps/desktop/src-tauri/src/deeplink_actions.rs
@BMNTR
BMNTR force-pushed the fix/deeplink-actions-raycast-support branch from fecb7e4 to 5509218 Compare July 29, 2026 12:30
Comment thread apps/desktop/src-tauri/src/deeplink_actions.rs
#[cfg(debug_assertions)]
SetCameraPreviewState {
state: CameraPreviewState,
state: crate::camera::CameraPreviewState,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment thread apps/desktop/src-tauri/src/deeplink_actions.rs
Comment thread apps/desktop/src-tauri/src/deeplink_actions.rs Outdated
Comment thread apps/desktop/src-tauri/src/deeplink_actions.rs
Comment thread apps/desktop/src-tauri/src/deeplink_actions.rs
@BMNTR
BMNTR force-pushed the fix/deeplink-actions-raycast-support branch from 26db394 to bf82662 Compare July 29, 2026 12:48
@superagent-security superagent-security Bot added pr:flagged PR flagged for review by security analysis. and removed pr:flagged PR flagged for review by security analysis. labels Jul 29, 2026
Comment on lines +163 to +232
.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),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

Suggested change
.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),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@BMNTR
BMNTR force-pushed the fix/deeplink-actions-raycast-support branch from 730e3e2 to d1230f4 Compare July 29, 2026 16:42
#[cfg(debug_assertions)]
SetCameraPreviewState {
state: CameraPreviewState,
state: crate::camera::CameraPreviewState,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 } => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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, ...)).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🙋 Bounty claim pr:flagged PR flagged for review by security analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant