feat: add deeplink support and Raycast extension (#1540)#1728
feat: add deeplink support and Raycast extension (#1540)#1728bcornish1797 wants to merge 3 commits intoCapSoftware:mainfrom
Conversation
apps/raycast/src/deeplink.ts
Outdated
| if (!url.startsWith("cap-desktop://")) { | ||
| throw new Error("Cap deeplink must use cap-desktop://"); | ||
| } |
There was a problem hiding this comment.
buildCapUrl always constructs the URL with the cap-desktop:// prefix via the template literal, so url.startsWith("cap-desktop://") is unconditionally true and the branch can never be taken. The guard can be removed without changing behavior.
| if (!url.startsWith("cap-desktop://")) { | |
| throw new Error("Cap deeplink must use cap-desktop://"); | |
| } | |
| const url = buildCapUrl(path, params); | |
| try { |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast/src/deeplink.ts
Line: 41-43
Comment:
**Dead safety guard**
`buildCapUrl` always constructs the URL with the `cap-desktop://` prefix via the template literal, so `url.startsWith("cap-desktop://")` is unconditionally `true` and the branch can never be taken. The guard can be removed without changing behavior.
```suggestion
const url = buildCapUrl(path, params);
try {
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
已处理:这个 dead guard 已在后续提交里移除。
| - `capture_type`: `screen` or `window` (required) | ||
| - `target`: screen/window name exactly as shown in Cap (required) | ||
| - `capture_system_audio`: `true` / `false` (optional) | ||
| - `mic_label`: microphone label exactly as shown in Cap (optional) | ||
| - omitting `mic_label`, `device_id`, `model_id`, and `off` keeps the current Cap inputs unchanged | ||
| - camera: | ||
| - `device_id=<id>` or `model_id=<VID:PID>` | ||
| - `off=true` to disable camera |
There was a problem hiding this comment.
mic_off parameter not documented
The Rust parser for record/start calls parse_microphone_from_query(&query, "mic_label", "mic_off"), meaning mic_off=true is a valid way to explicitly disable the microphone when starting a recording. This parameter is never mentioned in the docs — users relying on the docs will not know it exists.
Consider adding a bullet like:
- `mic_off=true` to disable microphone input (cannot be combined with `mic_label`)
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/DEEPLINKS.md
Line: 15-22
Comment:
**`mic_off` parameter not documented**
The Rust parser for `record/start` calls `parse_microphone_from_query(&query, "mic_label", "mic_off")`, meaning `mic_off=true` is a valid way to explicitly disable the microphone when starting a recording. This parameter is never mentioned in the docs — users relying on the docs will not know it exists.
Consider adding a bullet like:
```
- `mic_off=true` to disable microphone input (cannot be combined with `mic_label`)
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
已处理:文档里已补充 mic_off=true,且注明不能与 mic_label 同用。
|
Hi, it looks like the CI workflow is waiting for approval to run (first-time contributor from a fork). Could you please approve the workflow run? Thanks! |
|
@greptile-apps both review suggestions (dead safety guard in apps/raycast/src/deeplink.ts and mic_off parameter documentation in apps/desktop/src-tauri/DEEPLINKS.md) are already addressed in commit 1d8f6cf. Please re-review. |
|
Bumping this: greptile suggestions already addressed in commit 1d8f6cf (dead safety guard removed, mic_off documented). CI is green aside from the Vercel 'Authorization required to deploy' gate that's a fork-workflow permission issue, not code. Would appreciate a re-review @greptile-apps and a look from maintainers when time allows. Happy to add anything else if blocking. |
| let (mic_label, _) = parse_microphone_from_query(&query, "label", "off")?; | ||
| Ok(Self::SwitchMicrophone { mic_label }) |
There was a problem hiding this comment.
No-params call silently disables microphone
parse_microphone_from_query returns (None, false) when neither label nor off is present — the false signals "no change requested." By discarding it with _, this arm creates SwitchMicrophone { mic_label: None }, which calls set_mic_input(state, None). Confirmed from lib.rs:565-572: set_mic_input(None) compares against app.selected_mic_label and, when the mic is currently active, proceeds to disable it. A bare cap-desktop://device/microphone (no query params) will therefore silently disable an active microphone instead of being a no-op or returning an error.
Either require at least one of label or off and return a ParseFailed error when both are absent, or mirror the StartRecordingPath pattern and only call set_mic_input when apply_mic is true:
("device", "microphone") => {
let (mic_label, apply_mic) = parse_microphone_from_query(&query, "label", "off")?;
if !apply_mic {
return Err(ActionParseFromUrlError::ParseFailed(
"device/microphone requires 'label' or 'off=true'".to_string(),
));
}
Ok(Self::SwitchMicrophone { mic_label })
}Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 281-282
Comment:
**No-params call silently disables microphone**
`parse_microphone_from_query` returns `(None, false)` when neither `label` nor `off` is present — the `false` signals "no change requested." By discarding it with `_`, this arm creates `SwitchMicrophone { mic_label: None }`, which calls `set_mic_input(state, None)`. Confirmed from `lib.rs:565-572`: `set_mic_input(None)` compares against `app.selected_mic_label` and, when the mic is currently active, proceeds to disable it. A bare `cap-desktop://device/microphone` (no query params) will therefore silently disable an active microphone instead of being a no-op or returning an error.
Either require at least one of `label` or `off` and return a `ParseFailed` error when both are absent, or mirror the `StartRecordingPath` pattern and only call `set_mic_input` when `apply_mic` is `true`:
```rust
("device", "microphone") => {
let (mic_label, apply_mic) = parse_microphone_from_query(&query, "label", "off")?;
if !apply_mic {
return Err(ActionParseFromUrlError::ParseFailed(
"device/microphone requires 'label' or 'off=true'".to_string(),
));
}
Ok(Self::SwitchMicrophone { mic_label })
}
```
How can I resolve this? If you propose a fix, please make it concise.|
Hi, could a maintainer please approve the CI for this fork PR? All checks are ready to run. Thank you! |
|
Hi team — quick follow-up on this one. Looking at the CI state, the only remaining blocker is the Vercel deployment check, which shows Would any team member with Vercel team access mind approving the deployment when you get a chance? That's the single step between this PR and mergeability. Thanks! |
Address the remaining Greptile P1: a bare `cap-desktop://device/microphone`
URL (no `label` or `off=true`) previously constructed
`SwitchMicrophone { mic_label: None }`, which in turn called
`set_mic_input(None)` and silently disabled an active microphone. The
Raycast extension never sends this shape, but the Rust handler is a public
API surface and must not treat 'no intent' as 'disable'. The arm now
returns `ParseFailed` when neither `label` nor `off` is supplied,
mirroring the existing `StartRecordingPath` `apply_mic` pattern.
Also expands the deeplink test module to cover gaps highlighted during
review:
- regression: `rejects_microphone_with_no_params` locks in the fix above
- `parses_switch_microphone_label` (previously only `off=true` was tested)
- `parses_stop_pause_resume_restart_toggle` covers the five record/* unit
actions that had no direct tests
- `parses_settings_open_with_page` / `parses_settings_open_without_page`
- `rejects_unknown_path_domain` for the fallthrough arm
- `rejects_start_recording_invalid_capture_type` for capture_type guard
|
Quick update — pushed Fix: Tests added (in the same commit):
The test module now has 14 cases (up from 7) and the confirmed P1 is closed. @greptile-apps please re-review when you have a moment. @richiemcilroy @Brendonovich — current status at a glance:
Happy to add anything else if it helps the merge. |
| ("device", "camera") => Ok(Self::SwitchCamera { | ||
| camera: parse_camera_from_query(&query)?, | ||
| }), |
There was a problem hiding this comment.
Bare
device/camera URL silently disables camera
parse_camera_from_query returns Ok(None) when no params are present (all inputs None, off=false), so cap-desktop://device/camera with no query string produces SwitchCamera { camera: None } and calls set_camera_input(None) — which, confirmed from lib.rs:731-742, sets camera_in_use = false, clears selected_camera_id, and calls RemoveInput. This is exactly the same silent-disable bug that was fixed for device/microphone (which now requires label or off=true). The camera handler needs the same guard:
("device", "camera") => {
let camera = parse_camera_from_query(&query)?;
if !query.contains_key("device_id")
&& !query.contains_key("model_id")
&& !query.contains_key("off")
{
return Err(ActionParseFromUrlError::ParseFailed(
"device/camera requires 'device_id', 'model_id', or 'off=true'".to_string(),
));
}
Ok(Self::SwitchCamera { camera })
}A corresponding rejects_camera_with_no_params test should also be added to mirror rejects_microphone_with_no_params.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 290-292
Comment:
**Bare `device/camera` URL silently disables camera**
`parse_camera_from_query` returns `Ok(None)` when no params are present (all inputs `None`, `off=false`), so `cap-desktop://device/camera` with no query string produces `SwitchCamera { camera: None }` and calls `set_camera_input(None)` — which, confirmed from `lib.rs:731-742`, sets `camera_in_use = false`, clears `selected_camera_id`, and calls `RemoveInput`. This is exactly the same silent-disable bug that was fixed for `device/microphone` (which now requires `label` or `off=true`). The camera handler needs the same guard:
```rust
("device", "camera") => {
let camera = parse_camera_from_query(&query)?;
if !query.contains_key("device_id")
&& !query.contains_key("model_id")
&& !query.contains_key("off")
{
return Err(ActionParseFromUrlError::ParseFailed(
"device/camera requires 'device_id', 'model_id', or 'off=true'".to_string(),
));
}
Ok(Self::SwitchCamera { camera })
}
```
A corresponding `rejects_camera_with_no_params` test should also be added to mirror `rejects_microphone_with_no_params`.
How can I resolve this? If you propose a fix, please make it concise.
Summary
cap-desktop://deeplinks for start/stop/pause/resume/toggle/restart plus microphone, camera, and settings actionscap-desktop://action?value=...JSON deeplinksDeviceOrModelIDhandlingapps/raycast/demo/cap-raycast-demo.mp4/claim #1540
Validation
corepack pnpm --dir apps/raycast run typecheckNotes
apps/raycast/demo/cap-raycast-demo.mp4is included in this branch as a lightweight walkthrough videoray lintstill hits Raycast package validation / standalone lint wiring issues in this monorepo context, but the extension source typechecks cleanlyGreptile Summary
This PR adds path-based
cap-desktop://deeplinks (record/start,record/stop,record/pause,record/resume,record/toggle-pause,record/restart,device/microphone,device/camera,settings/open) alongside a new Raycast extension, while preserving the existingcap-desktop://action?value=...JSON format.device/camerabare URL silently disables camera:parse_camera_from_queryreturnsOk(None)when no query params are supplied. A barecap-desktop://device/cameratherefore createsSwitchCamera { camera: None }and callsset_camera_input(None), disabling an active camera with no error. This is the same bug that was explicitly fixed fordevice/microphone(which now requireslabeloroff=true). The camera handler needs the same guard, and a corresponding regression test.Confidence Score: 4/5
One P1 defect remains: bare
device/cameraURL silently disables the active camera; should be fixed before merge.The microphone guard was correctly applied, but the symmetric camera guard was missed. This is a present, reproducible defect on the changed code path, not a theoretical concern — it matches the exact pattern called out in the prior review thread for microphone.
apps/desktop/src-tauri/src/deeplink_actions.rs — the ("device", "camera") arm at lines 290-292
Important Files Changed
openis appropriate for a Raycast extension.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Incoming URL] --> B{scheme == cap-desktop?} B -- No --> N1[NotAction] B -- Yes --> C{path_action matches?} C -- Yes --> D{domain / action} D -- record/start --> E[StartRecordingPath] D -- record/stop --> F[StopRecording] D -- record/pause --> G[PauseRecording] D -- record/resume --> H[ResumeRecording] D -- record/toggle-pause --> I[TogglePauseRecording] D -- record/restart --> J[RestartRecording] D -- device/microphone --> K{label or off present?} K -- Yes --> L[SwitchMicrophone] K -- No --> M[ParseFailed error] D -- device/camera --> P[SwitchCamera - no guard: bare URL disables camera] D -- settings/open --> Q[OpenSettings] D -- unknown --> N2[NotAction] C -- No --> R{domain == action?} R -- Yes --> S[Parse legacy JSON value] S --> T[DeepLinkAction variant] R -- No --> N3[NotAction / Invalid] E & F & G & H & I & J & L & P & Q & T --> U[execute]Prompt To Fix All With AI
Reviews (4): Last reviewed commit: "fix(deeplink): reject bare device/microp..." | Re-trigger Greptile
Context used: