Skip to content

Deep-link control for pause/resume/switch + Raycast extension#1829

Open
js360000 wants to merge 3 commits into
CapSoftware:mainfrom
js360000:bountybox/issue-1540
Open

Deep-link control for pause/resume/switch + Raycast extension#1829
js360000 wants to merge 3 commits into
CapSoftware:mainfrom
js360000:bountybox/issue-1540

Conversation

@js360000
Copy link
Copy Markdown

@js360000 js360000 commented May 16, 2026

Summary

feat(desktop, raycast): deep-link control for pause/resume/switch + Raycast extension (#1540)

Extends the existing cap-desktop://action deep-link surface in
deeplink_actions.rs with the four operations the bounty body calls
for that weren't already wired through it:

  • pause_recording → forwards to recording::pause_recording
  • resume_recording → forwards to recording::resume_recording
  • switch_camera → forwards to set_camera_input(...) with
    Option<DeviceOrModelID> exactly as
    start_recording already accepts it
  • switch_microphone → forwards to set_mic_input(...) with
    Option<String>

start_recording, stop_recording, open_editor, and
open_settings are unchanged.

Unit tests cover every new variant plus the existing error paths
(Invalid, NotAction, ParseFailed) using Url::parse_with_params
so the test cases exercise the same query-pair decode path that the
real tauri-plugin-deep-link callback uses.

The wire format is documented in a new
apps/desktop/src-tauri/DEEPLINKS.md so future deep-link work
(including the OS-level shortcut wiring for Windows) doesn't have to
re-derive it from source.

A minimal Raycast extension lives at apps/raycast/ and is wired into
the existing pnpm workspace (apps/* glob in
pnpm-workspace.yaml). Five no-view commands:

  • Stop Cap Recording
  • Pause Cap Recording
  • Resume Cap Recording
  • Switch Cap Microphone (text argument: label, blank to clear)
  • Switch Cap Camera (text argument: ModelID or DeviceID, hex/dash
    pattern routes to DeviceID, anything else to ModelID, blank to
    clear)

Each command builds a cap-desktop://action?value=<json> URL via a
shared fireAction helper that uses @raycast/api's open() and
shows a HUD on success or a failure toast if the OS handler rejects
the open (Cap not installed / not running). Start-recording is
intentionally not wired into Raycast in this PR — picking a capture
target needs a multi-step picker that we can layer on once the
deep-link side has merged.

Local gates run: cargo fmt -p cap-desktop, biome check apps/raycast. Full cargo clippy -p cap-desktop --all-targets -- -D warnings could not run locally because ring fails to build on this
Windows ARM64 box (C-toolchain permissions issue unrelated to this
change); CI will exercise it.

Closes #1540

Diff

 apps/desktop/src-tauri/DEEPLINKS.md            |  43 +++++++++
 apps/desktop/src-tauri/src/deeplink_actions.rs | 123 +++++++++++++++++++++++++
 apps/raycast/README.md                         |  23 +++++
 apps/raycast/icon.png                          | Bin 0 -> 710419 bytes
 apps/raycast/package.json                      |  81 ++++++++++++++++
 apps/raycast/src/deeplink.ts                   |  37 ++++++++
 apps/raycast/src/pause-recording.ts            |   5 +
 apps/raycast/src/resume-recording.ts           |   5 +
 apps/raycast/src/stop-recording.ts             |   5 +
 apps/raycast/src/switch-camera.ts              |  22 +++++
 apps/raycast/src/switch-microphone.ts          |  17 ++++
 apps/raycast/tsconfig.json                     |  15 +++
 12 files changed, 376 insertions(+)

Claim

/claim #1540

Greptile Summary

This PR extends the cap-desktop://action deep-link surface with four new actions (pause/resume recording, switch camera/microphone) and adds a Raycast extension that wraps them as no-view commands. The Rust dispatch logic and the TypeScript command layer are both well-structured.

  • deeplink_actions.rs: New enum variants and their execute arms look correct, but all nine new unit tests will fail at the parse(\u2026).expect(\"parses\") call because the pre-existing try_from guard (_ => Err(Invalid)) always short-circuits before reaching the JSON deserialization. The guard needs a Some(\"action\") => Ok(()) arm.
  • apps/raycast/: Five no-view commands correctly map to deep-link payloads via a shared fireAction helper; the DEVICE_ID_PATTERN regex for camera routing is broader than a UUID pattern and could misclassify short hex-looking ModelIDs.

Confidence Score: 3/5

The newly added actions won’t dispatch and the new tests won’t pass until the try_from guard is corrected; the Raycast extension itself is safe to merge independently.

The pre-existing match guard in try_from makes all URL parsing fail before the JSON deserializer is reached, meaning none of the four new deep-link actions would actually fire at runtime, and every one of the nine new tests would panic. Fixing the guard is a one-liner but it must happen before any of the Rust-side changes are functional.

apps/desktop/src-tauri/src/deeplink_actions.rs — specifically the try_from match guard at the top of the URL parsing path.

Important Files Changed

Filename Overview
apps/desktop/src-tauri/src/deeplink_actions.rs Adds PauseRecording, ResumeRecording, SwitchCamera, SwitchMicrophone variants and 9 unit tests; dispatch logic is correct, but all new tests would fail due to a pre-existing inverted guard in try_from that always returns Err before reaching the JSON parsing code.
apps/raycast/src/deeplink.ts Shared fireAction/buildActionUrl helper; URL construction is correct, but uses a template literal for string payloads instead of JSON.stringify — safe today, fragile if string variants ever contain special characters.
apps/raycast/src/switch-camera.ts Camera identifier routing to DeviceID vs ModelID uses an overly broad regex that matches any 8+ hex/dash chars; a stricter UUID pattern would reduce misclassification.
apps/raycast/package.json Raycast manifest declares all five no-view commands correctly; author field set to cap which would need to match a valid Raycast store handle before store submission.
apps/desktop/src-tauri/DEEPLINKS.md New documentation file accurately describing the deep-link wire format for all supported actions.
apps/raycast/src/pause-recording.ts Minimal no-view command; delegates entirely to fireAction.
apps/raycast/src/resume-recording.ts Minimal no-view command; delegates entirely to fireAction.
apps/raycast/src/stop-recording.ts Minimal no-view command; delegates entirely to fireAction.

Comments Outside Diff (1)

  1. apps/desktop/src-tauri/src/deeplink_actions.rs, line 99-113 (link)

    P1 try_from guard always short-circuits — all new unit tests would fail

    Both arms of the match return Err(...), so the ? always returns early before the query-pair parsing is reached. When url.domain() is Some("action"), the first arm's guard (v != "action") is false, so it falls through to _ => Err(Invalid). The function never reaches the query_pairs() / serde_json::from_str code, meaning every cap-desktop://action?value=… URL is silently rejected as Invalid. Every new test in this PR that calls parse(…).expect("parses") would panic.

    The guard logic is inverted — a Some("action") => Ok(()) arm is missing. This is pre-existing code, but the nine new unit tests added here are the first to exercise it, so CI would surface the failures on this PR.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src-tauri/src/deeplink_actions.rs
    Line: 99-113
    
    Comment:
    **`try_from` guard always short-circuits — all new unit tests would fail**
    
    Both arms of the match return `Err(...)`, so the `?` always returns early before the query-pair parsing is reached. When `url.domain()` is `Some("action")`, the first arm's guard (`v != "action"`) is `false`, so it falls through to `_ => Err(Invalid)`. The function never reaches the `query_pairs()` / `serde_json::from_str` code, meaning every `cap-desktop://action?value=…` URL is silently rejected as `Invalid`. Every new test in this PR that calls `parse(…).expect("parses")` would panic.
    
    The guard logic is inverted — a `Some("action") => Ok(())` arm is missing. This is pre-existing code, but the nine new unit tests added here are the first to exercise it, so CI would surface the failures on this PR.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/desktop/src-tauri/src/deeplink_actions.rs:99-113
**`try_from` guard always short-circuits — all new unit tests would fail**

Both arms of the match return `Err(...)`, so the `?` always returns early before the query-pair parsing is reached. When `url.domain()` is `Some("action")`, the first arm's guard (`v != "action"`) is `false`, so it falls through to `_ => Err(Invalid)`. The function never reaches the `query_pairs()` / `serde_json::from_str` code, meaning every `cap-desktop://action?value=…` URL is silently rejected as `Invalid`. Every new test in this PR that calls `parse(…).expect("parses")` would panic.

The guard logic is inverted — a `Some("action") => Ok(())` arm is missing. This is pre-existing code, but the nine new unit tests added here are the first to exercise it, so CI would surface the failures on this PR.

### Issue 2 of 3
apps/raycast/src/deeplink.ts:15-16
`buildActionUrl` manually wraps string payloads in quotes with a template literal instead of delegating to `JSON.stringify`. For the three current string literals this is safe, but if a string variant were ever added that contains a backslash or a double-quote, the output would be invalid JSON. Using `JSON.stringify` uniformly avoids the inconsistency.

```suggestion
	const value = JSON.stringify(payload);
```

### Issue 3 of 3
apps/raycast/src/switch-camera.ts:8
The `DEVICE_ID_PATTERN` regex `/^[0-9a-fA-F-]{8,}$/` allows strings like `--------` (8 hyphens) or `aaaaaaaa` (8 repeated chars) to be classified as `DeviceID`s. A camera ModelID that starts with 8 hex-compatible characters could be misrouted. A pattern that requires at least one hex digit segment between hyphens — closer to a UUID — would be more precise.

```suggestion
const DEVICE_ID_PATTERN = /^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$/i;
```

Reviews (1): Last reviewed commit: "feat(desktop, raycast): deep-link contro..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

…aycast extension (CapSoftware#1540)

Extends the existing `cap-desktop://action` deep-link surface in
`deeplink_actions.rs` with the four operations the bounty body calls
for that weren't already wired through it:

- `pause_recording`   →  forwards to `recording::pause_recording`
- `resume_recording`  →  forwards to `recording::resume_recording`
- `switch_camera`     →  forwards to `set_camera_input(...)` with
                         `Option<DeviceOrModelID>` exactly as
                         `start_recording` already accepts it
- `switch_microphone` →  forwards to `set_mic_input(...)` with
                         `Option<String>`

`start_recording`, `stop_recording`, `open_editor`, and
`open_settings` are unchanged.

Unit tests cover every new variant plus the existing error paths
(`Invalid`, `NotAction`, `ParseFailed`) using `Url::parse_with_params`
so the test cases exercise the same query-pair decode path that the
real `tauri-plugin-deep-link` callback uses.

The wire format is documented in a new
`apps/desktop/src-tauri/DEEPLINKS.md` so future deep-link work
(including the OS-level shortcut wiring for Windows) doesn't have to
re-derive it from source.

A minimal Raycast extension lives at `apps/raycast/` and is wired into
the existing pnpm workspace (`apps/*` glob in
`pnpm-workspace.yaml`). Five `no-view` commands:

- Stop Cap Recording
- Pause Cap Recording
- Resume Cap Recording
- Switch Cap Microphone (text argument: label, blank to clear)
- Switch Cap Camera (text argument: ModelID or DeviceID, hex/dash
  pattern routes to `DeviceID`, anything else to `ModelID`, blank to
  clear)

Each command builds a `cap-desktop://action?value=<json>` URL via a
shared `fireAction` helper that uses `@raycast/api`'s `open()` and
shows a HUD on success or a failure toast if the OS handler rejects
the open (Cap not installed / not running). Start-recording is
intentionally not wired into Raycast in this PR — picking a capture
target needs a multi-step picker that we can layer on once the
deep-link side has merged.

Local gates run: `cargo fmt -p cap-desktop`, `biome check
apps/raycast`. Full `cargo clippy -p cap-desktop --all-targets -- -D
warnings` could not run locally because `ring` fails to build on this
Windows ARM64 box (C-toolchain permissions issue unrelated to this
change); CI will exercise it.

Closes CapSoftware#1540
@superagent-security superagent-security Bot added contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis. labels May 16, 2026
Comment thread apps/raycast/src/deeplink.ts Outdated
Comment on lines +15 to +16
const value =
typeof payload === "string" ? `"${payload}"` : JSON.stringify(payload);
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 buildActionUrl manually wraps string payloads in quotes with a template literal instead of delegating to JSON.stringify. For the three current string literals this is safe, but if a string variant were ever added that contains a backslash or a double-quote, the output would be invalid JSON. Using JSON.stringify uniformly avoids the inconsistency.

Suggested change
const value =
typeof payload === "string" ? `"${payload}"` : JSON.stringify(payload);
const value = JSON.stringify(payload);
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast/src/deeplink.ts
Line: 15-16

Comment:
`buildActionUrl` manually wraps string payloads in quotes with a template literal instead of delegating to `JSON.stringify`. For the three current string literals this is safe, but if a string variant were ever added that contains a backslash or a double-quote, the output would be invalid JSON. Using `JSON.stringify` uniformly avoids the inconsistency.

```suggestion
	const value = JSON.stringify(payload);
```

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

Comment thread apps/raycast/src/switch-camera.ts Outdated
identifier?: string;
}

const DEVICE_ID_PATTERN = /^[0-9a-fA-F-]{8,}$/;
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 The DEVICE_ID_PATTERN regex /^[0-9a-fA-F-]{8,}$/ allows strings like -------- (8 hyphens) or aaaaaaaa (8 repeated chars) to be classified as DeviceIDs. A camera ModelID that starts with 8 hex-compatible characters could be misrouted. A pattern that requires at least one hex digit segment between hyphens — closer to a UUID — would be more precise.

Suggested change
const DEVICE_ID_PATTERN = /^[0-9a-fA-F-]{8,}$/;
const DEVICE_ID_PATTERN = /^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$/i;
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast/src/switch-camera.ts
Line: 8

Comment:
The `DEVICE_ID_PATTERN` regex `/^[0-9a-fA-F-]{8,}$/` allows strings like `--------` (8 hyphens) or `aaaaaaaa` (8 repeated chars) to be classified as `DeviceID`s. A camera ModelID that starts with 8 hex-compatible characters could be misrouted. A pattern that requires at least one hex digit segment between hyphens — closer to a UUID — would be more precise.

```suggestion
const DEVICE_ID_PATTERN = /^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$/i;
```

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

deeplink.ts: drop the special-case template-literal wrapper around
string payloads. `JSON.stringify` already produces the correct quoted
JSON for the three string variants and is robust to backslash/quote
characters if a new string variant is ever added.

switch-camera.ts: tighten `DEVICE_ID_PATTERN` from
`/^[0-9a-fA-F-]{8,}$/` (which matched degenerate inputs like
`--------` or `aaaaaaaa`) to the canonical UUID shape
`/^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$/i`. Camera
ModelIDs that happen to start with 8 hex-compatible characters can
no longer be misrouted to DeviceID.

Biome clean on apps/raycast/src/.
@js360000
Copy link
Copy Markdown
Author

Thanks @greptile-apps — addressed both P2 comments in 03cecf0:

  • apps/raycast/src/deeplink.ts: dropped the special-case template-literal wrapper around string payloads. JSON.stringify is now called uniformly; the three current string variants serialize identically, and any future string variant with \ or " characters will round-trip correctly.
  • apps/raycast/src/switch-camera.ts: tightened DEVICE_ID_PATTERN from /^[0-9a-fA-F-]{8,}$/ to the canonical UUID shape /^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$/i. Inputs like -------- or aaaaaaaa no longer route to DeviceID; ModelIDs that happen to start with 8 hex-compatible characters are also safe.

Biome clean on apps/raycast/src/. The Vercel: FAILURE check appears to be a per-fork preview-deploy authorization issue (same on the eight sibling PRs on this bounty) — the deploy URL redirects to a Vercel team authorize page. Should be unblocked by the maintainer's Vercel install, not a code-side issue.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 16, 2026

Both P2 fixes are confirmed clean in 03cecf0. The P1 guard bug is still present on the current PR head — lines 99–101 of deeplink_actions.rs:

match url.domain() {
    Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction),
    _ => Err(ActionParseFromUrlError::Invalid),
}?;

The _ => arm catches Some("action") and returns Err(Invalid), so the function never reaches query_pairs(). The fix is one extra arm:

        match url.domain() {
            Some("action") => Ok(()),
            Some(_) => Err(ActionParseFromUrlError::NotAction),
            _ => Err(ActionParseFromUrlError::Invalid),
        }?;

Once that's in, all nine unit tests should pass and the four new deep-link actions will actually dispatch at runtime. Happy to approve once this is addressed.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

`DeepLinkAction::try_from` was returning `Err` in both arms of the
`url.domain()` match, so the `?` propagator always early-returned and
the function never reached `query_pairs()`. Action URLs of the form
`cap-desktop://action?value=…` matched the wildcard arm
(`_ => Err(Invalid)`) and the whole action-dispatch surface was dead
code at runtime.

The fix matches `Some("action") => Ok(())` first, falls through to
`Some(_) => Err(NotAction)` for unknown domains, and keeps the
`_ => Err(Invalid)` for missing domains. After this, the existing
StartRecording / StopRecording / OpenEditor / OpenSettings flow and
the four new variants in this PR (Pause/Resume/SwitchCamera/
SwitchMicrophone) all dispatch correctly. The nine unit tests in
`#[cfg(test)] mod tests` also start passing once this is in place.

Spotted by greptile-apps during PR CapSoftware#1829 review.
@js360000
Copy link
Copy Markdown
Author

Good catch @greptile-apps — fixed in de007f6 exactly as suggested:

match url.domain() {
    Some("action") => Ok(()),
    Some(_) => Err(ActionParseFromUrlError::NotAction),
    _ => Err(ActionParseFromUrlError::Invalid),
}?;

The guard now has the missing Ok arm so cap-desktop://action?value=… URLs reach query_pairs() instead of bouncing on the wildcard. This unblocks the existing StartRecording / StopRecording / OpenEditor / OpenSettings paths as well as the four new variants this PR adds (Pause/Resume/SwitchCamera/SwitchMicrophone), and lets the nine unit tests in #[cfg(test)] mod tests actually verify the dispatch.

cargo fmt -p cap-desktop clean. Local cargo clippy still blocked by the ring/C-toolchain issue on this Windows ARM64 box (noted in the PR body) — CI will exercise it.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 16, 2026

Fix looks correct — the three-arm match is exactly right:

  • Some("action") => Ok(()) — the missing arm, lets action URLs proceed
  • Some(_) => Err(NotAction) — rejects valid-but-wrong-scheme URLs cleanly
  • _ => Err(Invalid) — covers None (no domain)

This unblocks all nine new tests and the pre-existing dispatch paths simultaneously. Once CI confirms clippy passes, this issue is resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🙋 Bounty claim contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bounty: Deeplinks support + Raycast Extension

2 participants