Skip to content

feat: add recording deeplinks and Raycast extension#1647

Open
lustsazeus-lab wants to merge 1 commit intoCapSoftware:mainfrom
lustsazeus-lab:feat/issue-1540-deeplinks-raycast
Open

feat: add recording deeplinks and Raycast extension#1647
lustsazeus-lab wants to merge 1 commit intoCapSoftware:mainfrom
lustsazeus-lab:feat/issue-1540-deeplinks-raycast

Conversation

@lustsazeus-lab
Copy link

@lustsazeus-lab lustsazeus-lab commented Mar 6, 2026

Summary

  • extend desktop deeplink actions with pause/resume/toggle + mic/camera switching
  • add a new apps/raycast extension with commands for start/stop/pause/resume/toggle and mic/camera switching
  • keep existing cap-desktop://action?value=... payload format for compatibility

Validation

  • pnpm --dir apps/raycast run typecheck
  • cargo test -p cap-desktop deeplink_actions -- --nocapture (blocked in this environment: cargo not installed)

/claim #1540

Greptile Summary

This PR adds pause/resume/toggle deeplink actions to the Cap desktop app and introduces a new Raycast extension (apps/raycast) that exposes seven commands (start/stop/pause/resume/toggle recording, switch mic, switch camera) by serializing DeepLinkAction payloads and dispatching them via macOS's open URL scheme.

Key changes:

  • deeplink_actions.rs: Adds PauseRecording, ResumeRecording, TogglePauseRecording, SetMicrophone, and SetCamera enum variants with correct delegation to existing recording.rs functions.
  • apps/raycast/: New workspace package with a utils.ts dispatcher (execFile("open", [...])) and one source file per command.
  • pnpm-lock.yaml: Adds @raycast/api and related dev dependencies for the new workspace; also reflects minor transitive dependency bumps.

Issues found:

  • Race condition in start-recording.tsx: The form fires two sequential open deeplinks — start_recording (with camera: null) then set_camera. Because open returns before Cap processes the URL, these can be handled by Cap out of order. If set_camera is processed first, the subsequent StartRecording handler's call to set_camera_input(..., None, None) silently overwrites the camera selection back to null.
  • Missing isRequired on targetName: An empty target name passes form validation but causes a silent Rust-side error when the lookup fails.

Confidence Score: 2/5

  • Safe to merge for basic pause/resume/toggle/mic/camera deeplinks; however, the start-recording race condition can silently overwrite camera selection and should be addressed before heavy use of that command.
  • The Rust deeplink additions are clean and delegate to already-tested recording functions. The simple Raycast commands (pause, resume, toggle, stop, switch-mic, switch-camera) are straightforward and low-risk. However, the score is reduced due to two issues in start-recording.tsx: (1) a race condition where the two-step start_recording + set_camera dispatch pattern can silently overwrite the user's camera selection back to null if processing order is inverted, and (2) missing isRequired validation on the targetName field allowing empty form submission with silent Rust-side failures. The race condition specifically poses a data-loss risk in the most complex command.
  • apps/raycast/src/start-recording.tsx — two issues: race condition between sequential deeplink dispatches, and missing field validation.

Sequence Diagram

sequenceDiagram
    participant User
    participant Raycast
    participant macOS as macOS open
    participant Cap as Cap Desktop (Tauri)
    participant Recording as recording.rs

    User->>Raycast: Invoke command (e.g. Pause Recording)
    Raycast->>macOS: open cap-desktop://action?value={"pause_recording":null}
    macOS-->>Raycast: returns immediately
    Raycast->>Raycast: showToast("Sent to Cap")
    macOS->>Cap: deliver deeplink URL
    Cap->>Cap: DeepLinkAction::try_from(url) → PauseRecording
    Cap->>Recording: pause_recording(app, state)

    Note over Raycast,Cap: start-recording race condition
    User->>Raycast: Submit start form (with cameraLabel)
    Raycast->>macOS: open cap-desktop://action?value={"start_recording":{...,"camera":null}}
    macOS-->>Raycast: returns immediately
    Raycast->>macOS: open cap-desktop://action?value={"set_camera":{"camera_label":"..."}}
    macOS-->>Raycast: returns immediately
    macOS->>Cap: deliver start_recording (camera=null)
    macOS->>Cap: deliver set_camera (may arrive before OR after start_recording)
    Cap->>Recording: set_camera_input(..., None) ← may overwrite set_camera result
Loading

Last reviewed commit: 525d651

Greptile also left 2 inline comments on this PR.

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

.into_iter()
.find(|camera| camera.display_name() == label)
.map(|camera| DeviceOrModelID::from_info(&camera))
.ok_or(format!("No camera with label \"{}\"", &label))?,
Copy link

Choose a reason for hiding this comment

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

format! here runs even on the happy path. ok_or_else avoids allocating unless we actually error.

Suggested change
.ok_or(format!("No camera with label \"{}\"", &label))?,
.ok_or_else(|| format!("No camera with label \"{}\"", &label))?,


All commands serialize a `DeepLinkAction` payload and open:

`cap-desktop://action?value=<json>`
Copy link

Choose a reason for hiding this comment

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

value needs to be URL-encoded JSON (otherwise spaces/quotes/etc can break the deeplink).

Suggested change
`cap-desktop://action?value=<json>`
`cap-desktop://action?value=<url-encoded-json>`

Comment on lines +12 to +31
export async function dispatchAction(action: unknown) {
const url = toDeepLink(action);
await closeMainWindow();

try {
await execFileAsync("open", [url]);
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to trigger Cap",
message: error instanceof Error ? error.message : String(error),
});
return;
}

await showToast({
style: Toast.Style.Success,
title: "Sent to Cap",
});
}
Copy link

Choose a reason for hiding this comment

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

toDeepLink() can throw (e.g. JSON.stringify), but right now that bypasses the toast and fails silently. Pull it into the try so we always surface a failure.

Suggested change
export async function dispatchAction(action: unknown) {
const url = toDeepLink(action);
await closeMainWindow();
try {
await execFileAsync("open", [url]);
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to trigger Cap",
message: error instanceof Error ? error.message : String(error),
});
return;
}
await showToast({
style: Toast.Style.Success,
title: "Sent to Cap",
});
}
export async function dispatchAction(action: unknown) {
await closeMainWindow();
try {
const url = toDeepLink(action);
await execFileAsync("open", [url]);
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to trigger Cap",
message: error instanceof Error ? error.message : String(error),
});
return;
}
await showToast({
style: Toast.Style.Success,
title: "Sent to Cap",
});
}

Comment on lines +26 to +38
await dispatchAction({
start_recording: {
capture_mode: captureMode,
camera: null,
mic_label: values.micLabel || null,
capture_system_audio: values.captureSystemAudio,
mode: values.mode,
},
});

if (values.cameraLabel) {
await dispatchAction({ set_camera: { camera_label: values.cameraLabel } });
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Race condition: start_recording can overwrite camera set by set_camera

The form dispatches two sequential deeplinks: start_recording (with camera: null) followed by set_camera. Because both open url calls return immediately to Raycast (before Cap processes the URLs), there is no guaranteed ordering between the two actions in the Tauri handler.

If set_camera is processed first and start_recording second (possible since Tauri spawns each deep-link handler as an async task), the start_recording handler explicitly calls set_camera_input(..., None, None), overwriting the user's camera choice back to null.

Since StartRecording already accepts camera: Option<DeviceOrModelID>, the cleanest fix is to avoid the two-step dispatch pattern entirely by resolving the camera label to DeviceOrModelID in Raycast (matching the logic in SetCamera), then passing the result in a single start_recording deeplink. Alternatively, ensure set_camera is always processed last by firing it in a separate non-awaited call, though this still leaves a small race window.

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

Comment:
**Race condition: `start_recording` can overwrite camera set by `set_camera`**

The form dispatches two sequential deeplinks: `start_recording` (with `camera: null`) followed by `set_camera`. Because both `open url` calls return immediately to Raycast (before Cap processes the URLs), there is no guaranteed ordering between the two actions in the Tauri handler.

If `set_camera` is processed first and `start_recording` second (possible since Tauri spawns each deep-link handler as an async task), the `start_recording` handler explicitly calls `set_camera_input(..., None, None)`, overwriting the user's camera choice back to `null`.

Since `StartRecording` already accepts `camera: Option<DeviceOrModelID>`, the cleanest fix is to avoid the two-step dispatch pattern entirely by resolving the camera label to `DeviceOrModelID` in Raycast (matching the logic in `SetCamera`), then passing the result in a single `start_recording` deeplink. Alternatively, ensure `set_camera` is always processed last by firing it in a separate non-awaited call, though this still leaves a small race window.

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

<Form.Dropdown.Item value="screen" title="Screen" />
<Form.Dropdown.Item value="window" title="Window" />
</Form.Dropdown>
<Form.TextField id="targetName" title="Target Name" placeholder="Built-in Retina Display" />
Copy link
Contributor

Choose a reason for hiding this comment

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

targetName is required by the Rust handler (used in list_displays()/list_windows() lookup), but the form allows empty submission. An empty string will fail silently with "No screen with name """ on the Rust side. Mark the field as required to prevent invalid form submission:

Suggested change
<Form.TextField id="targetName" title="Target Name" placeholder="Built-in Retina Display" />
<Form.TextField id="targetName" title="Target Name" placeholder="Built-in Retina Display" isRequired />
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast/src/start-recording.tsx
Line: 48

Comment:
`targetName` is required by the Rust handler (used in `list_displays()`/`list_windows()` lookup), but the form allows empty submission. An empty string will fail silently with "No screen with name \"\"" on the Rust side. Mark the field as required to prevent invalid form submission:

```suggestion
      <Form.TextField id="targetName" title="Target Name" placeholder="Built-in Retina Display" isRequired />
```

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant