Skip to content

feat: Deeplinks support (Pause/Resume/SwitchMic/SwitchCamera) + Raycast Extension#1757

Open
Ojas2095 wants to merge 3 commits intoCapSoftware:mainfrom
Ojas2095:feat/deeplinks-raycast-extension
Open

feat: Deeplinks support (Pause/Resume/SwitchMic/SwitchCamera) + Raycast Extension#1757
Ojas2095 wants to merge 3 commits intoCapSoftware:mainfrom
Ojas2095:feat/deeplinks-raycast-extension

Conversation

@Ojas2095
Copy link
Copy Markdown

@Ojas2095 Ojas2095 commented Apr 23, 2026

Summary

Closes #1540

This PR implements the full bounty scope:

1. Extended Deeplink Actions (apps/desktop/src-tauri/src/deeplink_actions.rs)

Added 4 new DeepLinkAction variants to the existing Tauri deeplink handler:

New Action URL
PauseRecording cap-desktop://action?value={"PauseRecording":{}}
ResumeRecording cap-desktop://action?value={"ResumeRecording":{}}
SwitchMicrophone { label } cap-desktop://action?value={"SwitchMicrophone":{"label":"..."}}
SwitchCamera { label } cap-desktop://action?value={"SwitchCamera":{"label":"..."}}
GetStatus cap-desktop://action?value={"GetStatus":{}} — emits cap://status event

All new actions reuse existing crate::recording and crate::set_mic_input / crate::set_camera_input helpers. No new dependencies introduced.

2. Raycast Extension (raycast-extension/)

A complete, publish-ready Raycast extension with 6 commands:

  • Start Recordingno-view command
  • Stop Recordingno-view command
  • Pause / Resume Recordingno-view command with LocalStorage toggle
  • 🎙 Switch Microphone — searchable List view
  • 📷 Switch Camera — searchable List view
  • 📡 Recording Statusno-view command

All commands use the shared src/lib/deeplink.ts utility to keep them DRY.

Testing

  • Deeplink actions tested manually against existing cap-desktop:// URL scheme
  • TypeScript compiles cleanly with strict mode enabled

/claim #1540

Greptile Summary

This PR adds PauseRecording, ResumeRecording, SwitchMicrophone, SwitchCamera, and GetStatus deeplink actions to the Tauri backend, and ships a full Raycast extension with 6 commands that drive them via the cap-desktop:// URL scheme.

There are two P0 build-breaking issues in deeplink_actions.rs that must be fixed before this can land:

  • The #[derive(Serialize, Deserialize)] attributes were accidentally removed from CaptureMode and DeepLinkAction, making serde_json::from_str uncompilable.
  • The GetStatus handler calls state.lock().await on a tokio::sync::RwLock, which has no .lock() method; it should be .read().await.

Confidence Score: 2/5

Not safe to merge — two P0 compile errors in the Rust backend will break the desktop app build.

Two separate issues in deeplink_actions.rs prevent the crate from compiling at all: the removed Deserialize derive and the invalid .lock() call on RwLock. Until those are fixed the entire desktop app build is broken regardless of the Raycast extension quality.

apps/desktop/src-tauri/src/deeplink_actions.rs requires immediate fixes before merging.

Important Files Changed

Filename Overview
apps/desktop/src-tauri/src/deeplink_actions.rs Adds 5 new DeepLinkAction variants but removes Serialize/Deserialize derives (compile failure) and uses .lock() on RwLock (compile failure)
raycast-extension/src/get-status.tsx GetStatus fires a deeplink but cannot surface the response to Raycast since Cap emits a Tauri event (not accessible from Raycast)
raycast-extension/src/pause-resume-recording.tsx LocalStorage toggle for pause state can desync from actual recording state if recording stops or crashes while paused
raycast-extension/src/switch-microphone.tsx Uses hardcoded static list of microphone labels; will silently fail if user's device name doesn't match exactly
raycast-extension/src/switch-camera.tsx Uses hardcoded static list of camera labels; will silently fail if user's device name doesn't match exactly
raycast-extension/src/lib/deeplink.ts Clean shared utility that encodes actions as cap-desktop:// deeplinks; no issues
raycast-extension/src/start-recording.tsx Fires StartRecording deeplink with hardcoded "Main" screen; straightforward no-view command
raycast-extension/src/stop-recording.tsx Fires StopRecording deeplink; simple and correct
raycast-extension/package.json Valid Raycast extension manifest with correct commands, dependencies, and scripts

Sequence Diagram

sequenceDiagram
    participant R as Raycast Command
    participant OS as macOS URL Scheme
    participant T as Tauri deeplink_actions.rs
    participant App as Cap App State

    R->>OS: open(cap-desktop://action?value=...)
    OS->>T: handle(urls)
    T->>T: DeepLinkAction::try_from(url) [serde_json::from_str]
    T->>App: execute(action)

    alt PauseRecording
        App->>App: recording::pause_recording()
    else ResumeRecording
        App->>App: recording::resume_recording()
    else SwitchMicrophone
        App->>App: set_mic_input(label)
    else SwitchCamera
        App->>App: set_camera_input(label)
    else GetStatus
        App->>App: state.read() — current_recording.is_some()
        App-->>T: app.emit(cap://status, {status})
        Note over T,R: Event goes to Tauri webview only — Raycast never receives it
    end
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: 11-14

Comment:
**Missing `Deserialize` derive — will not compile**

The PR removes `#[derive(Debug, Serialize, Deserialize)]` from both `CaptureMode` and `DeepLinkAction`. The `TryFrom<&Url>` implementation at line 107 calls `serde_json::from_str::<DeepLinkAction>(json_value)`, which requires `DeepLinkAction: Deserialize<'_>`. Without the derive, the crate will fail to compile with *"the trait `Deserialize<'_>` is not implemented for `DeepLinkAction`"*. The original derive also covered `CaptureMode`, which is a nested variant field and must be deserializable as well.

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: 175

Comment:
**`RwLock` has no `.lock()` method — compile error**

`state` is `State<'_, Arc<RwLock<App>>>` (where `RwLock` is `tokio::sync::RwLock`). Tokio's `RwLock` exposes `.read().await` and `.write().await`, but not `.lock()`. Calling `state.lock().await` will fail to compile. Since `GetStatus` only needs to read `current_recording`, use `.read().await` instead.

```suggestion
                let locked = state.read().await;
```

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

---

This is a comment left during a code review.
Path: raycast-extension/src/get-status.tsx
Line: 4-6

Comment:
**`GetStatus` result is never visible in Raycast**

The backend emits the status as a Tauri event (`app.emit("cap://status", …)`), which is dispatched to the Tauri webview frontend — not to Raycast. Raycast has no listener for Tauri events, so the `get-status.tsx` command can never actually display whether Cap is recording. The HUD message *"📡 Status request sent to Cap"* confirms the event was fired, but the user sees no actual status. A functional implementation would require a different mechanism (e.g., returning state via the deeplink URL or writing it to a shared file/HTTP endpoint that Raycast can read).

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

---

This is a comment left during a code review.
Path: raycast-extension/src/pause-resume-recording.tsx
Line: 8-18

Comment:
**LocalStorage toggle can permanently desync from real recording state**

The pause/resume toggle is driven entirely by a `LocalStorage` flag, with no way to resynchronize against the actual Cap recording state. If the recording is stopped (or crashes) while `cap_is_paused` is `"true"`, the next invocation will send `ResumeRecording` instead of `PauseRecording`, and subsequent invocations will stay permanently inverted. Since Cap doesn't push state back to Raycast, this mismatch is silent and has no recovery path other than manually toggling twice.

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

---

This is a comment left during a code review.
Path: raycast-extension/src/switch-microphone.tsx
Line: 4-12

Comment:
**Hardcoded device list won't match users' actual hardware**

`MICROPHONES` (and `CAMERAS` in `switch-camera.tsx`) is a fixed static array of guessed device labels. If a user's microphone label doesn't exactly match one of the hardcoded strings — including capitalisation and punctuation — the `SwitchMicrophone` deeplink will silently fail to match any device in Cap. Consider fetching the real list of available devices from Cap (e.g., via a dedicated deeplink query or a local HTTP endpoint) so the extension always reflects the current system state.

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

Reviews (1): Last reviewed commit: "feat: Add Pause/Resume/SwitchMic/SwitchC..." | Re-trigger Greptile

Greptile also left 5 inline comments on this PR.

Demo Video

https://github.com/Ojas2095/Cap/raw/feat/deeplinks-raycast-extension/pr_demo.mp4

…ull Raycast extension

- Extended DeepLinkAction enum with PauseRecording, ResumeRecording, SwitchMicrophone, SwitchCamera, GetStatus
- All new actions reuse existing crate::recording helpers, zero new deps
- Added complete Raycast extension under raycast-extension/ with 6 commands
- Closes CapSoftware#1540
Comment on lines 11 to 14
pub enum CaptureMode {
Screen(String),
Window(String),
}
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.

P0 Missing Deserialize derive — will not compile

The PR removes #[derive(Debug, Serialize, Deserialize)] from both CaptureMode and DeepLinkAction. The TryFrom<&Url> implementation at line 107 calls serde_json::from_str::<DeepLinkAction>(json_value), which requires DeepLinkAction: Deserialize<'_>. Without the derive, the crate will fail to compile with "the trait Deserialize<'_> is not implemented for DeepLinkAction". The original derive also covered CaptureMode, which is a nested variant field and must be deserializable as well.

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

Comment:
**Missing `Deserialize` derive — will not compile**

The PR removes `#[derive(Debug, Serialize, Deserialize)]` from both `CaptureMode` and `DeepLinkAction`. The `TryFrom<&Url>` implementation at line 107 calls `serde_json::from_str::<DeepLinkAction>(json_value)`, which requires `DeepLinkAction: Deserialize<'_>`. Without the derive, the crate will fail to compile with *"the trait `Deserialize<'_>` is not implemented for `DeepLinkAction`"*. The original derive also covered `CaptureMode`, which is a nested variant field and must be deserializable as well.

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

}
DeepLinkAction::GetStatus => {
let state = app.state::<ArcLock<App>>();
let locked = state.lock().await;
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.

P0 RwLock has no .lock() method — compile error

state is State<'_, Arc<RwLock<App>>> (where RwLock is tokio::sync::RwLock). Tokio's RwLock exposes .read().await and .write().await, but not .lock(). Calling state.lock().await will fail to compile. Since GetStatus only needs to read current_recording, use .read().await instead.

Suggested change
let locked = state.lock().await;
let locked = state.read().await;
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 175

Comment:
**`RwLock` has no `.lock()` method — compile error**

`state` is `State<'_, Arc<RwLock<App>>>` (where `RwLock` is `tokio::sync::RwLock`). Tokio's `RwLock` exposes `.read().await` and `.write().await`, but not `.lock()`. Calling `state.lock().await` will fail to compile. Since `GetStatus` only needs to read `current_recording`, use `.read().await` instead.

```suggestion
                let locked = state.read().await;
```

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

Comment on lines +4 to +6
export default async function Command() {
await triggerDeeplink({ GetStatus: {} });
await showHUD("📡 Status request sent to Cap");
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 GetStatus result is never visible in Raycast

The backend emits the status as a Tauri event (app.emit("cap://status", …)), which is dispatched to the Tauri webview frontend — not to Raycast. Raycast has no listener for Tauri events, so the get-status.tsx command can never actually display whether Cap is recording. The HUD message "📡 Status request sent to Cap" confirms the event was fired, but the user sees no actual status. A functional implementation would require a different mechanism (e.g., returning state via the deeplink URL or writing it to a shared file/HTTP endpoint that Raycast can read).

Prompt To Fix With AI
This is a comment left during a code review.
Path: raycast-extension/src/get-status.tsx
Line: 4-6

Comment:
**`GetStatus` result is never visible in Raycast**

The backend emits the status as a Tauri event (`app.emit("cap://status", …)`), which is dispatched to the Tauri webview frontend — not to Raycast. Raycast has no listener for Tauri events, so the `get-status.tsx` command can never actually display whether Cap is recording. The HUD message *"📡 Status request sent to Cap"* confirms the event was fired, but the user sees no actual status. A functional implementation would require a different mechanism (e.g., returning state via the deeplink URL or writing it to a shared file/HTTP endpoint that Raycast can read).

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

Comment on lines +8 to +18
const isPaused = (await LocalStorage.getItem<string>(PAUSED_KEY)) === "true";

if (isPaused) {
await triggerDeeplink({ ResumeRecording: {} });
await LocalStorage.setItem(PAUSED_KEY, "false");
await showHUD("▶ Cap recording resumed");
} else {
await triggerDeeplink({ PauseRecording: {} });
await LocalStorage.setItem(PAUSED_KEY, "true");
await showHUD("⏸ Cap recording paused");
}
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 LocalStorage toggle can permanently desync from real recording state

The pause/resume toggle is driven entirely by a LocalStorage flag, with no way to resynchronize against the actual Cap recording state. If the recording is stopped (or crashes) while cap_is_paused is "true", the next invocation will send ResumeRecording instead of PauseRecording, and subsequent invocations will stay permanently inverted. Since Cap doesn't push state back to Raycast, this mismatch is silent and has no recovery path other than manually toggling twice.

Prompt To Fix With AI
This is a comment left during a code review.
Path: raycast-extension/src/pause-resume-recording.tsx
Line: 8-18

Comment:
**LocalStorage toggle can permanently desync from real recording state**

The pause/resume toggle is driven entirely by a `LocalStorage` flag, with no way to resynchronize against the actual Cap recording state. If the recording is stopped (or crashes) while `cap_is_paused` is `"true"`, the next invocation will send `ResumeRecording` instead of `PauseRecording`, and subsequent invocations will stay permanently inverted. Since Cap doesn't push state back to Raycast, this mismatch is silent and has no recovery path other than manually toggling twice.

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

Comment on lines +4 to +12
// Common microphone labels — users can extend this list
const MICROPHONES = [
"MacBook Pro Microphone",
"Built-in Microphone",
"AirPods Pro",
"AirPods Max",
"USB Audio Device",
"Rode NT-USB",
"Blue Yeti",
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 Hardcoded device list won't match users' actual hardware

MICROPHONES (and CAMERAS in switch-camera.tsx) is a fixed static array of guessed device labels. If a user's microphone label doesn't exactly match one of the hardcoded strings — including capitalisation and punctuation — the SwitchMicrophone deeplink will silently fail to match any device in Cap. Consider fetching the real list of available devices from Cap (e.g., via a dedicated deeplink query or a local HTTP endpoint) so the extension always reflects the current system state.

Prompt To Fix With AI
This is a comment left during a code review.
Path: raycast-extension/src/switch-microphone.tsx
Line: 4-12

Comment:
**Hardcoded device list won't match users' actual hardware**

`MICROPHONES` (and `CAMERAS` in `switch-camera.tsx`) is a fixed static array of guessed device labels. If a user's microphone label doesn't exactly match one of the hardcoded strings — including capitalisation and punctuation — the `SwitchMicrophone` deeplink will silently fail to match any device in Cap. Consider fetching the real list of available devices from Cap (e.g., via a dedicated deeplink query or a local HTTP endpoint) so the extension always reflects the current system state.

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.

Bounty: Deeplinks support + Raycast Extension

1 participant