feat: add recording deeplinks and Raycast extension#1647
feat: add recording deeplinks and Raycast extension#1647lustsazeus-lab wants to merge 1 commit intoCapSoftware:mainfrom
Conversation
| .into_iter() | ||
| .find(|camera| camera.display_name() == label) | ||
| .map(|camera| DeviceOrModelID::from_info(&camera)) | ||
| .ok_or(format!("No camera with label \"{}\"", &label))?, |
There was a problem hiding this comment.
format! here runs even on the happy path. ok_or_else avoids allocating unless we actually error.
| .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>` |
There was a problem hiding this comment.
value needs to be URL-encoded JSON (otherwise spaces/quotes/etc can break the deeplink).
| `cap-desktop://action?value=<json>` | |
| `cap-desktop://action?value=<url-encoded-json>` |
| 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", | ||
| }); | ||
| } |
There was a problem hiding this comment.
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.
| 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", | |
| }); | |
| } |
| 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 } }); | ||
| } |
There was a problem hiding this 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.
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" /> |
There was a problem hiding this 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:
| <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.
Summary
apps/raycastextension with commands for start/stop/pause/resume/toggle and mic/camera switchingcap-desktop://action?value=...payload format for compatibilityValidation
pnpm --dir apps/raycast run typecheckcargo 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 serializingDeepLinkActionpayloads and dispatching them via macOS'sopenURL scheme.Key changes:
deeplink_actions.rs: AddsPauseRecording,ResumeRecording,TogglePauseRecording,SetMicrophone, andSetCameraenum variants with correct delegation to existingrecording.rsfunctions.apps/raycast/: New workspace package with autils.tsdispatcher (execFile("open", [...])) and one source file per command.pnpm-lock.yaml: Adds@raycast/apiand related dev dependencies for the new workspace; also reflects minor transitive dependency bumps.Issues found:
start-recording.tsx: The form fires two sequentialopendeeplinks —start_recording(withcamera: null) thenset_camera. Becauseopenreturns before Cap processes the URL, these can be handled by Cap out of order. Ifset_camerais processed first, the subsequentStartRecordinghandler's call toset_camera_input(..., None, None)silently overwrites the camera selection back to null.isRequiredontargetName: An empty target name passes form validation but causes a silent Rust-side error when the lookup fails.Confidence Score: 2/5
start_recording + set_cameradispatch pattern can silently overwrite the user's camera selection back to null if processing order is inverted, and (2) missingisRequiredvalidation on thetargetNamefield allowing empty form submission with silent Rust-side failures. The race condition specifically poses a data-loss risk in the most complex command.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 resultLast reviewed commit: 525d651
(2/5) Greptile learns from your feedback when you react with thumbs up/down!