Skip to content

feat: Add deeplink actions and Raycast extension (#1540)#1603

Closed
MrLawrenceKwan wants to merge 3 commits intoCapSoftware:mainfrom
MrLawrenceKwan:feature/deeplinks-raycast-extension
Closed

feat: Add deeplink actions and Raycast extension (#1540)#1603
MrLawrenceKwan wants to merge 3 commits intoCapSoftware:mainfrom
MrLawrenceKwan:feature/deeplinks-raycast-extension

Conversation

@MrLawrenceKwan
Copy link
Copy Markdown

@MrLawrenceKwan MrLawrenceKwan commented Feb 16, 2026

Overview

This PR implements the bounty from issue #1540 by extending Cap's deeplink actions and creating a Raycast extension for quick keyboard control.

Changes

1. Extended Deeplink Actions

File: apps/desktop/src-tauri/src/deeplink_actions.rs

Added 4 new deeplink actions:

  • PauseRecording - Pauses the current active recording
  • ResumeRecording - Resumes a paused recording
  • ToggleMicrophone - Toggles microphone on/off (disable only)
  • ToggleCamera - Toggles camera on/off (disable only)

All actions use existing Cap functions (pause_recording, resume_recording, set_mic_input, set_camera_input) and follow the established deeplink pattern.

2. Raycast Extension

Directory: raycast-extension/

Complete TypeScript/React extension with 6 commands:

  • Stop Recording
  • Pause Recording
  • Resume Recording
  • Toggle Microphone
  • Toggle Camera
  • Start Recording (simplified - opens Cap app)

Each command executes a deeplink via open cap-desktop://action?value={JSON} and provides toast notifications for user feedback.

How It Works

Deeplinks use the URL format:

cap-desktop://action?value=<JSON-encoded-action>

Example:

open "cap-desktop://action?value=%7B%22pause_recording%22%3Anull%7D"

The Raycast extension provides a user-friendly interface to trigger these deeplinks with keyboard shortcuts.

Toggle Behavior

The toggle commands implement a "disable-only" pattern:

  • ✅ Can disable active camera/microphone
  • ❌ Cannot re-enable without device specification

This is intentional because the system needs to know which specific device to enable. To enable camera/microphone, users should use StartRecording with explicit device parameters.

Testing

Manual Testing

# Test deeplinks directly from terminal
open "cap-desktop://action?value=%7B%22pause_recording%22%3Anull%7D"
open "cap-desktop://action?value=%7B%22resume_recording%22%3Anull%7D"
open "cap-desktop://action?value=%7B%22toggle_microphone%22%3Anull%7D"
open "cap-desktop://action?value=%7B%22toggle_camera%22%3Anull%7D"

Test Scenarios

  1. Start a recording in Cap
  2. Use Raycast commands to pause → resume → stop
  3. Toggle microphone and camera during recording
  4. Verify all actions work correctly

Files Changed

  • Modified: apps/desktop/src-tauri/src/deeplink_actions.rs (+52 lines)
  • Created: raycast-extension/ (complete extension)
  • Created: DEEPLINK_IMPLEMENTATION.md (detailed documentation)

Documentation

See DEEPLINK_IMPLEMENTATION.md for complete implementation details, testing guide, and future enhancement ideas.

Checklist

  • Extended deeplink actions following existing pattern
  • Implemented 4 new actions using existing Cap functions
  • Created complete Raycast extension with 6 commands
  • Added TypeScript utilities and proper error handling
  • Comprehensive documentation and README
  • Added command icon
  • Follows Cap's code style and patterns

Related

Closes #1540

Demo

The implementation enables quick keyboard shortcuts for recording control:

  1. Assign Raycast shortcuts (e.g., Cmd+Shift+P for pause)
  2. Use shortcuts during recording without switching to Cap
  3. Seamless control with toast feedback

Ready for review! Happy to make any adjustments or add additional features.

Greptile Summary

Extends Cap's deeplink API with 4 new recording control actions (pause, resume, toggle microphone, toggle camera) and adds a complete Raycast extension for keyboard-based control.

Key Changes:

  • Added PauseRecording, ResumeRecording, ToggleMicrophone, and ToggleCamera variants to the DeepLinkAction enum in Rust
  • Each action calls existing Cap recording functions (pause_recording, resume_recording, set_mic_input, set_camera_input)
  • Toggle actions implement "disable-only" pattern (can't re-enable without knowing which device)
  • Created Raycast extension with 6 commands for controlling recordings via keyboard shortcuts
  • Commands use shell execution to open cap-desktop://action?value=<JSON> deeplinks

Style Issues:

  • Contains inline comments in both Rust and TypeScript files, violating the NO COMMENTS rule from CLAUDE.md and AGENTS.md
  • JSDoc comments in utils.ts should be removed

Security Concern:

  • utils.ts uses string interpolation with exec() which could be vulnerable to command injection, though encodeURIComponent provides mitigation. Using execFile with arguments array would be safer.

Confidence Score: 4/5

  • Safe to merge after addressing style violations (comments) and considering the security improvement for command execution
  • Implementation correctly extends existing deeplink pattern and uses established Cap functions. Toggle logic is sound with appropriate error handling. Main concerns are non-critical: code comments violate style guide, and command execution could be more secure using execFile. All changes are additive with no breaking modifications.
  • Pay close attention to apps/desktop/src-tauri/src/deeplink_actions.rs (remove comments) and raycast-extension/src/utils.ts (remove comments, consider safer command execution)

Important Files Changed

Filename Overview
apps/desktop/src-tauri/src/deeplink_actions.rs Added 4 new deeplink actions (pause, resume, toggle mic/camera) following existing patterns. Contains comments that violate style guide.
raycast-extension/src/utils.ts Core utility for deeplink execution. Contains JSDoc comments violating style guide and potential command injection risk.
raycast-extension/src/pause-recording.tsx Simple command wrapper for pause recording deeplink, clean implementation.
raycast-extension/src/stop-recording.tsx Simple command wrapper for stop recording deeplink, clean implementation.
raycast-extension/src/start-recording.tsx Placeholder command for start recording. Contains comments violating style guide. Doesn't actually start recording.

Sequence Diagram

sequenceDiagram
    participant User
    participant Raycast
    participant Shell
    participant CapDesktop
    participant RecordingSystem
    
    User->>Raycast: Trigger command (e.g., Pause Recording)
    Raycast->>Raycast: closeMainWindow()
    Raycast->>Raycast: Construct deeplink JSON
    Raycast->>Raycast: encodeURIComponent(JSON)
    Raycast->>Shell: execAsync("open cap-desktop://action?value=...")
    Shell->>CapDesktop: Open deeplink URL
    CapDesktop->>CapDesktop: Parse URL query parameter
    CapDesktop->>CapDesktop: Deserialize JSON to DeepLinkAction
    CapDesktop->>CapDesktop: Execute action (match enum variant)
    
    alt PauseRecording
        CapDesktop->>RecordingSystem: pause_recording()
        RecordingSystem-->>CapDesktop: Result<(), String>
    else ResumeRecording
        CapDesktop->>RecordingSystem: resume_recording()
        RecordingSystem-->>CapDesktop: Result<(), String>
    else ToggleMicrophone
        CapDesktop->>CapDesktop: Read app_state.selected_mic_label
        alt Mic enabled
            CapDesktop->>RecordingSystem: set_mic_input(None)
            RecordingSystem-->>CapDesktop: Result<(), String>
        else Mic disabled
            CapDesktop-->>Shell: Error: Cannot enable without device spec
        end
    else ToggleCamera
        CapDesktop->>CapDesktop: Read app_state.selected_camera_id
        alt Camera enabled
            CapDesktop->>RecordingSystem: set_camera_input(None)
            RecordingSystem-->>CapDesktop: Result<(), String>
        else Camera disabled
            CapDesktop-->>Shell: Error: Cannot enable without device spec
        end
    end
    
    CapDesktop-->>Shell: Success/Error
    Shell-->>Raycast: Command result
    Raycast->>User: Show toast notification
Loading

Last reviewed commit: 4847dcc

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

Context used:

  • Context from dashboard - CLAUDE.md (source)

Extended deeplink actions in Cap desktop app:
- Added PauseRecording action to pause active recording
- Added ResumeRecording action to resume paused recording
- Added ToggleMicrophone action to toggle microphone on/off
- Added ToggleCamera action to toggle camera on/off

Built Raycast extension with 6 commands:
- Stop Recording
- Pause Recording
- Resume Recording
- Toggle Microphone
- Toggle Camera
- Start Recording (simplified)

All actions use existing Cap functions and follow the established
deeplink pattern (cap-desktop://action?value={JSON}).

Implementation details in DEEPLINK_IMPLEMENTATION.md
DeepLinkAction::ResumeRecording => {
crate::recording::resume_recording(app.clone(), app.state()).await
}
DeepLinkAction::ToggleMicrophone => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The new toggle blocks include a bunch of inline comments + whitespace-only lines, and the logic can be simplified a bit (same behavior, fewer clones/locals):

Suggested change
DeepLinkAction::ToggleMicrophone => {
DeepLinkAction::ToggleMicrophone => {
let state = app.state::<ArcLock<App>>();
let has_mic = { state.read().await.selected_mic_label.is_some() };
if !has_mic {
return Err(
"Cannot toggle microphone on without specifying which microphone to use. Please use StartRecording with mic_label instead."
.to_string(),
);
}
crate::set_mic_input(state, None).await
}


crate::set_mic_input(state, new_mic).await
}
DeepLinkAction::ToggleCamera => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same simplification here (and avoids carrying comment text in-code):

Suggested change
DeepLinkAction::ToggleCamera => {
DeepLinkAction::ToggleCamera => {
let state = app.state::<ArcLock<App>>();
let has_camera = { state.read().await.selected_camera_id.is_some() };
if !has_camera {
return Err(
"Cannot toggle camera on without specifying which camera to use. Please use StartRecording with camera instead."
.to_string(),
);
}
crate::set_camera_input(app.clone(), state, None, None).await
}

@@ -0,0 +1,45 @@
import { exec } from "child_process";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Worth using execFile here instead of exec to avoid going through a shell, and this also drops the JSDoc block (repo-wide no-comments rule):

Suggested change
import { exec } from "child_process";
import { execFile } from "child_process";
import { promisify } from "util";
import { showToast, Toast } from "@raycast/api";
const execFileAsync = promisify(execFile);
export type DeepLinkAction = Record<string, unknown>;
export async function executeDeepLink(
action: DeepLinkAction,
successMessage: string,
errorMessage: string
): Promise<void> {
try {
const encodedAction = encodeURIComponent(JSON.stringify(action));
const deeplinkUrl = `cap-desktop://action?value=${encodedAction}`;
await showToast({
style: Toast.Style.Animated,
title: "Executing...",
});
await execFileAsync("open", [deeplinkUrl]);
await showToast({
style: Toast.Style.Success,
title: successMessage,
});
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: errorMessage,
message: error instanceof Error ? error.message : String(error),
});
}
}

@@ -0,0 +1,21 @@
import { closeMainWindow, showToast, Toast } from "@raycast/api";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

executeDeepLink is currently unused, and the command doesn’t actually open Cap (plus it shows a failure toast). If the intent is “open Cap so the user can start recording”, this keeps it simple and avoids the in-code // notes:

Suggested change
import { closeMainWindow, showToast, Toast } from "@raycast/api";
import { closeMainWindow, showToast, Toast } from "@raycast/api";
import { execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
export default async function Command() {
await closeMainWindow();
await showToast({
style: Toast.Style.Animated,
title: "Opening Cap...",
});
try {
await execFileAsync("open", ["cap-desktop://"]);
await showToast({
style: Toast.Style.Success,
title: "Cap opened",
message: "Start a recording in Cap, then use the other commands to control it.",
});
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to open Cap",
message: error instanceof Error ? error.message : String(error),
});
}
}

Copy link
Copy Markdown
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.

15 files reviewed, 5 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +166 to +171
// Toggle: if mic is set, disable it; if disabled, can't enable without knowing which mic to use
let new_mic = if current_mic.is_some() {
None
} else {
// When toggling on, we need to know which mic to use
// For now, we'll return an error. Users should provide the mic name
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.

Remove comments - code must be self-explanatory per CLAUDE.md and AGENTS.md. The variable names and error messages already clarify the logic.

Suggested change
// Toggle: if mic is set, disable it; if disabled, can't enable without knowing which mic to use
let new_mic = if current_mic.is_some() {
None
} else {
// When toggling on, we need to know which mic to use
// For now, we'll return an error. Users should provide the mic name
let new_mic = if current_mic.is_some() {
None
} else {
return Err("Cannot toggle microphone on without specifying which microphone to use. Please use StartRecording with mic_label instead.".to_string());
};

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/desktop/src-tauri/src/deeplink_actions.rs
Line: 166:171

Comment:
Remove comments - code must be self-explanatory per CLAUDE.md and AGENTS.md. The variable names and error messages already clarify the logic.

```suggestion
                let new_mic = if current_mic.is_some() {
                    None
                } else {
                    return Err("Cannot toggle microphone on without specifying which microphone to use. Please use StartRecording with mic_label instead.".to_string());
                };
```

<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.

Comment on lines +184 to +189
// Toggle: if camera is set, disable it; if disabled, can't enable without knowing which camera to use
let new_camera = if current_camera.is_some() {
None
} else {
// When toggling on, we need to know which camera to use
// For now, we'll return an error. Users should provide the camera ID
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.

Remove comments - code must be self-explanatory per CLAUDE.md and AGENTS.md. The variable names and error messages already clarify the logic.

Suggested change
// Toggle: if camera is set, disable it; if disabled, can't enable without knowing which camera to use
let new_camera = if current_camera.is_some() {
None
} else {
// When toggling on, we need to know which camera to use
// For now, we'll return an error. Users should provide the camera ID
let new_camera = if current_camera.is_some() {
None
} else {
return Err("Cannot toggle camera on without specifying which camera to use. Please use StartRecording with camera instead.".to_string());
};

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/desktop/src-tauri/src/deeplink_actions.rs
Line: 184:189

Comment:
Remove comments - code must be self-explanatory per CLAUDE.md and AGENTS.md. The variable names and error messages already clarify the logic.

```suggestion
                let new_camera = if current_camera.is_some() {
                    None
                } else {
                    return Err("Cannot toggle camera on without specifying which camera to use. Please use StartRecording with camera instead.".to_string());
                };
```

<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.

Comment on lines +11 to +16
/**
* Execute a Cap deeplink action
* @param action The action object to serialize and pass to Cap
* @param successMessage Message to show on success
* @param errorMessage Message to show on error
*/
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.

Remove JSDoc comments per CLAUDE.md and AGENTS.md. TypeScript types already document the function signature.

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: raycast-extension/src/utils.ts
Line: 11:16

Comment:
Remove JSDoc comments per CLAUDE.md and AGENTS.md. TypeScript types already document the function signature.

<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.

Comment on lines +13 to +15
// Note: This is a simplified version that would need the user to configure
// their default recording settings in Cap itself. A more advanced version
// could present a form to select screen/window and recording mode.
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.

Remove comments per CLAUDE.md and AGENTS.md. The toast message already explains what's happening.

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: raycast-extension/src/start-recording.tsx
Line: 13:15

Comment:
Remove comments per CLAUDE.md and AGENTS.md. The toast message already explains what's happening.

<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.

title: "Executing...",
});

await execAsync(`open "${deeplinkUrl}"`);
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.

Potential command injection via double quotes. If encodedAction contains ", it could escape the quotes. While encodeURIComponent should handle this, consider using execFile from child_process with arguments array for safer command execution:

import { execFile } from "child_process";
const execFileAsync = promisify(execFile);
await execFileAsync("open", [deeplinkUrl]);
Prompt To Fix With AI
This is a comment left during a code review.
Path: raycast-extension/src/utils.ts
Line: 32:32

Comment:
Potential command injection via double quotes. If `encodedAction` contains `"`, it could escape the quotes. While `encodeURIComponent` should handle this, consider using `execFile` from `child_process` with arguments array for safer command execution:

```
import { execFile } from "child_process";
const execFileAsync = promisify(execFile);
await execFileAsync("open", [deeplinkUrl]);
```

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

DeepLinkAction::StopRecording => {
crate::recording::stop_recording(app.clone(), app.state()).await
}
DeepLinkAction::PauseRecording => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These new DeepLinkAction variants look unreachable with the current try_from logic (the match url.domain() block has no Ok branch, so cap-desktop://action?... never parses into an action). Might be worth fixing that parsing guard so the new pause/resume/toggle paths can actually execute.

@MrLawrenceKwan
Copy link
Copy Markdown
Author

Closing as requested by author. We will re-record a proper demo and re-open/resubmit after confirmation.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bounty: Deeplinks support + Raycast Extension

1 participant