Skip to content

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

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

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

Conversation

@bcornish1797
Copy link
Copy Markdown

@bcornish1797 bcornish1797 commented Apr 10, 2026

Summary

  • add path-based cap-desktop:// deeplinks for start/stop/pause/resume/toggle/restart plus microphone, camera, and settings actions
  • keep backward compatibility with the existing cap-desktop://action?value=... JSON deeplinks
  • avoid hardcoded device names in the Raycast extension and keep camera payloads aligned with Rust DeviceOrModelID handling
  • add desktop deeplink docs and a short demo video at apps/raycast/demo/cap-raycast-demo.mp4

/claim #1540

Validation

  • corepack pnpm --dir apps/raycast run typecheck

Notes

  • apps/raycast/demo/cap-raycast-demo.mp4 is included in this branch as a lightweight walkthrough video
  • I could not run the Rust-side cargo tests in this environment because Rust tooling is unavailable here
  • ray lint still hits Raycast package validation / standalone lint wiring issues in this monorepo context, but the extension source typechecks cleanly

Greptile 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 existing cap-desktop://action?value=... JSON format.

  • P1 — device/camera bare URL silently disables camera: parse_camera_from_query returns Ok(None) when no query params are supplied. A bare cap-desktop://device/camera therefore creates SwitchCamera { camera: None } and calls set_camera_input(None), disabling an active camera with no error. This is the same bug that was explicitly fixed for device/microphone (which now requires label or off=true). The camera handler needs the same guard, and a corresponding regression test.

Confidence Score: 4/5

One P1 defect remains: bare device/camera URL 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

Filename Overview
apps/desktop/src-tauri/src/deeplink_actions.rs Adds new path-based deep link parsing and execution; microphone guard is correct but the camera handler allows a bare URL to silently disable the camera (no params → Ok(None) → set_camera_input(None)).
apps/raycast/src/deeplink.ts Clean URL builder and opener; correctly filters empty/undefined params before appending to URL; macOS-only open is appropriate for a Raycast extension.
apps/raycast/src/start-recording.tsx Form-based start recording command with correct camera source handling; always sends explicit capture_system_audio value which is correct for the Rust boolean param.
apps/raycast/src/switch-camera.tsx Correctly passes device_id, model_id, or off=true; every dropdown value is explicitly handled so no bare URL is ever constructed from this component.
apps/raycast/src/switch-microphone.tsx Correctly maps empty label to off=true, preventing accidental bare microphone URL.
apps/desktop/src-tauri/DEEPLINKS.md Documents all new deeplinks accurately; mic_off=true and camera exclusion constraints are mentioned.
apps/raycast/package.json New Raycast extension manifest; all nine commands declared match the source files present.

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]
Loading
Prompt To Fix All 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.

---

This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 526-528

Comment:
**Code comments violate project no-comments rule**

The three `//`-prefixed lines inside `rejects_microphone_with_no_params` violate the CLAUDE.md convention that bans all code comments. The test name and the assertion already convey the intent clearly without prose annotation.

```suggestion
    fn rejects_microphone_with_no_params() {
        let url = Url::parse("cap-desktop://device/microphone").unwrap();
```

**Context Used:** CLAUDE.md ([source](https://app.greptile.com/review/custom-context?memory=9a906542-f1fe-42c1-89a2-9f252d96d9f0))

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

Reviews (4): Last reviewed commit: "fix(deeplink): reject bare device/microp..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

  • Context used - CLAUDE.md (source)

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.

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