feat: deeplinks for recording control + Raycast extension#1657
feat: deeplinks for recording control + Raycast extension#1657klawgulp-ship-it wants to merge 8 commits intoCapSoftware:mainfrom
Conversation
| export function initDeeplinks() { | ||
| onOpenUrl((urls) => { | ||
| for (const url of urls) { | ||
| handleDeeplink(url); |
There was a problem hiding this comment.
Dropping the promise here reads like an accident. Using void makes the intent explicit (and avoids any unhandled-promise linting).
| handleDeeplink(url); | |
| void handleDeeplink(url); |
|
|
||
| switch (action) { | ||
| case "start-recording": | ||
| await invoke("start_recording", params); |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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", | |||
There was a problem hiding this comment.
This $schema looks like the Raycast extension manifest schema rather than the TSConfig schema. Swapping it makes editor validation/autocomplete work correctly.
| "$schema": "https://www.raycast.com/schemas/extension.json", | |
| "$schema": "https://json.schemastore.org/tsconfig", |
| "description": "Restart the current Cap screen recording", | ||
| "mode": "no-view" | ||
| } | ||
| ], |
There was a problem hiding this comment.
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.
| try { | ||
| const parsed = new URL(url); |
There was a problem hiding this 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.
| 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.| export function initDeeplinks() { | ||
| onOpenUrl((urls) => { | ||
| for (const url of urls) { | ||
| handleDeeplink(url); | ||
| } | ||
| }); |
There was a problem hiding this 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.
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.| await invoke("set_microphone", { deviceId }); | ||
| break; | ||
| } | ||
| case "switch-camera": { | ||
| const deviceId = params["device-id"] ?? null; | ||
| await invoke("set_camera", { deviceId }); | ||
| break; |
There was a problem hiding this 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.
| 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.| await invoke("start_recording", params); | ||
| break; | ||
| case "stop-recording": |
There was a problem hiding this 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={…}).
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", | |||
There was a problem hiding this 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.
| "$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.| { | ||
| "$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" | ||
| } | ||
| } |
There was a problem hiding this 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.
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.|
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! |
Summary
What
cap://deeplink handler inapps/desktop/src/lib/deeplinks.tsfor recording control actions:start-recording,stop-recording,pause-recording,resume-recording,restart-recording,switch-microphone,switch-cameraapps/raycast-extension/with Raycast commands that invoke each deeplink viaopen("cap://...")and show a HUD confirmationWhy
switch-microphoneandswitch-cameraaccept an optional?device-id=query param for targeting a specific deviceUsage
Changes
Testing
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 inapps/desktop/src/lib/deeplinks.tshas several critical bugs that would prevent any deeplink from working at runtime.Key issues found:
tauri.conf.jsonregisterscap-desktopas the scheme, so all OS-dispatched URLs arrive ascap-desktop://…. The handler checks forcap:and will silently discard every incoming deeplink.initDeeplinks()is exported but never called anywhere in the app's startup flow, so theonOpenUrlsubscription is never established.invoke("set_microphone", …)andinvoke("set_camera", …)reference commands that do not exist; the registered names areset_mic_inputandset_camera_input.start_recordingreceives untyped params — raw URL query-string key/value pairs are passed directly to a command that expects a strongly-typedStartRecordingInputsstruct, causing a deserialization failure at runtime.apps/desktop/src-tauri/src/lib.rsalready registers anon_open_urlhandler viadeeplink_actions.rs. The two systems need to be reconciled to avoid racing on the same URL.switch-microphoneandswitch-cameraare described in the PR but neither source files norpackage.jsonentries were added for them.$schemaintsconfig.json— points to the Raycast extension schema instead of the TypeScript config schema.Confidence Score: 1/5
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, andstart_recordingwill throw a deserialization error. The Raycast commands themselves are structurally fine but inherit the broken scheme.Important Files Changed
cap:vs registeredcap-desktop:),initDeeplinks()never called, wrong Tauri command names for mic/camera (set_microphone/set_cameradon't exist), andstart_recordingpassed raw string params instead of a typed struct. Conflicts with existing Rust-levelon_open_urlhandler.switch-microphoneandswitch-cameracommands that are described in the PR and referenced by the deeplink handler.cap://start-recordingand shows a HUD. Will not work until the scheme is corrected tocap-desktop://in the deeplink handler (or scheme registration updated).$schemaincorrectly 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 argsLast reviewed commit: 2644b34
(2/5) Greptile learns from your feedback when you react with thumbs up/down!