Skip to content

feat: add deeplink support and Raycast extension (#1540)#1728

Open
bcornish1797 wants to merge 4 commits intoCapSoftware:mainfrom
bcornish1797:feat/1540-deeplinks-raycast-bcornish
Open

feat: add deeplink support and Raycast extension (#1540)#1728
bcornish1797 wants to merge 4 commits intoCapSoftware:mainfrom
bcornish1797:feat/1540-deeplinks-raycast-bcornish

Conversation

@bcornish1797
Copy link
Copy Markdown

@bcornish1797 bcornish1797 commented Apr 10, 2026

Adds path-based cap-desktop:// deeplinks for start/stop/pause/resume/toggle/restart plus microphone, camera, and settings. Keeps the existing cap-desktop://action?value=... JSON format working. Includes a small Raycast extension that uses the new deeplinks.

/claim #1540

Demo video at apps/raycast/demo/cap-raycast-demo.mp4. Raycast typecheck passes. I couldn't run cargo tests in my environment, and ray lint still trips on this monorepo's Raycast validation wiring, but the extension source typechecks cleanly.

Comment thread apps/raycast/src/deeplink.ts Outdated
Comment on lines +41 to +43
if (!url.startsWith("cap-desktop://")) {
throw new Error("Cap deeplink must use cap-desktop://");
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 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.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

已处理:这个 dead guard 已在后续提交里移除。

Comment on lines +15 to +22
- `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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

已处理:文档里已补充 mic_off=true,且注明不能与 mic_label 同用。

@bcornish1797
Copy link
Copy Markdown
Author

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!

@bcornish1797
Copy link
Copy Markdown
Author

@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.

@bcornish1797
Copy link
Copy Markdown
Author

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.

Comment on lines +281 to +282
let (mic_label, _) = parse_microphone_from_query(&query, "label", "off")?;
Ok(Self::SwitchMicrophone { mic_label })
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 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.

@bcornish1797
Copy link
Copy Markdown
Author

Hi, could a maintainer please approve the CI for this fork PR? All checks are ready to run. Thank you!

@bcornish1797
Copy link
Copy Markdown
Author

Hi team — quick follow-up on this one. Looking at the CI state, the only remaining blocker is the Vercel deployment check, which shows Authorization required to deploy because Vercel deployments from external fork PRs need a Cap team member (Member role or above on the Vercel team) to click the Authorize button in the Vercel dashboard. All other checks are green (Greptile ✅, etc.), and the PR is MERGEABLE with no conflicts.

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
@bcornish1797
Copy link
Copy Markdown
Author

Quick update — pushed 62c9d1b to address Greptile's remaining P1 finding (the one blocking the 5/5 confidence score):

Fix: ("device", "microphone") now returns ParseFailed when neither label nor off is supplied. Previously a bare cap-desktop://device/microphone URL would silently disable an active microphone because the discarded apply_mic signal caused set_mic_input(None) to fire. The Raycast extension never sends this shape, but the Rust deeplink handler is a public API surface and shouldn't treat 'no intent' as 'disable'. The arm now mirrors the apply_mic discipline already used in StartRecordingPath.

Tests added (in the same commit):

  • rejects_microphone_with_no_params — regression lock on the fix above
  • parses_switch_microphone_label — previously only off=true was covered
  • parses_stop_pause_resume_restart_toggle — the five record/* unit actions had no direct tests
  • parses_settings_open_with_page / parses_settings_open_without_page
  • rejects_unknown_path_domain — fallthrough arm
  • rejects_start_recording_invalid_capture_type — capture_type guard

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:

  • ✅ Greptile: green (pending re-review of this commit; P1 now addressed in-code)
  • ✅ Mergeable: no conflicts
  • ⏸️ Vercel: Authorization required to deploy — this is a fork-workflow permission gate; a Cap team member with Vercel team access needs to click Authorize in the Vercel dashboard. That's the only non-code item between this PR and mergeability.

Happy to add anything else if it helps the merge.

Comment on lines +290 to +292
("device", "camera") => Ok(Self::SwitchCamera {
camera: parse_camera_from_query(&query)?,
}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 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.

Mirror of the recent device/microphone fix. A bare
cap-desktop://device/camera URL would previously produce
SwitchCamera { camera: None }, which silently disables an active
camera. The arm now requires at least one of device_id, model_id, or
off=true and returns ParseFailed otherwise, matching the microphone
discipline.

Adds two tests: rejects_camera_with_no_params locks in the fix, and
parses_switch_camera_off covers the explicit disable path so the
rejection does not regress it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant