Skip to content

feat: add deeplink controls and Raycast extension#1838

Open
itsbryanman wants to merge 3 commits into
CapSoftware:mainfrom
itsbryanman:feat/deeplinks-raycast
Open

feat: add deeplink controls and Raycast extension#1838
itsbryanman wants to merge 3 commits into
CapSoftware:mainfrom
itsbryanman:feat/deeplinks-raycast

Conversation

@itsbryanman
Copy link
Copy Markdown

@itsbryanman itsbryanman commented May 18, 2026

Summary

  • add semantic desktop deeplinks for recording control and device switching while preserving existing cap-desktop://action?value=... and cap-desktop://signin?... behavior
  • route deeplink recording actions through the existing desktop recording/device command paths, including pause-before-device-switch behavior and recording event emission
  • add apps/desktop/src-tauri/DEEPLINKS.md plus a new apps/raycast extension with cap-control and switch-device commands

Deeplink URLs

  • cap-desktop://record/start
  • cap-desktop://record/start?mode=studio
  • cap-desktop://record/start?mode=instant
  • cap-desktop://record/stop
  • cap-desktop://record/pause
  • cap-desktop://record/resume
  • cap-desktop://record/toggle-pause
  • cap-desktop://device/microphone?label=<device-label>
  • cap-desktop://device/microphone?off=true
  • cap-desktop://device/camera?model_id=<vid:pid>
  • cap-desktop://device/camera?device_id=<unique-id>
  • cap-desktop://device/camera?id=<unique-id>
  • cap-desktop://device/camera?label=<display-name>
  • cap-desktop://device/camera?off=true

Validation

  • cargo fmt --all
  • cargo check -p cap-desktop
    • blocked on this Linux host before reaching the app crate because GTK desktop system libraries are missing: pango, gdk-3.0, cairo, gdk-pixbuf-2.0
  • ./node_modules/.bin/tsc -p apps/raycast/tsconfig.json --noEmit --pretty false
  • npx @raycast/api@latest lint
    • fails only on the Raycast manifest author field because CapSoftware is not a valid Raycast username

Manual Testing

  • Not run on this machine. This environment is Linux and I could not smoke-test open "cap-desktop://..." flows or verify in-app macOS recording UI sync manually.

Demo

  • Demo video: TODO

Notes

  • Raycast device enumeration uses macOS system_profiler output and prefers camera model_id, then device_id, then label fallback.
  • Existing auth and legacy action deeplinks remain supported.

Greptile Summary

This PR adds semantic cap-desktop://record/* and cap-desktop://device/* deeplink routes to the Tauri desktop backend, alongside a new apps/raycast extension with cap-control and switch-device commands. Existing action and signin deeplinks are preserved unchanged.

  • Rust backend (deeplink_actions.rs, lib.rs): new URL parser dispatches to recording and device-switch handlers, routing through the same pause/resume/stop paths already used by the in-app UI; start_recording_from_saved_settings is extracted from the existing RequestStartRecording listener to share logic.
  • Raycast extension (devices.ts, deeplinks.ts, cap-control.tsx, switch-device.tsx): enumerates macOS audio and camera devices via system_profiler -json, builds deeplinks preferring model_iddevice_idlabel, and opens them via cap-desktop:// scheme.
  • Test coverage: a #[cfg(test)] module in deeplink_actions.rs pins URL shape, all new record/device parse paths, and error cases.

Confidence Score: 4/5

The new deeplink routes reuse well-tested recording and device-switch paths; the refactor of start_recording_from_saved_settings is behaviour-preserving.

Three small correctness concerns exist: parse_recording_mode builds a JSON string via raw format! without escaping, camera label matching is case-sensitive while the two ID selectors use eq_ignore_ascii_case, and readModelId scans all string values for a hex pattern that could match non-ID fields. None block correct operation under normal inputs but are worth addressing.

apps/desktop/src-tauri/src/deeplink_actions.rs for mode-parsing and label-matching issues; apps/raycast/src/lib/devices.ts for the model-ID heuristic.

Important Files Changed

Filename Overview
apps/desktop/src-tauri/src/deeplink_actions.rs Adds ~230 lines of new deeplink parsing and execution logic; two minor issues: raw-format JSON construction for recording mode and case-sensitive label matching vs case-insensitive ID matching
apps/desktop/src-tauri/src/lib.rs Extracts start_recording_from_saved_settings into a shared function; refactor is behaviour-preserving for the existing RequestStartRecording listener path
apps/raycast/src/lib/devices.ts Enumerates macOS devices via system_profiler; heuristic model-ID extraction scans all string values and may produce false positives on non-ID hex-like fields
apps/raycast/src/lib/deeplinks.ts Clean static action list and openCapDeeplink helper with proper error handling via showToast
apps/raycast/src/cap-control.tsx Minimal Raycast List command rendering static control actions with copy-to-clipboard support; straightforward
apps/raycast/src/switch-device.tsx Async device loading with cancellation guard, sectioned list for microphones and cameras, toast on failure; well-structured
apps/raycast/package.json New Raycast extension manifest; author value CapSoftware is noted in the PR as a known Raycast lint failure for non-registered usernames
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/desktop/src-tauri/src/deeplink_actions.rs:212-215
`parse_recording_mode` embeds the URL-decoded value directly into a JSON string literal with `format!("\"{value}\"")` without escaping. A value containing a backslash (e.g., `mode=studio%5C`) produces `"studio\"` — an unterminated JSON string — causing a misleading `ParseFailed` error rather than a clear "invalid mode" message. Use `serde_json::to_string` to produce a properly escaped JSON string.

```suggestion
fn parse_recording_mode(value: String) -> Result<RecordingMode, ActionParseFromUrlError> {
    let json = serde_json::to_string(&value)
        .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?;
    serde_json::from_str::<RecordingMode>(&json)
        .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))
}
```

### Issue 2 of 3
apps/desktop/src-tauri/src/deeplink_actions.rs:288-292
Label matching is case-sensitive here while `DeviceId` and `ModelId` both use `eq_ignore_ascii_case`. A deeplink like `cap-desktop://device/camera?label=facetime+hd+camera` would fail to match a camera named `FaceTime HD Camera`, even though the same user intent succeeds with the other two selectors. Applying consistent case-folding avoids this surprise.

```suggestion
        CameraSelector::Label(label) => cameras
            .iter()
            .find(|camera| camera.display_name.eq_ignore_ascii_case(label))
            .ok_or_else(|| format!("No camera with label \"{label}\""))?
            .device_or_model_id(),
```

### Issue 3 of 3
apps/raycast/src/lib/devices.ts:207-211
**Unanchored value scan may produce false-positive model IDs**

`readModelId` scans every string value in the object for `/\b[0-9a-fA-F]{4}:[0-9a-fA-F]{4}\b/` before consulting key names. Any string field that incidentally contains a `xxxx:xxxx` hex-like substring (e.g., a UUID fragment, a serial number, or a firmware version tag) would be treated as a model ID and generate a wrong deeplink. The key-scoped `readHexId` path that follows already applies key filtering; applying similar key filtering to the direct-match scan (e.g. checking that the key contains `model`, `vendor`, `product`, or `usb`) would prevent false matches.

Reviews (1): Last reviewed commit: "chore: fix raycast icon permissions" | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

@superagent-security superagent-security Bot added the contributor:verified Contributor passed trust analysis. label May 18, 2026
@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 18, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​raycast/​api@​1.104.179610084100100

View full report

@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label May 18, 2026
Copy link
Copy Markdown

@superagent-security superagent-security Bot left a comment

Choose a reason for hiding this comment

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

Superagent found 2 security concern(s).

Comment on lines +212 to +215
fn parse_recording_mode(value: String) -> Result<RecordingMode, ActionParseFromUrlError> {
serde_json::from_str::<RecordingMode>(&format!("\"{value}\""))
.map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))
}
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 parse_recording_mode embeds the URL-decoded value directly into a JSON string literal with format!("\"{value}\"") without escaping. A value containing a backslash (e.g., mode=studio%5C) produces "studio\" — an unterminated JSON string — causing a misleading ParseFailed error rather than a clear "invalid mode" message. Use serde_json::to_string to produce a properly escaped JSON string.

Suggested change
fn parse_recording_mode(value: String) -> Result<RecordingMode, ActionParseFromUrlError> {
serde_json::from_str::<RecordingMode>(&format!("\"{value}\""))
.map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))
}
fn parse_recording_mode(value: String) -> Result<RecordingMode, ActionParseFromUrlError> {
let json = serde_json::to_string(&value)
.map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?;
serde_json::from_str::<RecordingMode>(&json)
.map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 212-215

Comment:
`parse_recording_mode` embeds the URL-decoded value directly into a JSON string literal with `format!("\"{value}\"")` without escaping. A value containing a backslash (e.g., `mode=studio%5C`) produces `"studio\"` — an unterminated JSON string — causing a misleading `ParseFailed` error rather than a clear "invalid mode" message. Use `serde_json::to_string` to produce a properly escaped JSON string.

```suggestion
fn parse_recording_mode(value: String) -> Result<RecordingMode, ActionParseFromUrlError> {
    let json = serde_json::to_string(&value)
        .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?;
    serde_json::from_str::<RecordingMode>(&json)
        .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))
}
```

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

Comment on lines +288 to +292
CameraSelector::Label(label) => cameras
.iter()
.find(|camera| camera.display_name == *label)
.ok_or_else(|| format!("No camera with label \"{label}\""))?
.device_or_model_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.

P2 Label matching is case-sensitive here while DeviceId and ModelId both use eq_ignore_ascii_case. A deeplink like cap-desktop://device/camera?label=facetime+hd+camera would fail to match a camera named FaceTime HD Camera, even though the same user intent succeeds with the other two selectors. Applying consistent case-folding avoids this surprise.

Suggested change
CameraSelector::Label(label) => cameras
.iter()
.find(|camera| camera.display_name == *label)
.ok_or_else(|| format!("No camera with label \"{label}\""))?
.device_or_model_id(),
CameraSelector::Label(label) => cameras
.iter()
.find(|camera| camera.display_name.eq_ignore_ascii_case(label))
.ok_or_else(|| format!("No camera with label \"{label}\""))?
.device_or_model_id(),
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 288-292

Comment:
Label matching is case-sensitive here while `DeviceId` and `ModelId` both use `eq_ignore_ascii_case`. A deeplink like `cap-desktop://device/camera?label=facetime+hd+camera` would fail to match a camera named `FaceTime HD Camera`, even though the same user intent succeeds with the other two selectors. Applying consistent case-folding avoids this surprise.

```suggestion
        CameraSelector::Label(label) => cameras
            .iter()
            .find(|camera| camera.display_name.eq_ignore_ascii_case(label))
            .ok_or_else(|| format!("No camera with label \"{label}\""))?
            .device_or_model_id(),
```

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

Comment thread apps/raycast/src/lib/devices.ts Outdated
Comment on lines +207 to +211
for (const value of Object.values(object)) {
if (typeof value !== "string") continue;
const directMatch = value.match(/\b[0-9a-fA-F]{4}:[0-9a-fA-F]{4}\b/);
if (directMatch) return directMatch[0].toLowerCase();
}
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 Unanchored value scan may produce false-positive model IDs

readModelId scans every string value in the object for /\b[0-9a-fA-F]{4}:[0-9a-fA-F]{4}\b/ before consulting key names. Any string field that incidentally contains a xxxx:xxxx hex-like substring (e.g., a UUID fragment, a serial number, or a firmware version tag) would be treated as a model ID and generate a wrong deeplink. The key-scoped readHexId path that follows already applies key filtering; applying similar key filtering to the direct-match scan (e.g. checking that the key contains model, vendor, product, or usb) would prevent false matches.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast/src/lib/devices.ts
Line: 207-211

Comment:
**Unanchored value scan may produce false-positive model IDs**

`readModelId` scans every string value in the object for `/\b[0-9a-fA-F]{4}:[0-9a-fA-F]{4}\b/` before consulting key names. Any string field that incidentally contains a `xxxx:xxxx` hex-like substring (e.g., a UUID fragment, a serial number, or a firmware version tag) would be treated as a model ID and generate a wrong deeplink. The key-scoped `readHexId` path that follows already applies key filtering; applying similar key filtering to the direct-match scan (e.g. checking that the key contains `model`, `vendor`, `product`, or `usb`) would prevent false matches.

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

- use serde_json::to_string in parse_recording_mode to avoid unescaped JSON

- apply eq_ignore_ascii_case to CameraSelector::Label for consistency

- scope readModelId hex scan to model/vendor/product/usb keys to prevent false positives
Copy link
Copy Markdown

@superagent-security superagent-security Bot left a comment

Choose a reason for hiding this comment

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

Superagent found 2 security concern(s).

@@ -144,9 +352,35 @@ impl DeepLinkAction {
.await
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MEDIUM] External deeplinks can control recording and devices without confirmation

Custom deeplinks directly start/stop recordings and switch devices without confirmation.

Fix: Gate recording/device deeplinks behind user opt-in or confirmation, or use authenticated local IPC.

Comment thread apps/raycast/package.json
],
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "npx @raycast/api@latest lint"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[LOW] NPM script executes latest Raycast CLI outside the lockfile

lint runs npx @raycast/api@latest, bypassing the pinned lockfile version.

Fix: Use the locked local Raycast CLI or pin npx to the reviewed version.

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

Labels

contributor:verified Contributor passed trust analysis. pr:flagged PR flagged for review by security analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant