Bounty #1540: Deep Links Support + Raycast Extension#1655
Bounty #1540: Deep Links Support + Raycast Extension#1655richardiitse wants to merge 1 commit intoCapSoftware:mainfrom
Conversation
…itch) - Extend DeepLinkAction enum with PauseRecording, ResumeRecording, TogglePause, SwitchMic, SwitchCamera - Implement execute handlers for new actions using existing recording commands - Add TypeScript deep link listener (recording-deeplinks.ts) for cap-desktop:// URLs - Initialize deep link handler in App.tsx on mount - Support URL formats: cap-desktop://record/pause, cap-desktop://devices/mic?name=xxx Part of CapSoftware#1540 bounty implementation - Day 1: Deeplinks Extension
| const pathParts = url.pathname.split("/").filter(Boolean); | ||
| const params = Object.fromEntries(url.searchParams); | ||
|
|
||
| if (pathParts[0] !== "record" && pathParts[0] !== "devices") { | ||
| return null; | ||
| } | ||
|
|
||
| const [action, subaction] = pathParts; | ||
|
|
There was a problem hiding this comment.
cap-desktop://record/stop parses as host=record, pathname=/stop. With the current url.pathname splitting, pathParts[0] becomes stop, so this will never match the documented routes.
| const pathParts = url.pathname.split("/").filter(Boolean); | |
| const params = Object.fromEntries(url.searchParams); | |
| if (pathParts[0] !== "record" && pathParts[0] !== "devices") { | |
| return null; | |
| } | |
| const [action, subaction] = pathParts; | |
| const action = url.hostname; | |
| const [subaction] = url.pathname.split("/").filter(Boolean); | |
| const params = Object.fromEntries(url.searchParams); | |
| if (action !== "record" && action !== "devices") { | |
| return null; | |
| } |
| /** | ||
| * Initialize deep link listener for recording controls | ||
| * Routes: | ||
| * - cap-desktop://record/start?target=display&displayId=1 | ||
| * - cap-desktop://record/start?target=window&windowId=abc | ||
| * - cap-desktop://record/start?target=area&displayId=1&bounds={"x":0,"y":0,"width":1920,"height":1080} | ||
| * - cap-desktop://record/stop | ||
| * - cap-desktop://record/pause | ||
| * - cap-desktop://record/resume | ||
| * - cap-desktop://record/toggle | ||
| * - cap-desktop://devices/mic?name=Built-in Microphone | ||
| * - cap-desktop://devices/camera?id=faceTimeHD | ||
| */ | ||
| export async function initRecordingControlDeepLinks() { |
There was a problem hiding this comment.
Repo convention here is no inline code comments; consider moving the route examples to a doc/README and keeping the code comment-free.
| /** | |
| * Initialize deep link listener for recording controls | |
| * Routes: | |
| * - cap-desktop://record/start?target=display&displayId=1 | |
| * - cap-desktop://record/start?target=window&windowId=abc | |
| * - cap-desktop://record/start?target=area&displayId=1&bounds={"x":0,"y":0,"width":1920,"height":1080} | |
| * - cap-desktop://record/stop | |
| * - cap-desktop://record/pause | |
| * - cap-desktop://record/resume | |
| * - cap-desktop://record/toggle | |
| * - cap-desktop://devices/mic?name=Built-in Microphone | |
| * - cap-desktop://devices/camera?id=faceTimeHD | |
| */ | |
| export async function initRecordingControlDeepLinks() { | |
| export async function initRecordingControlDeepLinks() { |
| displayId: params.displayId, | ||
| windowId: params.windowId, | ||
| bounds: params.bounds, | ||
| mode: (params.mode as RecordingMode) || "studio", |
There was a problem hiding this comment.
params.mode as RecordingMode will allow any string through at runtime. Might be worth validating and defaulting cleanly.
| mode: (params.mode as RecordingMode) || "studio", | |
| mode: | |
| params.mode === "studio" || | |
| params.mode === "instant" || | |
| params.mode === "screenshot" | |
| ? params.mode | |
| : "studio", |
| initAnonymousUser(); | ||
| // Initialize deep link listener for recording controls | ||
| initRecordingControlDeepLinks().catch((err) => | ||
| console.error("[App] Failed to init deep links:", err), |
There was a problem hiding this comment.
Small thing: this repo avoids code comments, so I'd drop the inline // ... here.
| initAnonymousUser(); | |
| // Initialize deep link listener for recording controls | |
| initRecordingControlDeepLinks().catch((err) => | |
| console.error("[App] Failed to init deep links:", err), | |
| initRecordingControlDeepLinks().catch((err) => | |
| console.error("[App] Failed to init deep links:", err), | |
| ); |
|
|
||
| onMount(() => { | ||
| initAnonymousUser(); | ||
| // Initialize deep link listener for recording controls |
There was a problem hiding this comment.
Since you already export disposeRecordingControlDeepLinks(), it’d be nice to wire it up with onCleanup so we don’t keep a stale listener around during reload/unmount.
| const result = await commands.startRecording({ | ||
| capture_target: captureTarget, | ||
| capture_system_audio: true, | ||
| mode: mode || "studio", | ||
| }); |
There was a problem hiding this comment.
capture_system_audio hardcoded to true
capture_system_audio: true is unconditionally forced on for all deep-link-initiated recordings, regardless of the user's configured preference in settings. A user who has intentionally disabled system audio capture will find that preference silently overridden whenever recording is started via a deep link (e.g. through the Raycast extension).
The value should be read from the application settings store (the same source that the normal recording flow consults) rather than being hardcoded.
| const result = await commands.startRecording({ | |
| capture_target: captureTarget, | |
| capture_system_audio: true, | |
| mode: mode || "studio", | |
| }); | |
| const result = await commands.startRecording({ | |
| capture_target: captureTarget, | |
| capture_system_audio: true, // TODO: read from user settings | |
| mode: mode || "studio", | |
| }); |
For example, the existing recording flow reads this preference via the settings store — the same approach should be used here so the deep-link path stays in sync with user configuration.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/recording-deeplinks.ts
Line: 170-174
Comment:
**`capture_system_audio` hardcoded to `true`**
`capture_system_audio: true` is unconditionally forced on for all deep-link-initiated recordings, regardless of the user's configured preference in settings. A user who has intentionally disabled system audio capture will find that preference silently overridden whenever recording is started via a deep link (e.g. through the Raycast extension).
The value should be read from the application settings store (the same source that the normal recording flow consults) rather than being hardcoded.
```suggestion
const result = await commands.startRecording({
capture_target: captureTarget,
capture_system_audio: true, // TODO: read from user settings
mode: mode || "studio",
});
```
For example, the existing recording flow reads this preference via the settings store — the same approach should be used here so the deep-link path stays in sync with user configuration.
How can I resolve this? If you propose a fix, please make it concise.| stopListening = await onOpenUrl(async (urls) => { | ||
| for (const urlString of urls) { | ||
| try { | ||
| console.log(`[DeepLink] Received: ${urlString}`); | ||
| const url = new URL(urlString); | ||
| const command = parseDeepLinkUrl(url); | ||
|
|
||
| if (command) { | ||
| await executeDeepLinkCommand(command); | ||
| } else { | ||
| console.warn(`[DeepLink] Unknown command: ${url.pathname}`); | ||
| } | ||
| } catch (error) { | ||
| console.error(`[DeepLink] Error processing ${urlString}:`, error); | ||
| } | ||
| } | ||
| }); |
There was a problem hiding this comment.
Deep link listener registered in every webview window
App.tsx/Inner is the root component for all Cap windows (main window, camera overlay, editor, etc.), each of which runs in its own independent JavaScript runtime. Because initRecordingControlDeepLinks is called unconditionally in onMount, every open window will register its own onOpenUrl handler via the Tauri deep-link plugin.
When a deep link such as cap-desktop://record/stop arrives, the plugin emits the event to all registered listeners simultaneously. That means every currently-open window will independently call commands.stopRecording(), commands.pauseRecording(), etc. The first call will succeed; subsequent calls from other windows will fail (no recording in progress) and produce error-level console noise at minimum, with unpredictable side effects depending on how the backend handles concurrent calls.
Consider restricting initialization to the main application window only, for example by checking the current window label before calling initRecordingControlDeepLinks:
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
onMount(() => {
initAnonymousUser();
const win = getCurrentWebviewWindow();
if (win.label === "main") {
initRecordingControlDeepLinks().catch((err) =>
console.error("[App] Failed to init deep links:", err),
);
}
});Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/recording-deeplinks.ts
Line: 45-61
Comment:
**Deep link listener registered in every webview window**
`App.tsx`/`Inner` is the root component for all Cap windows (main window, camera overlay, editor, etc.), each of which runs in its own independent JavaScript runtime. Because `initRecordingControlDeepLinks` is called unconditionally in `onMount`, every open window will register its own `onOpenUrl` handler via the Tauri deep-link plugin.
When a deep link such as `cap-desktop://record/stop` arrives, the plugin emits the event to all registered listeners simultaneously. That means every currently-open window will independently call `commands.stopRecording()`, `commands.pauseRecording()`, etc. The first call will succeed; subsequent calls from other windows will fail (no recording in progress) and produce error-level console noise at minimum, with unpredictable side effects depending on how the backend handles concurrent calls.
Consider restricting initialization to the main application window only, for example by checking the current window label before calling `initRecordingControlDeepLinks`:
```ts
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
onMount(() => {
initAnonymousUser();
const win = getCurrentWebviewWindow();
if (win.label === "main") {
initRecordingControlDeepLinks().catch((err) =>
console.error("[App] Failed to init deep links:", err),
);
}
});
```
How can I resolve this? If you propose a fix, please make it concise.| } else if (target === "area" && displayId && bounds) { | ||
| try { | ||
| const boundsObj = JSON.parse(bounds); | ||
| captureTarget = { | ||
| variant: "area", | ||
| screen: displayId, | ||
| bounds: boundsObj, | ||
| }; | ||
| } catch (e) { | ||
| console.error("[DeepLink] Invalid bounds JSON:", e); | ||
| return; |
There was a problem hiding this comment.
No structural validation on parsed bounds object
JSON.parse(bounds) produces an any value that is passed directly as captureTarget.bounds with no validation that it actually contains the expected { x, y, width, height } fields (or that those fields are numbers). A malformed or adversarially crafted deep link like cap-desktop://record/start?target=area&displayId=1&bounds={"x":"evil"} will pass through to the backend without any client-side rejection.
Add a basic shape guard before constructing the captureTarget:
try {
const boundsObj = JSON.parse(bounds);
if (
typeof boundsObj?.x !== "number" ||
typeof boundsObj?.y !== "number" ||
typeof boundsObj?.width !== "number" ||
typeof boundsObj?.height !== "number"
) {
console.error("[DeepLink] bounds object has unexpected shape:", boundsObj);
return;
}
captureTarget = {
variant: "area",
screen: displayId,
bounds: boundsObj,
};
} catch (e) {Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/recording-deeplinks.ts
Line: 153-163
Comment:
**No structural validation on parsed `bounds` object**
`JSON.parse(bounds)` produces an `any` value that is passed directly as `captureTarget.bounds` with no validation that it actually contains the expected `{ x, y, width, height }` fields (or that those fields are numbers). A malformed or adversarially crafted deep link like `cap-desktop://record/start?target=area&displayId=1&bounds={"x":"evil"}` will pass through to the backend without any client-side rejection.
Add a basic shape guard before constructing the `captureTarget`:
```ts
try {
const boundsObj = JSON.parse(bounds);
if (
typeof boundsObj?.x !== "number" ||
typeof boundsObj?.y !== "number" ||
typeof boundsObj?.width !== "number" ||
typeof boundsObj?.height !== "number"
) {
console.error("[DeepLink] bounds object has unexpected shape:", boundsObj);
return;
}
captureTarget = {
variant: "area",
screen: displayId,
bounds: boundsObj,
};
} catch (e) {
```
How can I resolve this? If you propose a fix, please make it concise.| DeepLinkAction::PauseRecording => { | ||
| crate::recording::pause_recording(app.clone(), app.state()).await | ||
| } | ||
| DeepLinkAction::ResumeRecording => { | ||
| crate::recording::resume_recording(app.clone(), app.state()).await | ||
| } | ||
| DeepLinkAction::TogglePause => { | ||
| crate::recording::toggle_pause_recording(app.clone(), app.state()).await | ||
| } | ||
| DeepLinkAction::SwitchMic { mic_label } => { | ||
| crate::set_mic_input(app.state(), Some(mic_label)).await | ||
| } | ||
| DeepLinkAction::SwitchCamera { camera_id } => { | ||
| crate::set_camera_input(app.clone(), app.state(), Some(camera_id), None).await | ||
| } |
There was a problem hiding this comment.
New Rust enum variants are unreachable through URL parsing
The execute implementations for PauseRecording, ResumeRecording, TogglePause, SwitchMic, and SwitchCamera are well-written, but they can never be reached from the existing TryFrom<&Url> parser (lines 88–114). That parser only returns Ok(…) for file:// URLs on macOS; for all other schemes (including cap-desktop://) both match arms return Err(…), so every non-file deep link is unconditionally dropped before execute is ever called.
The actual deep-link handling for these actions is implemented exclusively in the TypeScript layer (recording-deeplinks.ts), which calls the Tauri commands directly. The Rust additions are therefore dead code in the current form.
Either fix the TryFrom<&Url> implementation to parse cap-desktop://record/pause etc. and route them to these variants, or remove the Rust additions and keep the logic solely in the TypeScript handler to avoid confusion.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 159-173
Comment:
**New Rust enum variants are unreachable through URL parsing**
The `execute` implementations for `PauseRecording`, `ResumeRecording`, `TogglePause`, `SwitchMic`, and `SwitchCamera` are well-written, but they can never be reached from the existing `TryFrom<&Url>` parser (lines 88–114). That parser only returns `Ok(…)` for `file://` URLs on macOS; for all other schemes (including `cap-desktop://`) both match arms return `Err(…)`, so every non-file deep link is unconditionally dropped before `execute` is ever called.
The actual deep-link handling for these actions is implemented exclusively in the TypeScript layer (`recording-deeplinks.ts`), which calls the Tauri commands directly. The Rust additions are therefore dead code in the current form.
Either fix the `TryFrom<&Url>` implementation to parse `cap-desktop://record/pause` etc. and route them to these variants, or remove the Rust additions and keep the logic solely in the TypeScript handler to avoid confusion.
How can I resolve this? If you propose a fix, please make it concise.
Summary
This PR implements deep link support for Cap desktop recording controls and a companion Raycast extension for quick access.
Changes
1. Deep Link Handler
cap-desktop://record/start- Start recording with configurable targetcap-desktop://record/stop- Stop current recordingcap-desktop://record/pause- Pause recordingcap-desktop://record/resume- Resume recordingcap-desktop://record/toggle- Toggle pause/resumecap-desktop://devices/mic?name=<label>- Switch microphonecap-desktop://devices/camera?id=<id>- Switch camera2. Raycast Extension
Built complete Raycast extension with 8 commands for quick recording control.
Testing
Tested via terminal commands:
open "cap-desktop://record/stop"Closes #1540
Greptile Summary
This PR adds deep link support (
cap-desktop://record/*andcap-desktop://devices/*) and a companion Raycast extension to Cap. The TypeScript handler inrecording-deeplinks.tsis the functional implementation; the new RustDeepLinkActionvariants are added for completeness but are currently unreachable through the existing Rust URL parser. Three issues need attention before merging:capture_system_audiois hardcoded totruein thestartdeep link, overriding whatever the user has configured in Cap settings.initRecordingControlDeepLinksis called for every webview window (main, camera overlay, editor, etc.) because it runs unconditionally inApp.tsx'sInnercomponent. Since each window has its own JS runtime, a single incoming deep link will trigger the same Tauri command from every open window simultaneously.deeplink_actions.rs(PauseRecording,ResumeRecording,TogglePause,SwitchMic,SwitchCamera) cannot be reached via the existingTryFrom<&Url>parser, which always returnsErrfor non-file://URLs. The deep-link flow runs entirely through the TypeScript side.Confidence Score: 2/5
capture_system_audio: truesilently overrides user preferences. Both are behavioral regressions for existing users.apps/desktop/src/utils/recording-deeplinks.tsrequires the most attention (hardcoded audio setting, no bounds validation);apps/desktop/src/App.tsxneeds a window-label guard to prevent duplicate listener registration.Important Files Changed
capture_system_audiohardcoded totrue(overrides user settings), listener registered in every webview window causing duplicate command execution, and no structural validation of theboundsJSON parameter.initRecordingControlDeepLinks()call inonMountwith error handling, but does not restrict initialization to the main window, leading to multiple listener registrations across all webview contexts.DeepLinkActionenum variants and theirexecuteimplementations, but these variants are unreachable through the existingTryFrom<&Url>parser, making this Rust code effectively dead — the deep-link logic lives entirely in the TypeScript layer.Sequence Diagram
sequenceDiagram participant OS as OS / Raycast participant TauriPlugin as Tauri Deep-Link Plugin participant RustHandler as deeplink_actions::handle (Rust) participant TSHandler as onOpenUrl callback (TypeScript) participant TauriCmd as Tauri Commands participant Backend as Recording Backend (Rust) OS->>TauriPlugin: Open cap-desktop://record/stop TauriPlugin->>RustHandler: on_open_url event Note over RustHandler: TryFrom<&Url> returns NotAction<br/>(domain ≠ "action") → filtered out TauriPlugin->>TSHandler: onOpenUrl event (all windows) Note over TSHandler: Each webview window receives<br/>the same event independently TSHandler->>TSHandler: parseDeepLinkUrl() TSHandler->>TauriCmd: commands.stopRecording() TauriCmd->>Backend: stop_recording() Backend-->>TauriCmd: Ok / Err TauriCmd-->>TSHandler: result OS->>TauriPlugin: Open cap-desktop://record/start?target=display&displayId=1 TauriPlugin->>TSHandler: onOpenUrl event TSHandler->>TSHandler: parseDeepLinkUrl() → StartParams TSHandler->>TauriCmd: commands.startRecording({capture_target, capture_system_audio: true, mode}) Note over TSHandler: capture_system_audio always true<br/>(ignores user setting) TauriCmd->>Backend: start_recording() Backend-->>TauriCmd: Ok / Err TauriCmd-->>TSHandler: resultLast reviewed commit: e67c648
(2/5) Greptile learns from your feedback when you react with thumbs up/down!