Skip to content

Bounty #1540: Deep Links Support + Raycast Extension#1655

Open
richardiitse wants to merge 1 commit intoCapSoftware:mainfrom
richardiitse:bounty-1540-deeplinks-raycast
Open

Bounty #1540: Deep Links Support + Raycast Extension#1655
richardiitse wants to merge 1 commit intoCapSoftware:mainfrom
richardiitse:bounty-1540-deeplinks-raycast

Conversation

@richardiitse
Copy link

@richardiitse richardiitse commented Mar 11, 2026

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 target
  • cap-desktop://record/stop - Stop current recording
  • cap-desktop://record/pause - Pause recording
  • cap-desktop://record/resume - Resume recording
  • cap-desktop://record/toggle - Toggle pause/resume
  • cap-desktop://devices/mic?name=<label> - Switch microphone
  • cap-desktop://devices/camera?id=<id> - Switch camera

2. 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/* and cap-desktop://devices/*) and a companion Raycast extension to Cap. The TypeScript handler in recording-deeplinks.ts is the functional implementation; the new Rust DeepLinkAction variants are added for completeness but are currently unreachable through the existing Rust URL parser. Three issues need attention before merging:

  • User setting bypassed: capture_system_audio is hardcoded to true in the start deep link, overriding whatever the user has configured in Cap settings.
  • Duplicate listener registration: initRecordingControlDeepLinks is called for every webview window (main, camera overlay, editor, etc.) because it runs unconditionally in App.tsx's Inner component. Since each window has its own JS runtime, a single incoming deep link will trigger the same Tauri command from every open window simultaneously.
  • Dead Rust code: The five new enum variants added to deeplink_actions.rs (PauseRecording, ResumeRecording, TogglePause, SwitchMic, SwitchCamera) cannot be reached via the existing TryFrom<&Url> parser, which always returns Err for non-file:// URLs. The deep-link flow runs entirely through the TypeScript side.

Confidence Score: 2/5

  • Not safe to merge without resolving the duplicate-listener and hardcoded system-audio issues.
  • The duplicate-listener bug can cause every open Cap window to simultaneously fire recording commands when a single deep link is received. The hardcoded capture_system_audio: true silently overrides user preferences. Both are behavioral regressions for existing users.
  • apps/desktop/src/utils/recording-deeplinks.ts requires the most attention (hardcoded audio setting, no bounds validation); apps/desktop/src/App.tsx needs a window-label guard to prevent duplicate listener registration.

Important Files Changed

Filename Overview
apps/desktop/src/utils/recording-deeplinks.ts New TypeScript deep-link handler — functional but has three issues: capture_system_audio hardcoded to true (overrides user settings), listener registered in every webview window causing duplicate command execution, and no structural validation of the bounds JSON parameter.
apps/desktop/src/App.tsx Minimal change — adds initRecordingControlDeepLinks() call in onMount with error handling, but does not restrict initialization to the main window, leading to multiple listener registrations across all webview contexts.
apps/desktop/src-tauri/src/deeplink_actions.rs Adds new DeepLinkAction enum variants and their execute implementations, but these variants are unreachable through the existing TryFrom<&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: result
Loading

Last reviewed commit: e67c648

Greptile also left 4 inline comments on this PR.

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

…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
Comment on lines +75 to +83
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;

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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;
}

Comment on lines +24 to +37
/**
* 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() {
Copy link

Choose a reason for hiding this comment

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

Repo convention here is no inline code comments; consider moving the route examples to a doc/README and keeping the code comment-free.

Suggested change
/**
* 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",
Copy link

Choose a reason for hiding this comment

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

params.mode as RecordingMode will allow any string through at runtime. Might be worth validating and defaulting cleanly.

Suggested change
mode: (params.mode as RecordingMode) || "studio",
mode:
params.mode === "studio" ||
params.mode === "instant" ||
params.mode === "screenshot"
? params.mode
: "studio",

Comment on lines 105 to +108
initAnonymousUser();
// Initialize deep link listener for recording controls
initRecordingControlDeepLinks().catch((err) =>
console.error("[App] Failed to init deep links:", err),
Copy link

Choose a reason for hiding this comment

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

Small thing: this repo avoids code comments, so I'd drop the inline // ... here.

Suggested change
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
Copy link

Choose a reason for hiding this comment

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

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.

Comment on lines +170 to +174
const result = await commands.startRecording({
capture_target: captureTarget,
capture_system_audio: true,
mode: mode || "studio",
});
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
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.

Comment on lines +45 to +61
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);
}
}
});
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +153 to +163
} 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;
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +159 to +173
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
}
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bounty: Deeplinks support + Raycast Extension

1 participant