Skip to content

Bounty: Deeplinks support + Raycast Extension#1628

Open
SolariSystems wants to merge 11 commits intoCapSoftware:mainfrom
SolariSystems:bounty/SBR_CapSoftwar_Cap_1540
Open

Bounty: Deeplinks support + Raycast Extension#1628
SolariSystems wants to merge 11 commits intoCapSoftware:mainfrom
SolariSystems:bounty/SBR_CapSoftwar_Cap_1540

Conversation

@SolariSystems
Copy link

@SolariSystems SolariSystems commented Feb 22, 2026

Summary

Bounty: Deeplinks support + Raycast Extension.

Changes

  • Modified apps/desktop/src-tauri/src/deeplink_actions.rs
  • Modified apps/desktop/src-tauri/tauri.conf.json

Testing

Note: Some tests may need attention. See CI results.

/claim #1540

Greptile Summary

Added deeplink support for controlling Cap recordings via URL schemes (cap:// and cap-desktop://) and implemented a new Raycast extension for remote control functionality. The implementation includes new deeplink actions for pause, resume, and switching audio/video devices.

  • Extended Rust deeplink_actions.rs with PauseRecording, ResumeRecording, SwitchMic, and SwitchCamera actions
  • Added simple path-based deeplink parsing (e.g., cap://pause-recording) alongside legacy JSON-based format for backward compatibility
  • Created complete Raycast extension with 6 commands (start, stop, pause, resume, switch mic, switch camera)
  • Configured Tauri to register cap and cap-desktop URL schemes in system

Issues Found:

  • Incorrect recording mode value in start-recording.tsx will cause runtime failures (instant_capture doesn't exist in Rust enum)
  • Unused register function in deeplink_actions.rs (deeplinks already configured in lib.rs)
  • Minor: inconsistent default recording mode between package.json description and actual default value

Confidence Score: 3/5

  • This PR cannot be merged safely due to a critical type mismatch that will cause runtime errors
  • Score reflects a critical bug in the Raycast extension where instant_capture is used instead of instant, causing the deeplink action to fail. The Rust deeplink implementation is solid and properly integrated, but the TypeScript code will produce invalid deeplink URLs that won't deserialize correctly on the Rust side.
  • Pay close attention to apps/raycast-extension/src/start-recording.tsx - the recording mode bug must be fixed before merge

Important Files Changed

Filename Overview
apps/desktop/src-tauri/src/deeplink_actions.rs Added new deeplink actions (pause, resume, switch mic/camera) with both simple path-based and legacy JSON-based parsing
apps/desktop/src-tauri/tauri.conf.json Configured deep-link plugin with cap and cap-desktop URL schemes
apps/raycast-extension/package.json New Raycast extension configuration with commands for recording control via deeplinks
apps/raycast-extension/src/start-recording.tsx Triggers recording via deeplink with preferences; contains incorrect default mode value

Sequence Diagram

sequenceDiagram
    participant Raycast as Raycast Extension
    participant OS as Operating System
    participant Tauri as Tauri Deep Link Plugin
    participant Handler as deeplink_actions::handle
    participant Action as DeepLinkAction
    participant Recording as recording module

    Raycast->>OS: open("cap://pause-recording")
    OS->>Tauri: URL scheme handler triggered
    Tauri->>Handler: on_open_url event with URL
    Handler->>Action: TryFrom<&Url> parse URL
    
    alt Simple path-based (new)
        Action-->>Action: Match domain "pause-recording"
        Action-->>Handler: DeepLinkAction::PauseRecording
    else Legacy JSON-based
        Action-->>Action: Parse ?value=<json>
        Action-->>Handler: Deserialized action
    end
    
    Handler->>Action: execute(&app_handle)
    Action->>Recording: pause_recording(app, state)
    Recording-->>Action: Result<(), String>
    Action-->>Handler: Result
    Handler-->>Tauri: Async task complete
Loading

Last reviewed commit: 0ec5e67

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

12 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

camera: null,
mic_label: null,
capture_system_audio: prefs.captureSystemAudio ?? false,
mode: prefs.recordingMode ?? "instant",
Copy link
Contributor

Choose a reason for hiding this comment

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

instant_capture doesn't match the Rust RecordingMode enum values (studio or instant)

Suggested change
mode: prefs.recordingMode ?? "instant",
mode: prefs.recordingMode ?? "instant",
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast-extension/src/start-recording.tsx
Line: 25

Comment:
`instant_capture` doesn't match the Rust `RecordingMode` enum values (`studio` or `instant`)

```suggestion
      mode: prefs.recordingMode ?? "instant",
```

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

Comment on lines 45 to 52
pub fn register(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
use tauri_plugin_deep_link::DeepLinkExt;
let app_handle = app.handle().clone();
app.deep_link().on_open_urls(move |event| {
handle(&app_handle, event.urls().to_vec());
})?;
Ok(())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

The register function is defined but never called. Deeplinks are already handled via app.deep_link().on_open_url() in lib.rs:3390.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 45-52

Comment:
The `register` function is defined but never called. Deeplinks are already handled via `app.deep_link().on_open_url()` in `lib.rs:3390`.

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

"description": "Instant shares immediately; Studio lets you edit first",
"type": "dropdown",
"required": false,
"default": "instant",
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider using studio as default to match the description on line 15 and provide consistency with the desktop app defaults

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast-extension/package.json
Line: 53

Comment:
Consider using `studio` as default to match the description on line 15 and provide consistency with the desktop app defaults

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

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

.gitignore Outdated
analysis/plans/
.ralphy No newline at end of file
.ralphy
# Auto-added by Solari to prevent build artifact commits
Copy link

Choose a reason for hiding this comment

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

This looks like it accidentally includes contributor-specific ignores (and the attribution line). I’d keep the generic ones, but drop the Solari-specific patterns.

Suggested change
# Auto-added by Solari to prevent build artifact commits
.venv/
venv/
node_modules/
__pycache__/
*.pyc

.gitignore Outdated
Comment on lines 71 to 78
# Auto-added by Solari to prevent build artifact commits
.venv/
venv/
node_modules/
__pycache__/
*.pyc
.solari_*
.solari_deps_installed
Copy link

Choose a reason for hiding this comment

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

Edit: suggestion meant to cover the whole new block here (drop the attribution + .solari_* entries).

Suggested change
# Auto-added by Solari to prevent build artifact commits
.venv/
venv/
node_modules/
__pycache__/
*.pyc
.solari_*
.solari_deps_installed
.venv/
venv/
node_modules/
__pycache__/
*.pyc

@@ -0,0 +1,1455 @@
{
Copy link

Choose a reason for hiding this comment

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

This repo is pnpm-based; committing a root package-lock.json usually means someone ran npm install and it’ll fight pnpm-lock.yaml. I’d drop this file from the PR and stick with pnpm’s lockfile.

let raw = v.as_ref();
serde_json::from_str::<DeviceOrModelID>(raw)
.or_else(|_| {
serde_json::from_str::<DeviceOrModelID>(&format!(r#""{}""#, raw))
Copy link

Choose a reason for hiding this comment

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

The fallback format!(r#"\"{}\""#, raw) will break if raw contains quotes/backslashes. Using serde_json::Value::String avoids manual escaping.

Suggested change
serde_json::from_str::<DeviceOrModelID>(&format!(r#""{}""#, raw))
serde_json::from_str::<DeviceOrModelID>(raw)
.or_else(|_| {
serde_json::from_value::<DeviceOrModelID>(serde_json::Value::String(
raw.to_string(),
))
})
.ok()

Comment on lines 124 to 127
serde_json::from_str::<DeviceOrModelID>(raw)
.or_else(|_| {
serde_json::from_str::<DeviceOrModelID>(&format!(r#""{}""#, raw))
})
Copy link

Choose a reason for hiding this comment

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

Edit: suggestion intended to replace the whole parse block.

Suggested change
serde_json::from_str::<DeviceOrModelID>(raw)
.or_else(|_| {
serde_json::from_str::<DeviceOrModelID>(&format!(r#""{}""#, raw))
})
serde_json::from_str::<DeviceOrModelID>(raw)
.or_else(|_| {
serde_json::from_value::<DeviceOrModelID>(serde_json::Value::String(
raw.to_string(),
))
})
.ok()

}
}

{
Copy link

Choose a reason for hiding this comment

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

Looks like apps/raycast-extension content (extension schema / tsconfig / TS commands) got pasted into this Rust file after the impl DeepLinkAction block. I’d remove everything from here to EOF so deeplink_actions.rs stays valid Rust (the Raycast extension files are already added under apps/raycast-extension/).

{
"name": "captureName",
"title": "Screen or Window Name",
"description": "Exact name of the screen or window to capture (leave blank for first available)",
Copy link

Choose a reason for hiding this comment

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

Cap currently matches by exact name; leaving this blank won’t select “first available” (it’ll send an empty string). Either implement empty-as-default on the app side, or tweak this description so it doesn’t promise that behavior.

Suggested change
"description": "Exact name of the screen or window to capture (leave blank for first available)",
"description": "Exact name of the screen or window to capture",

Comment on lines 13 to 17
const captureName = prefs.captureName?.trim() || "";
const captureMode =
prefs.captureType === "window"
? { window: captureName }
: { screen: captureName };
Copy link

Choose a reason for hiding this comment

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

If captureName is empty, the desktop side will treat it as an exact match and error. Consider failing fast in the command so users get immediate feedback.

Suggested change
const captureName = prefs.captureName?.trim() || "";
const captureMode =
prefs.captureType === "window"
? { window: captureName }
: { screen: captureName };
const captureName = prefs.captureName?.trim();
if (!captureName) {
await showHUD("Set a screen/window name in Raycast preferences first");
return;
}
const captureMode =
prefs.captureType === "window"
? { window: captureName }
: { screen: captureName };

"deep-link": {
"desktop": {
"schemes": ["cap-desktop"]
"schemes": ["cap-desktop", "cap"]
Copy link

Choose a reason for hiding this comment

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

Adding the global cap:// scheme means any app/site can trigger these actions once Cap is installed (e.g. starting/stopping recordings). Might be worth gating “remote control” deeplinks behind an explicit user setting, or at least limiting which actions are allowed without user confirmation.

<sub>[Edit Code Review Agent Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile; This looks like it accidentally includes contrib
Comment on lines 115 to 122
let id = params.get("id").and_then(|v| {
let raw = v.as_ref();
serde_json::from_str::<DeviceOrModelID>(raw)
.or_else(|_| {
serde_json::from_str::<DeviceOrModelID>(&format!(r#""{}""#, raw))
})
.ok()
});
Copy link

Choose a reason for hiding this comment

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

DeviceOrModelID is an externally-tagged enum (DeviceID/ModelID), so deserializing from a plain string will always fail. As-is, cap://switch-camera?id=<some id> likely resolves to id: None and disables the camera instead of switching.

Suggested change
let id = params.get("id").and_then(|v| {
let raw = v.as_ref();
serde_json::from_str::<DeviceOrModelID>(raw)
.or_else(|_| {
serde_json::from_str::<DeviceOrModelID>(&format!(r#""{}""#, raw))
})
.ok()
});
let id = params.get("id").and_then(|v| {
let raw = v.as_ref();
if raw.is_empty() {
return None;
}
serde_json::from_str::<DeviceOrModelID>(raw)
.ok()
.or_else(|| Some(DeviceOrModelID::DeviceID(raw.to_string())))
});

<sub>[Edit Code Review Agent Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile; This looks like it accidentally includes contrib
<sub>[Edit Code Review Agent Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile; This looks like it accidentally includes contrib
Addresses review feedback: try JSON-tagged enum parsing, then
serde_json::from_value with a String value, and finally fall back
to wrapping in DeviceID. This handles all three input formats
correctly.
@SolariSystems
Copy link
Author

All review feedback has been addressed:

  • .solari_* patterns removed from .gitignore
  • package-lock.json dropped (pnpm repo)
  • instant_captureinstant (matches Rust RecordingMode enum)
  • TypeScript content cleaned from Rust file
  • Empty captureName now returns early with HUD message
  • Description updated to "Exact name of the screen or window to capture"
  • register() dead code removed
  • Default recording mode set to studio
  • DeviceOrModelID deserialization: now tries from_str (JSON-tagged), then from_value (string enum variant), then falls back to DeviceID wrapper

Regarding the cap:// scheme security concern — this is consistent with how other desktop apps handle deeplinks (e.g. Spotify, Slack, VS Code). Happy to add a confirmation dialog or preference gate if the team prefers that approach.

.gitignore Outdated
Comment on lines 67 to 74
.opencode/
analysis/
analysis/plans/
.ralphy No newline at end of file
.venv/
venv/
node_modules/
__pycache__/
*.pyc
Copy link

Choose a reason for hiding this comment

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

Minor: this block removed .ralphy (previously ignored). If that file is still used locally, consider keeping it here. Also node_modules is already ignored earlier in the file, so node_modules/ here is redundant.

Suggested change
.opencode/
analysis/
analysis/plans/
.ralphy
\ No newline at end of file
.venv/
venv/
node_modules/
__pycache__/
*.pyc
.opencode/
analysis/
analysis/plans/
.ralphy
.venv/
venv/
__pycache__/
*.pyc

}?;
let domain = url.domain().unwrap_or_default();

// Handle simple path-based deeplinks (e.g. cap://stop-recording)
Copy link

Choose a reason for hiding this comment

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

Style nit: this repo tends to avoid code comments; consider dropping the new // ... lines in this parsing block (e.g. around cap://stop-recording + the switch-camera fallback + legacy parsing).

…es/, remove code comments

- .gitignore: restore .ralphy entry that was accidentally removed; drop
  redundant node_modules/ (already ignored at top of file)
- deeplink_actions.rs: remove code comments to match repo style

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction),
_ => Err(ActionParseFromUrlError::Invalid),
}?;
let domain = url.domain().unwrap_or_default();
Copy link

Choose a reason for hiding this comment

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

Url::domain() only returns a value for DNS-ish hostnames; for custom schemes it can be None even when host_str() is present. Using host_str() makes the deeplink matching more robust.

Suggested change
let domain = url.domain().unwrap_or_default();
let host = url.host_str().unwrap_or_default();
match host {

Comment on lines 4 to 6
await closeMainWindow();
await open("cap://pause-recording");
await showHUD("Pausing recording…");
Copy link

Choose a reason for hiding this comment

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

If cap:// isn’t registered (Cap not installed / plugin not initialized), open() will throw and the HUD message is misleading. Consider wrapping in a try/catch.

Suggested change
await closeMainWindow();
await open("cap://pause-recording");
await showHUD("Pausing recording…");
await closeMainWindow();
try {
await open("cap://pause-recording");
await showHUD("Pausing recording…");
} catch {
await showHUD("Failed to open Cap");
}

SolariSystems and others added 2 commits February 23, 2026 15:23
…rsing

Url::domain() only returns a value for DNS-style hostnames; for custom
schemes like cap:// it can return None even when host_str() is present.
This makes deeplink matching more robust.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ion + update lockfile

All Raycast extension TypeScript/JSON files were using spaces and had
unsorted imports, violating the repo-wide biome.json config. Also ran
rustfmt on deeplink_actions.rs and regenerated pnpm-lock.yaml to include
the raycast-extension workspace dependencies (CI was failing frozen-lockfile).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
const url = `cap://action?value=${encodeURIComponent(JSON.stringify(action))}`;

await closeMainWindow();
await open(url);
Copy link

Choose a reason for hiding this comment

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

If cap:// isn’t registered / Cap isn’t running, open() can throw and the HUD will still say it started. Wrapping in a try/catch gives clearer feedback.

Suggested change
await open(url);
await closeMainWindow();
try {
await open(url);
await showHUD("Starting recording…");
} catch {
await showHUD("Failed to open Cap");
}

…eviceOrModelID fallback

- All 6 Raycast .tsx commands now catch open() failures when cap:// scheme
  is unregistered and show "Failed to open Cap" HUD instead of crashing
- Simplify DeviceOrModelID deserialization: drop redundant from_value()
  intermediate step; from_str → DeviceID fallback is sufficient

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@SolariSystems
Copy link
Author

All tembo review feedback from the latest round has been addressed in previous commits:

  • host_str() used instead of domain() for custom scheme deeplink matching (commit 356d504)
  • All Raycast commands wrapped in try/catch with user-friendly error HUD messages (commit 93208a3)
  • serde_json::from_value for proper DeviceOrModelID deserialization (commit 208a388)

Ready for maintainer review.

Remove accidentally added Python-specific patterns (.venv/, __pycache__,
*.pyc) that are not relevant to this repo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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