Skip to content

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

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

feat: add deeplink support and Raycast extension (#1540)#1728
bcornish1797 wants to merge 2 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/*, device/*, settings/*) alongside a new Raycast extension with 9 commands, while preserving backward compatibility with the legacy cap-desktop://action?value=... JSON format. The implementation is well-structured with good input validation and unit tests — one P1 logic bug was found in the Rust deeplink parser for the device/microphone route.

Confidence Score: 4/5

Safe to merge after fixing the SwitchMicrophone no-params bug — the Raycast extension itself never triggers it, but the Rust handler has a confirmed incorrect behavior for that URL shape.

One confirmed P1 defect: discarding apply_mic=false in the device/microphone route causes a bare cap-desktop://device/microphone URL to silently disable an active microphone rather than error or no-op. Verified via set_mic_input implementation in lib.rs. All other findings are P2 style/refactoring suggestions. The Raycast extension and main recording controls are clean.

apps/desktop/src-tauri/src/deeplink_actions.rs — specifically the ("device", "microphone") arm at line 281

Important Files Changed

Filename Overview
apps/desktop/src-tauri/src/deeplink_actions.rs Adds path-based deeplink parsing (record/, device/, settings/*) with good validation; has a P1 bug where SwitchMicrophone discards the apply_mic signal, causing no-param calls to silently disable the microphone instead of no-op/error
apps/raycast/src/deeplink.ts Clean URL builder using execFile (no shell injection) and URL class for proper encoding; silently drops empty/whitespace-only params as designed
apps/raycast/src/start-recording.tsx Correct form with all recording options; empty mic label is safely filtered by buildCapUrl; no way to set mic_off=true but that's an intentional UI scope choice
apps/raycast/src/switch-microphone.tsx Always sends either label or off=true, never triggers the no-params bug; form description correctly communicates the empty=disable semantics
apps/raycast/src/switch-camera.tsx Camera form with mutually exclusive off/device_id/model_id options; validation prevents empty device/model IDs before submission
apps/raycast/package.json Standard Raycast extension manifest with all 9 commands correctly registered; no-view mode used appropriately for fire-and-forget commands
apps/desktop/src-tauri/DEEPLINKS.md Covers all new path-based endpoints and backward-compat legacy format; previously flagged mic_off omission has been addressed

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[cap-desktop:// URL received] --> B{path_action resolves?}
    B -- yes --> C{domain/action}
    B -- no --> D{scheme == cap-desktop?}
    D -- no --> E[NotAction]
    D -- yes --> F{domain == action?}
    F -- no --> G[NotAction / Invalid]
    F -- yes --> H[Parse JSON value param]
    H --> I[Legacy DeepLinkAction]

    C --> J["record/start → StartRecordingPath"]
    C --> K["record/stop|pause|resume|toggle-pause|restart"]
    C --> L["device/microphone → SwitchMicrophone"]
    C --> M["device/camera → SwitchCamera"]
    C --> N["settings/open → OpenSettings"]
    C --> O["unknown → NotAction"]

    J --> P[parse_camera_from_query\nparse_microphone_from_query\nparse_mode / parse_bool]
    L --> Q["parse_microphone_from_query\n⚠️ apply_mic flag discarded"]
    Q --> R["SwitchMicrophone { mic_label: None }\nif no params → silently disables mic"]

    K --> S[Execute recording command]
    P --> T[StartRecordingPath.execute\nconditional camera+mic apply]
    M --> U[SwitchCamera.execute]
    N --> V[show_window Settings]
    I --> W[Legacy StartRecording.execute\nunconditional camera+mic apply]
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: 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.

---

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

Comment:
**Duplicated capture-target resolution and `start_recording` call**

The `StartRecording` and `StartRecordingPath` execute arms share identical screen/window lookup and `start_recording` call logic. Extracting it into a shared helper avoids the duplication and keeps future changes (e.g. adding window-capture modes) in one place:

```rust
async fn resolve_and_start(
    app: &AppHandle,
    capture_mode: CaptureMode,
    capture_system_audio: bool,
    mode: RecordingMode,
) -> Result<(), String> {
    let state = app.state::<ArcLock<App>>();
    let capture_target = match capture_mode {
        CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays()
            .into_iter()
            .find(|(s, _)| s.name == name)
            .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id })
            .ok_or_else(|| format!("No screen with name \"{}\"", name))?,
        CaptureMode::Window(name) => cap_recording::screen_capture::list_windows()
            .into_iter()
            .find(|(w, _)| w.name == name)
            .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id })
            .ok_or_else(|| format!("No window with name \"{}\"", name))?,
    };
    let inputs = StartRecordingInputs { mode, capture_target, capture_system_audio, organization_id: None };
    crate::recording::start_recording(app.clone(), state, inputs).await.map(|_| ())
}
```

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

Reviews (3): Last reviewed commit: "fix: address Raycast review nits" | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

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!

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