Skip to content

feat: add deeplink actions for recording control and Raycast extension#1814

Open
fansifei wants to merge 2 commits into
CapSoftware:mainfrom
fansifei:feat/deeplinks-raycast-extension
Open

feat: add deeplink actions for recording control and Raycast extension#1814
fansifei wants to merge 2 commits into
CapSoftware:mainfrom
fansifei:feat/deeplinks-raycast-extension

Conversation

@fansifei
Copy link
Copy Markdown

@fansifei fansifei commented May 13, 2026

Closes #1540

Greptile Summary

This PR adds a Raycast extension for controlling Cap screen recording via cap-desktop:// deeplinks, and extends the Rust deeplink handler with new actions (pause, resume, restart, screenshot, switch device). The url.domain()url.host_str() fix is correct and necessary for custom URL schemes.

  • Broken deeplink encoding: sendAction in utils.ts passes a bare string (e.g. pause_recording) instead of a JSON-encoded string (e.g. \"pause_recording\"); serde_json::from_str rejects it, so every simple command — pause, resume, stop, restart, take-screenshot, toggle-pause — silently fails.
  • Start Recording always fails when no screen name is given: captureName defaults to \"\", which never matches any display name in the Rust handler, causing an error that the HUD does not surface to the user.
  • Hardcoded device lists: switch-camera.ts, switch-microphone.ts, and list-devices.ts all show fixed, made-up device names with no mechanism to receive real device data back from Cap, so switching devices will target non-existent hardware.

Confidence Score: 2/5

Not ready to merge — the core deeplink encoding bug means the majority of Raycast commands will silently do nothing when invoked.

The sendAction utility is the shared foundation for six of the ten commands, and its missing JSON.stringify call means every one of those commands sends a URL the Rust backend cannot parse. Additionally, Start Recording will fail with any user who omits a screen name (the common case), and both switch-device commands operate on a hardcoded fixture list rather than real hardware.

apps/raycast/src/utils.ts is the highest-priority fix; apps/raycast/src/start-recording.ts, switch-camera.ts, switch-microphone.ts, and list-devices.ts all need follow-up before the extension is usable.

Important Files Changed

Filename Overview
apps/raycast/src/utils.ts sendAction passes a bare unquoted string to the deeplink URL; serde_json::from_str will reject it, breaking all simple action commands
apps/raycast/src/start-recording.ts Defaults captureName to an empty string, which will never match any display name in the Rust backend, silently preventing recording from starting
apps/raycast/src/switch-camera.ts Hardcoded device list does not reflect actual connected devices; switching will likely fail with non-existent IDs
apps/raycast/src/switch-microphone.ts Hardcoded microphone list does not reflect actual connected devices; same issue as switch-camera.ts
apps/raycast/src/list-devices.ts Always shows hardcoded devices; second useEffect races and sets isLoading=false immediately, with no mechanism to receive real device data from Cap
apps/desktop/src-tauri/src/deeplink_actions.rs Adds new deeplink actions for pause/resume/restart/screenshot/switch-device; fixes url.domain() to url.host_str() for custom schemes; RefreshRaycastDeviceCache only restarts mic feed
apps/raycast/src/pause-recording.ts Correct structure but will fail at runtime due to the sendAction JSON-encoding bug in utils.ts
apps/raycast/package.json Correct Raycast extension manifest with all commands declared; no issues found
Prompt To Fix All With AI
Fix the following 5 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 5
apps/raycast/src/utils.ts:5-8
`sendAction` passes a bare string to the URL instead of a JSON-encoded string. The Rust backend calls `serde_json::from_str(json_value)` on the decoded value, so it requires valid JSON — a plain string like `pause_recording` is not valid JSON and will fail to parse. Every command using `sendAction` (pause, resume, stop, restart, take-screenshot, toggle-pause) will silently fail. Note that `list-devices.ts` already correctly wraps the value in `JSON.stringify("refresh_raycast_device_cache")`.

```suggestion
export async function sendAction(action: string) {
  const url = `${DEEP_LINK_BASE}?value=${encodeURIComponent(JSON.stringify(action))}`;
  await open(url);
}
```

### Issue 2 of 5
apps/raycast/src/start-recording.ts:14-17
**Empty `captureName` causes start recording to fail silently**

When `captureName` is not supplied, it defaults to `""`. The Rust handler then calls `list_displays().into_iter().find(|(s, _)| s.name == name)` with an empty string, which will never match any real display name and returns `Err("No screen with name \"\"")`. The user sees the "Cap: Recording started" HUD but no recording is started. Either default to the first available display (matching what `TakeScreenshot` does), or require the argument to be provided in `package.json`.

### Issue 3 of 5
apps/raycast/src/switch-camera.ts:8-11
**Hardcoded device list never reflects actual hardware**

Both `switch-camera.ts` and `switch-microphone.ts` define a fixed array (`"Built-in Camera"`, `"External Camera"`, etc.) that is never populated with real system devices. A user with a USB webcam or external audio interface will not see it listed, and selecting a hardcoded entry will try to switch to a device ID/name that likely doesn't exist in the Cap backend. The same issue affects `list-devices.ts`, where the comment says "actual list comes from the Cap app" but there is no IPC channel for Cap to push device data back to Raycast — the list is always the two hardcoded entries.

### Issue 4 of 5
apps/desktop/src-tauri/src/deeplink_actions.rs:193-199
**`RefreshRaycastDeviceCache` only refreshes microphone feed, not cameras**

The handler calls `restart_mic_feed()` only. Camera device enumeration is left untouched, so Raycast's "List Devices" / "Switch Camera" flows won't benefit from this refresh for cameras. If the intent is a full device-list refresh, the camera feed should also be restarted here.

### Issue 5 of 5
apps/raycast/src/list-devices.ts:25-32
**Second `useEffect` immediately overrides loading state with hardcoded data**

The second `useEffect` runs synchronously on mount and calls `setIsLoading(false)` before the async deeplink in the first effect resolves. As a result, `isLoading` flips to `false` almost immediately regardless of whether Cap has responded, and the shown devices are always the two hardcoded entries — the refresh deeplink result has no path to update this list. If the goal is to show a spinner while Cap refreshes, the second effect should not set `isLoading(false)`.

Reviews (1): Last reviewed commit: "feat: add deeplink actions for recording..." | Re-trigger Greptile

Greptile also left 5 inline comments on this PR.

Add new DeepLinkAction variants for pause, resume, toggle-pause, restart,
screenshot, switch camera/microphone, and device cache refresh.

Fix URL parsing bug: url.domain() returns None for custom schemes like
cap-desktop:// — use url.host_str() instead.

Create Raycast extension at apps/raycast/ with 10 commands:
start/stop/pause/resume/toggle/restart recording, screenshot,
device listing, switch camera, switch microphone.

Closes CapSoftware#1540

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@superagent-security superagent-security Bot added contributor:verified Contributor passed trust analysis. pr:flagged PR flagged for review by security analysis. labels May 13, 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 1 security concern(s).

Comment thread apps/raycast/src/utils.ts
Comment on lines +5 to +8
export async function sendAction(action: string) {
const url = `${DEEP_LINK_BASE}?value=${encodeURIComponent(action)}`;
await open(url);
}
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 sendAction passes a bare string to the URL instead of a JSON-encoded string. The Rust backend calls serde_json::from_str(json_value) on the decoded value, so it requires valid JSON — a plain string like pause_recording is not valid JSON and will fail to parse. Every command using sendAction (pause, resume, stop, restart, take-screenshot, toggle-pause) will silently fail. Note that list-devices.ts already correctly wraps the value in JSON.stringify("refresh_raycast_device_cache").

Suggested change
export async function sendAction(action: string) {
const url = `${DEEP_LINK_BASE}?value=${encodeURIComponent(action)}`;
await open(url);
}
export async function sendAction(action: string) {
const url = `${DEEP_LINK_BASE}?value=${encodeURIComponent(JSON.stringify(action))}`;
await open(url);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast/src/utils.ts
Line: 5-8

Comment:
`sendAction` passes a bare string to the URL instead of a JSON-encoded string. The Rust backend calls `serde_json::from_str(json_value)` on the decoded value, so it requires valid JSON — a plain string like `pause_recording` is not valid JSON and will fail to parse. Every command using `sendAction` (pause, resume, stop, restart, take-screenshot, toggle-pause) will silently fail. Note that `list-devices.ts` already correctly wraps the value in `JSON.stringify("refresh_raycast_device_cache")`.

```suggestion
export async function sendAction(action: string) {
  const url = `${DEEP_LINK_BASE}?value=${encodeURIComponent(JSON.stringify(action))}`;
  await open(url);
}
```

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

Comment thread apps/raycast/src/start-recording.ts Outdated
Comment on lines +14 to +17
const captureName = props.arguments.captureName || "";

await sendActionWithPayload("start_recording", {
capture_mode: { [captureMode]: captureName },
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 Empty captureName causes start recording to fail silently

When captureName is not supplied, it defaults to "". The Rust handler then calls list_displays().into_iter().find(|(s, _)| s.name == name) with an empty string, which will never match any real display name and returns Err("No screen with name \"\""). The user sees the "Cap: Recording started" HUD but no recording is started. Either default to the first available display (matching what TakeScreenshot does), or require the argument to be provided in package.json.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast/src/start-recording.ts
Line: 14-17

Comment:
**Empty `captureName` causes start recording to fail silently**

When `captureName` is not supplied, it defaults to `""`. The Rust handler then calls `list_displays().into_iter().find(|(s, _)| s.name == name)` with an empty string, which will never match any real display name and returns `Err("No screen with name \"\"")`. The user sees the "Cap: Recording started" HUD but no recording is started. Either default to the first available display (matching what `TakeScreenshot` does), or require the argument to be provided in `package.json`.

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

Comment on lines +8 to +11
const cameras = [
{ name: "Built-in Camera", id: "built-in" },
{ name: "External Camera", id: "external" },
];
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 Hardcoded device list never reflects actual hardware

Both switch-camera.ts and switch-microphone.ts define a fixed array ("Built-in Camera", "External Camera", etc.) that is never populated with real system devices. A user with a USB webcam or external audio interface will not see it listed, and selecting a hardcoded entry will try to switch to a device ID/name that likely doesn't exist in the Cap backend. The same issue affects list-devices.ts, where the comment says "actual list comes from the Cap app" but there is no IPC channel for Cap to push device data back to Raycast — the list is always the two hardcoded entries.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast/src/switch-camera.ts
Line: 8-11

Comment:
**Hardcoded device list never reflects actual hardware**

Both `switch-camera.ts` and `switch-microphone.ts` define a fixed array (`"Built-in Camera"`, `"External Camera"`, etc.) that is never populated with real system devices. A user with a USB webcam or external audio interface will not see it listed, and selecting a hardcoded entry will try to switch to a device ID/name that likely doesn't exist in the Cap backend. The same issue affects `list-devices.ts`, where the comment says "actual list comes from the Cap app" but there is no IPC channel for Cap to push device data back to Raycast — the list is always the two hardcoded entries.

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

Comment on lines +193 to +199
DeepLinkAction::RefreshRaycastDeviceCache => {
// Refresh by re-initializing the mic feed
let state: tauri::State<'_, ArcLock<App>> = app.state();
let mut app_state = state.write().await;
app_state.restart_mic_feed().await.map_err(|e| e.to_string())?;
Ok(())
}
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 RefreshRaycastDeviceCache only refreshes microphone feed, not cameras

The handler calls restart_mic_feed() only. Camera device enumeration is left untouched, so Raycast's "List Devices" / "Switch Camera" flows won't benefit from this refresh for cameras. If the intent is a full device-list refresh, the camera feed should also be restarted here.

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

Comment:
**`RefreshRaycastDeviceCache` only refreshes microphone feed, not cameras**

The handler calls `restart_mic_feed()` only. Camera device enumeration is left untouched, so Raycast's "List Devices" / "Switch Camera" flows won't benefit from this refresh for cameras. If the intent is a full device-list refresh, the camera feed should also be restarted here.

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

Comment thread apps/raycast/src/list-devices.ts Outdated
Comment on lines +25 to +32
useEffect(() => {
// Populate with known device types — actual list comes from the Cap app
setDevices([
{ name: "Built-in Camera", type: "camera" },
{ name: "Built-in Microphone", type: "microphone" },
]);
setIsLoading(false);
}, []);
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 Second useEffect immediately overrides loading state with hardcoded data

The second useEffect runs synchronously on mount and calls setIsLoading(false) before the async deeplink in the first effect resolves. As a result, isLoading flips to false almost immediately regardless of whether Cap has responded, and the shown devices are always the two hardcoded entries — the refresh deeplink result has no path to update this list. If the goal is to show a spinner while Cap refreshes, the second effect should not set isLoading(false).

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

Comment:
**Second `useEffect` immediately overrides loading state with hardcoded data**

The second `useEffect` runs synchronously on mount and calls `setIsLoading(false)` before the async deeplink in the first effect resolves. As a result, `isLoading` flips to `false` almost immediately regardless of whether Cap has responded, and the shown devices are always the two hardcoded entries — the refresh deeplink result has no path to update this list. If the goal is to show a spinner while Cap refreshes, the second effect should not set `isLoading(false)`.

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

- Fix sendAction(): wrap action string in JSON.stringify() so
  serde_json::from_str() on the Rust side can parse it correctly
- Fix start-recording: use undefined instead of empty string for
  captureName to avoid matching failure on display lookup
- Fix list-devices: merge two useEffects into one to prevent loading
  state from flipping before deeplink resolves
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 1 security concern(s).

DeepLinkAction::RestartRecording => {
crate::recording::restart_recording(app.clone(), app.state())
.await
.map(|_| ())
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] Unauthenticated deeplink action can capture screenshots

New cap-desktop deeplink action directly invokes take_screenshot without caller validation.

Fix: Gate screenshot deeplinks with confirmation, nonce/auth, or trusted IPC before capturing the screen.

@socket-security
Copy link
Copy Markdown

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

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​types/​node@​20.14.91001008196100
Added@​raycast/​api@​1.104.169610084100100

View full report

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.

Bounty: Deeplinks support + Raycast Extension

1 participant