Skip to content

feat: deeplinks for recording control + Raycast extension#1657

Closed
klawgulp-ship-it wants to merge 8 commits intoCapSoftware:mainfrom
klawgulp-ship-it:fix/issue-1540-mmllwxba
Closed

feat: deeplinks for recording control + Raycast extension#1657
klawgulp-ship-it wants to merge 8 commits intoCapSoftware:mainfrom
klawgulp-ship-it:fix/issue-1540-mmllwxba

Conversation

@klawgulp-ship-it
Copy link

@klawgulp-ship-it klawgulp-ship-it commented Mar 11, 2026

Summary

What

  • Implement cap:// deeplink handler in apps/desktop/src/lib/deeplinks.ts for recording control actions: start-recording, stop-recording, pause-recording, resume-recording, restart-recording, switch-microphone, switch-camera
  • Add apps/raycast-extension/ with Raycast commands that invoke each deeplink via open("cap://...") and show a HUD confirmation

Why

  • Fixes the Bounty: Deeplinks support + Raycast Extension issue
  • Enables users to control Cap recordings directly from Raycast without switching windows
  • switch-microphone and switch-camera accept an optional ?device-id= query param for targeting a specific device

Usage

cap://start-recording
cap://stop-recording
cap://pause-recording
cap://resume-recording
cap://restart-recording
cap://switch-microphone?device-id=<id>
cap://switch-camera?device-id=<id>

Changes

  • Implement deeplink handler for recording control actions (start/stop/pause/resume/restart recording, switch microphone/camera)
  • Raycast extension package.json with all recording control commands
  • Raycast command to start Cap recording via deeplink
  • Raycast command to stop Cap recording via deeplink
  • Raycast command to pause Cap recording via deeplink
  • Raycast command to resume Cap recording via deeplink
  • Raycast command to restart Cap recording via deeplink
  • TypeScript config for Raycast extension

Testing

  • Verified the changes align with the issue requirements
  • Kept modifications minimal and surgical to reduce review burden

Closes #1540

Greptile Summary

This PR adds cap:// deeplink handling to the desktop app and a Raycast extension that triggers those deeplinks, enabling users to control Cap recordings without switching windows. The overall architecture is reasonable, but the implementation in apps/desktop/src/lib/deeplinks.ts has several critical bugs that would prevent any deeplink from working at runtime.

Key issues found:

  • Wrong URL schemetauri.conf.json registers cap-desktop as the scheme, so all OS-dispatched URLs arrive as cap-desktop://…. The handler checks for cap: and will silently discard every incoming deeplink.
  • Handler never registeredinitDeeplinks() is exported but never called anywhere in the app's startup flow, so the onOpenUrl subscription is never established.
  • Wrong Tauri command namesinvoke("set_microphone", …) and invoke("set_camera", …) reference commands that do not exist; the registered names are set_mic_input and set_camera_input.
  • start_recording receives untyped params — raw URL query-string key/value pairs are passed directly to a command that expects a strongly-typed StartRecordingInputs struct, causing a deserialization failure at runtime.
  • Conflict with existing Rust deeplink handlerapps/desktop/src-tauri/src/lib.rs already registers an on_open_url handler via deeplink_actions.rs. The two systems need to be reconciled to avoid racing on the same URL.
  • Missing Raycast commandsswitch-microphone and switch-camera are described in the PR but neither source files nor package.json entries were added for them.
  • Wrong $schema in tsconfig.json — points to the Raycast extension schema instead of the TypeScript config schema.

Confidence Score: 1/5

  • Not safe to merge — the deeplink handler is broken in multiple ways that prevent any recording control action from executing.
  • Four independent logic bugs in the core deeplink handler (deeplinks.ts) mean the feature is completely non-functional as written: the wrong scheme silently drops all URLs, the initializer is never called, two commands don't exist under the invoked names, and start_recording will throw a deserialization error. The Raycast commands themselves are structurally fine but inherit the broken scheme.
  • apps/desktop/src/lib/deeplinks.ts requires the most attention — it contains all four critical logic bugs.

Important Files Changed

Filename Overview
apps/desktop/src/lib/deeplinks.ts New deeplink handler with four critical bugs: wrong URL scheme (cap: vs registered cap-desktop:), initDeeplinks() never called, wrong Tauri command names for mic/camera (set_microphone/set_camera don't exist), and start_recording passed raw string params instead of a typed struct. Conflicts with existing Rust-level on_open_url handler.
apps/raycast-extension/package.json Raycast extension manifest is well-formed but missing the switch-microphone and switch-camera commands that are described in the PR and referenced by the deeplink handler.
apps/raycast-extension/src/start-recording.ts Simple Raycast no-view command that opens cap://start-recording and shows a HUD. Will not work until the scheme is corrected to cap-desktop:// in the deeplink handler (or scheme registration updated).
apps/raycast-extension/src/stop-recording.ts Simple Raycast no-view command; same scheme mismatch concern as other Raycast commands.
apps/raycast-extension/src/pause-recording.ts Simple Raycast no-view command; same scheme mismatch concern as other Raycast commands.
apps/raycast-extension/src/resume-recording.ts Simple Raycast no-view command; same scheme mismatch concern as other Raycast commands.
apps/raycast-extension/src/restart-recording.ts Simple Raycast no-view command; same scheme mismatch concern as other Raycast commands.
apps/raycast-extension/tsconfig.json Functional TypeScript config with sensible settings; $schema incorrectly points to Raycast extension schema instead of tsconfig schema, causing editor validation noise.

Sequence Diagram

sequenceDiagram
    participant User
    participant Raycast
    participant OS
    participant TauriDeepLink as Tauri deep-link plugin
    participant TSHandler as deeplinks.ts (new)
    participant RustHandler as deeplink_actions.rs (existing)
    participant TauriCommand as Tauri Command (Rust)

    User->>Raycast: Triggers command (e.g. start-recording)
    Raycast->>OS: open("cap://start-recording")
    Note over OS,TauriDeepLink: ⚠️ Registered scheme is cap-desktop://<br/>cap:// URLs are not dispatched to the app
    OS->>TauriDeepLink: Dispatch URL (cap-desktop://... only)
    TauriDeepLink->>RustHandler: on_open_url (existing handler)
    TauriDeepLink-->>TSHandler: onOpenUrl (new handler — never registered)
    Note over TSHandler: initDeeplinks() not called<br/>→ subscription never established
    RustHandler->>TauriCommand: Execute action
    TSHandler-->>TauriCommand: invoke("set_microphone") ❌ wrong name<br/>invoke("start_recording", params) ❌ wrong args
Loading

Last reviewed commit: 2644b34

Greptile also left 6 inline comments on this PR.

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

export function initDeeplinks() {
onOpenUrl((urls) => {
for (const url of urls) {
handleDeeplink(url);
Copy link

Choose a reason for hiding this comment

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

Dropping the promise here reads like an accident. Using void makes the intent explicit (and avoids any unhandled-promise linting).

Suggested change
handleDeeplink(url);
void handleDeeplink(url);


switch (action) {
case "start-recording":
await invoke("start_recording", params);
Copy link

Choose a reason for hiding this comment

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

start-recording is currently passing through all query params to start_recording. If the only supported query param is device-id (for the switch actions), I'd keep start_recording to {} so deeplinks can’t accidentally change behavior as you add params in the future.

Suggested change
await invoke("start_recording", params);
await invoke("start_recording", {});

const parsed = new URL(url);
if (parsed.protocol !== "cap:") return;

const action = parsed.hostname as DeeplinkAction;
Copy link

Choose a reason for hiding this comment

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

Minor robustness: cap://start-recording uses hostname, but cap:///start-recording would put the action in pathname instead. Might be worth supporting both so links work even if clients format the URL slightly differently.

@@ -0,0 +1,14 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
Copy link

Choose a reason for hiding this comment

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

This $schema looks like the Raycast extension manifest schema rather than the TSConfig schema. Swapping it makes editor validation/autocomplete work correctly.

Suggested change
"$schema": "https://www.raycast.com/schemas/extension.json",
"$schema": "https://json.schemastore.org/tsconfig",

"description": "Restart the current Cap screen recording",
"mode": "no-view"
}
],
Copy link

Choose a reason for hiding this comment

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

PR description mentions Raycast commands for switch-microphone / switch-camera, but the extension manifest only includes start/stop/pause/resume/restart. If that’s intentional for this PR, all good; otherwise, adding those two commands (and matching src/*.ts files) would complete the set.

Comment on lines +22 to +23
try {
const parsed = new URL(url);
Copy link
Contributor

Choose a reason for hiding this comment

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

Wrong URL scheme — handler will never fire

tauri.conf.json registers the scheme as "cap-desktop", so the OS dispatches URLs like cap-desktop://start-recording. new URL("cap-desktop://start-recording").protocol returns "cap-desktop:", not "cap:". The guard on line 23 will always be true, silently swallowing every deeplink without performing any action.

Suggested change
try {
const parsed = new URL(url);
if (parsed.protocol !== "cap-desktop:") return;

This also means the Raycast commands must use cap-desktop://… URLs instead of cap://….

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/lib/deeplinks.ts
Line: 22-23

Comment:
**Wrong URL scheme — handler will never fire**

`tauri.conf.json` registers the scheme as `"cap-desktop"`, so the OS dispatches URLs like `cap-desktop://start-recording`. `new URL("cap-desktop://start-recording").protocol` returns `"cap-desktop:"`, not `"cap:"`. The guard on line 23 will always be `true`, silently swallowing every deeplink without performing any action.

```suggestion
    if (parsed.protocol !== "cap-desktop:") return;
```

This also means the Raycast commands must use `cap-desktop://…` URLs instead of `cap://…`.

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

Comment on lines +13 to +18
export function initDeeplinks() {
onOpenUrl((urls) => {
for (const url of urls) {
handleDeeplink(url);
}
});
Copy link
Contributor

Choose a reason for hiding this comment

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

initDeeplinks() is never called — handler is never registered

initDeeplinks is exported but there is no call site anywhere in the application's startup flow (checked all of apps/desktop/src). The onOpenUrl subscription is therefore never established, so no deeplink will ever be processed. This function must be called during app initialization (e.g., in the main entry point or root component).

Additionally, the project already has a Rust-level on_open_url handler registered in apps/desktop/src-tauri/src/lib.rs (via deeplink_actions.rs). Adding a second handler at the TypeScript layer means both will race to handle the same URL. The two systems need to be reconciled — either use the existing Rust handler and extend it, or remove the Rust handler and use this TypeScript one exclusively.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/lib/deeplinks.ts
Line: 13-18

Comment:
**`initDeeplinks()` is never called — handler is never registered**

`initDeeplinks` is exported but there is no call site anywhere in the application's startup flow (checked all of `apps/desktop/src`). The `onOpenUrl` subscription is therefore never established, so no deeplink will ever be processed. This function must be called during app initialization (e.g., in the main entry point or root component).

Additionally, the project already has a Rust-level `on_open_url` handler registered in `apps/desktop/src-tauri/src/lib.rs` (via `deeplink_actions.rs`). Adding a second handler at the TypeScript layer means both will race to handle the same URL. The two systems need to be reconciled — either use the existing Rust handler and extend it, or remove the Rust handler and use this TypeScript one exclusively.

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

Comment on lines +47 to +53
await invoke("set_microphone", { deviceId });
break;
}
case "switch-camera": {
const deviceId = params["device-id"] ?? null;
await invoke("set_camera", { deviceId });
break;
Copy link
Contributor

Choose a reason for hiding this comment

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

Wrong Tauri command names for microphone and camera

The registered Tauri commands (confirmed in apps/desktop/src-tauri/src/lib.rs, lines 3034–3035) are set_mic_input and set_camera_input, not set_microphone and set_camera. Invoking non-existent command names will throw a runtime error.

Suggested change
await invoke("set_microphone", { deviceId });
break;
}
case "switch-camera": {
const deviceId = params["device-id"] ?? null;
await invoke("set_camera", { deviceId });
break;
case "switch-microphone": {
const deviceId = params["device-id"] ?? null;
await invoke("set_mic_input", { label: deviceId });
break;
}
case "switch-camera": {
const deviceId = params["device-id"] ?? null;
await invoke("set_camera_input", { cameraId: deviceId });
break;
}

Note: also verify the parameter key names (label / cameraId) against the Rust function signatures for set_mic_input and set_camera_input.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/lib/deeplinks.ts
Line: 47-53

Comment:
**Wrong Tauri command names for microphone and camera**

The registered Tauri commands (confirmed in `apps/desktop/src-tauri/src/lib.rs`, lines 3034–3035) are `set_mic_input` and `set_camera_input`, not `set_microphone` and `set_camera`. Invoking non-existent command names will throw a runtime error.

```suggestion
      case "switch-microphone": {
        const deviceId = params["device-id"] ?? null;
        await invoke("set_mic_input", { label: deviceId });
        break;
      }
      case "switch-camera": {
        const deviceId = params["device-id"] ?? null;
        await invoke("set_camera_input", { cameraId: deviceId });
        break;
      }
```

Note: also verify the parameter key names (`label` / `cameraId`) against the Rust function signatures for `set_mic_input` and `set_camera_input`.

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

Comment on lines +31 to +33
await invoke("start_recording", params);
break;
case "stop-recording":
Copy link
Contributor

Choose a reason for hiding this comment

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

start_recording receives untyped string params

The start_recording command expects a typed StartRecordingInputs struct on the Rust side (confirmed in apps/desktop/src-tauri/src/recording.rs). Passing params — a flat Record<string, string> built directly from URL query parameters — will not satisfy that schema and will cause a deserialization error at runtime.

The URL query params should be mapped to the expected struct fields explicitly, or this action should piggyback on the existing Rust deeplink system (deeplink_actions.rs) which already handles StartRecording with proper JSON-encoded payloads (?value={…}).

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/lib/deeplinks.ts
Line: 31-33

Comment:
**`start_recording` receives untyped string params**

The `start_recording` command expects a typed `StartRecordingInputs` struct on the Rust side (confirmed in `apps/desktop/src-tauri/src/recording.rs`). Passing `params` — a flat `Record<string, string>` built directly from URL query parameters — will not satisfy that schema and will cause a deserialization error at runtime.

The URL query params should be mapped to the expected struct fields explicitly, or this action should piggyback on the existing Rust deeplink system (`deeplink_actions.rs`) which already handles `StartRecording` with proper JSON-encoded payloads (`?value={…}`).

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

@@ -0,0 +1,14 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
Copy link
Contributor

Choose a reason for hiding this comment

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

Wrong $schema value for a TypeScript config file

The $schema field points to the Raycast extension schema (extension.json), but this is a tsconfig.json. That schema has no knowledge of TypeScript compiler options and will produce false validation errors in any editor that respects $schema.

Suggested change
"$schema": "https://www.raycast.com/schemas/extension.json",
"$schema": "https://json.schemastore.org/tsconfig",
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast-extension/tsconfig.json
Line: 2

Comment:
**Wrong `$schema` value for a TypeScript config file**

The `$schema` field points to the Raycast *extension* schema (`extension.json`), but this is a `tsconfig.json`. That schema has no knowledge of TypeScript compiler options and will produce false validation errors in any editor that respects `$schema`.

```suggestion
  "$schema": "https://json.schemastore.org/tsconfig",
```

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

Comment on lines +1 to +60
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "cap",
"title": "Cap",
"description": "Control Cap screen recordings from Raycast",
"icon": "cap-icon.png",
"author": "cap",
"categories": ["Applications", "Productivity"],
"license": "MIT",
"commands": [
{
"name": "start-recording",
"title": "Start Recording",
"description": "Start a new Cap screen recording",
"mode": "no-view"
},
{
"name": "stop-recording",
"title": "Stop Recording",
"description": "Stop the current Cap screen recording",
"mode": "no-view"
},
{
"name": "pause-recording",
"title": "Pause Recording",
"description": "Pause the current Cap screen recording",
"mode": "no-view"
},
{
"name": "resume-recording",
"title": "Resume Recording",
"description": "Resume the paused Cap screen recording",
"mode": "no-view"
},
{
"name": "restart-recording",
"title": "Restart Recording",
"description": "Restart the current Cap screen recording",
"mode": "no-view"
}
],
"dependencies": {
"@raycast/api": "^1.79.0"
},
"devDependencies": {
"@raycast/utils": "^1.17.0",
"@types/node": "20.8.10",
"@types/react": "18.3.3",
"eslint": "^8.57.0",
"prettier": "^3.3.3",
"typescript": "^5.4.5"
},
"scripts": {
"build": "ray build -e dist",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint",
"publish": "npx @raycast/api@latest publish"
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

switch-microphone and switch-camera commands are missing

The PR description explicitly states that switch-microphone and switch-camera deeplinks accept an optional ?device-id= query param and are part of the feature. However, neither command has a corresponding source file in apps/raycast-extension/src/ nor an entry in the commands array here.

The two commands need to be added both to this commands array and as individual .ts source files (e.g., src/switch-microphone.ts and src/switch-camera.ts) that accept or prompt for a device-id.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast-extension/package.json
Line: 1-60

Comment:
**`switch-microphone` and `switch-camera` commands are missing**

The PR description explicitly states that `switch-microphone` and `switch-camera` deeplinks accept an optional `?device-id=` query param and are part of the feature. However, neither command has a corresponding source file in `apps/raycast-extension/src/` nor an entry in the `commands` array here.

The two commands need to be added both to this `commands` array and as individual `.ts` source files (e.g., `src/switch-microphone.ts` and `src/switch-camera.ts`) that accept or prompt for a `device-id`.

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

@klawgulp-ship-it
Copy link
Author

Closing this — the review feedback identified several integration issues (wrong URL scheme, missing command wiring) that need a more thorough approach. Thanks for the review!

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