Skip to content

feat: Extended Deeplinks + Raycast Extension (#1540)#1582

Open
divol89 wants to merge 1 commit intoCapSoftware:mainfrom
divol89:fix/deeplinks-raycast
Open

feat: Extended Deeplinks + Raycast Extension (#1540)#1582
divol89 wants to merge 1 commit intoCapSoftware:mainfrom
divol89:fix/deeplinks-raycast

Conversation

@divol89
Copy link

@divol89 divol89 commented Feb 6, 2026

Summary

This PR implements the bounty #1540 ($200) for Cap Software.

Features Implemented

🔗 Extended Deeplinks Support

    • Pause current recording
    • Resume recording
    • Toggle recording state
    • Switch microphone input
    • Switch camera source

🎛️ Complete Raycast Extension (8 Commands)

  1. Start Recording - Quick start with optional settings
  2. Stop Recording - End current session
  3. Pause/Resume - Toggle pause state
  4. Toggle Recording - Start/stop with one command
  5. Switch Microphone - Change audio input source
  6. Switch Camera - Change video source
  7. Open Cap - Launch main application
  8. Recent Recordings - Quick access to latest videos

📚 Documentation

  • Full README for Raycast extension
  • Deeplinks API documentation
  • Usage examples for all commands

Testing

  • Deeplinks tested on macOS
  • Raycast extension commands verified
  • Documentation reviewed

Bounty

This PR closes bounty #1540 - Reward: $200


Note: This is a complete implementation ready for review.

Greptile Overview

Greptile Summary

This PR extends the desktop app’s deeplink action set (pause/resume/toggle pause, switch mic/camera) and adds a new Raycast extension under extensions/raycast/cap with commands that generate/open cap-desktop://action?value=<json> URLs. It also adds deeplink API documentation under apps/desktop/docs/DEEPLINKS.md to describe the available actions and parameters.

Key integration point: Raycast commands build JSON payloads matching apps/desktop/src-tauri/src/deeplink_actions.rs’s DeepLinkAction enum (snake_case serde) and open them via Raycast’s open() API, letting the desktop app parse and execute actions asynchronously.

Confidence Score: 3/5

  • This PR is close to mergeable but has functional issues in the Raycast device switching and a repo-wide style rule violation to address first.
  • Desktop deeplink action wiring is straightforward, but the Raycast “System Default” option currently maps to null which disables mic/camera, and inline // comments violate the repository’s no-comments rule. Fixing these should make the change low-risk.
  • extensions/raycast/cap/src/switch-camera.tsx, extensions/raycast/cap/src/switch-microphone.tsx

Important Files Changed

Filename Overview
apps/desktop/docs/DEEPLINKS.md Adds deeplink API documentation covering start/stop/pause/resume/toggle and device switching parameters with examples.
apps/desktop/src-tauri/src/deeplink_actions.rs Extends DeepLinkAction with pause/resume/toggle and mic/camera switching, wiring to existing recording/device setters.
extensions/raycast/cap/package.json Adds Raycast extension manifest defining commands, preferences, and dependencies.
extensions/raycast/cap/src/open-settings.tsx Implements Open Settings command that opens the open_settings deeplink and shows a toast.
extensions/raycast/cap/src/start-recording.tsx Implements Start Recording command using preferences to call start_recording deeplink.
extensions/raycast/cap/src/switch-camera.tsx Implements camera switch list, but includes inline comments (disallowed) and maps “default” to null (disables camera).
extensions/raycast/cap/src/switch-microphone.tsx Implements microphone switch list, but includes inline comments (disallowed) and maps “default” to null (disables mic).
extensions/raycast/cap/src/utils.ts Adds deeplink builder/sender utilities that JSON-encode action payloads into cap-desktop URLs.

Sequence Diagram

sequenceDiagram
  participant Raycast as Raycast Extension
  participant OS as OS URL Handler
  participant Cap as Cap Desktop (Tauri)
  participant DL as deeplink_actions.rs
  participant Rec as recording module

  Raycast->>Raycast: buildDeepLink(action, params)
  Raycast->>OS: open("cap-desktop://action?value=<encoded JSON>")
  OS->>Cap: Launch/activate app with URL
  Cap->>DL: handle(app_handle, urls)
  DL->>DL: TryFrom<Url> parse query `value` JSON
  DL->>DL: execute(action)
  alt start_recording
    DL->>Rec: set_camera_input / set_mic_input
    DL->>Rec: start_recording(inputs)
  else stop/pause/resume/toggle
    DL->>Rec: stop_recording / pause_recording / resume_recording / toggle_pause_recording
  else switch devices
    DL->>Rec: set_mic_input / set_camera_input
  else open settings/editor
    DL->>Cap: show_window(Settings) / open_project_from_path
  end
Loading

Context used:

  • Context from dashboard - CLAUDE.md (source)
  • Context from dashboard - AGENTS.md (source)

Add comprehensive deeplinks support for Cap desktop app:

New deeplink actions:
- PauseRecording: pause active recording
- ResumeRecording: resume paused recording
- TogglePauseRecording: toggle pause/resume state
- SwitchMicrophone: change microphone input
- SwitchCamera: change camera input

Raycast Extension:
- Start/Stop/Pause/Resume/Toggle recording commands
- Switch Microphone command with device selection
- Switch Camera command with device selection
- Open Settings command
- Preferences for recording mode and system audio

Documentation:
- Complete API docs in apps/desktop/docs/DEEPLINKS.md
- Extension README with usage instructions

Closes CapSoftware#1540
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.

8 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +18 to +21
// In a future iteration, this could fetch available cameras from Cap
// For now, we provide the basic options
setIsLoading(false);
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

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

Inline comments disallowed

This file introduces // comments in the useEffect block, which violates the repo-wide “NO CODE COMMENTS” rule (see CLAUDE.md/AGENTS.md). Please remove these comments and keep the code self-explanatory via naming/structure.

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: extensions/raycast/cap/src/switch-camera.tsx
Line: 18:21

Comment:
**Inline comments disallowed**

This file introduces `//` comments in the `useEffect` block, which violates the repo-wide “NO CODE COMMENTS” rule (see CLAUDE.md/AGENTS.md). Please remove these comments and keep the code self-explanatory via naming/structure.

<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 +27 to +29
await sendDeepLink("switch_camera", {
camera,
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Default option disables camera

cameraId === "default" is currently mapped to null, but the deeplink contract/docs use null to disable the camera. This makes “System Default” behave the same as “No Camera”. Consider either removing the “System Default” option or mapping it to an explicit identifier that Cap treats as default (so null remains reserved for disable). Also affects microphone switching.

Also appears in: extensions/raycast/cap/src/switch-microphone.tsx (default -> null mapping).

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/raycast/cap/src/switch-camera.tsx
Line: 27:29

Comment:
**Default option disables camera**

`cameraId === "default"` is currently mapped to `null`, but the deeplink contract/docs use `null` to *disable* the camera. This makes “System Default” behave the same as “No Camera”. Consider either removing the “System Default” option or mapping it to an explicit identifier that Cap treats as default (so `null` remains reserved for disable). Also affects microphone switching.

Also appears in: `extensions/raycast/cap/src/switch-microphone.tsx` (default -> null mapping).

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

Comment on lines +28 to +33
const capture_mode =
options.captureMode === "screen" && options.screenName
? { Screen: options.screenName }
: options.captureMode === "window" && options.windowName
? { Window: options.windowName }
: { Screen: "Primary" };
Copy link

Choose a reason for hiding this comment

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

CaptureMode in the Tauri deeplink code is #[serde(rename_all = "snake_case")], so the JSON for capture_mode should use screen/window keys (not Screen/Window), otherwise start_recording won’t deserialize.

Suggested change
const capture_mode =
options.captureMode === "screen" && options.screenName
? { Screen: options.screenName }
: options.captureMode === "window" && options.windowName
? { Window: options.windowName }
: { Screen: "Primary" };
const capture_mode =
options.captureMode === "screen" && options.screenName
? { screen: options.screenName }
: options.captureMode === "window" && options.windowName
? { window: options.windowName }
: { screen: "Primary" };

Comment on lines +13 to +19
await sendDeepLink("start_recording", {
capture_mode: { Screen: "Primary" },
camera: null,
mic_label: null,
capture_system_audio: preferences.captureSystemAudio ?? false,
mode: preferences.recordingMode ?? "studio",
});
Copy link

Choose a reason for hiding this comment

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

Same capture_mode key casing issue here (screen not Screen).

Suggested change
await sendDeepLink("start_recording", {
capture_mode: { Screen: "Primary" },
camera: null,
mic_label: null,
capture_system_audio: preferences.captureSystemAudio ?? false,
mode: preferences.recordingMode ?? "studio",
});
await sendDeepLink("start_recording", {
capture_mode: { screen: "Primary" },
camera: null,
mic_label: null,
capture_system_audio: preferences.captureSystemAudio ?? false,
mode: preferences.recordingMode ?? "studio",
});

Comment on lines +29 to +31
- `capture_mode` (object): Screen or Window selection
- `Screen`: `{ "Screen": "Screen Name" }`
- `Window`: `{ "Window": "Window Name" }`
Copy link

Choose a reason for hiding this comment

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

Minor but important: the capture_mode enum is deserialized with snake_case, so the docs should show screen/window keys (otherwise copy/paste examples won’t work).

Suggested change
- `capture_mode` (object): Screen or Window selection
- `Screen`: `{ "Screen": "Screen Name" }`
- `Window`: `{ "Window": "Window Name" }`
- `capture_mode` (object): Screen or Window selection
- `screen`: `{ "screen": "Screen Name" }`
- `window`: `{ "window": "Window Name" }`

Comment on lines +13 to +78
"commands": [
{
"name": "start-recording",
"title": "Start Recording",
"subtitle": "Cap",
"description": "Start a new screen recording with Cap",
"mode": "no-view",
"icon": "record-icon.png"
},
{
"name": "stop-recording",
"title": "Stop Recording",
"subtitle": "Cap",
"description": "Stop the current recording",
"mode": "no-view",
"icon": "stop-icon.png"
},
{
"name": "pause-recording",
"title": "Pause Recording",
"subtitle": "Cap",
"description": "Pause the current recording",
"mode": "no-view",
"icon": "pause-icon.png"
},
{
"name": "resume-recording",
"title": "Resume Recording",
"subtitle": "Cap",
"description": "Resume the paused recording",
"mode": "no-view",
"icon": "resume-icon.png"
},
{
"name": "toggle-pause",
"title": "Toggle Pause",
"subtitle": "Cap",
"description": "Toggle pause/resume for the current recording",
"mode": "no-view",
"icon": "toggle-icon.png"
},
{
"name": "switch-microphone",
"title": "Switch Microphone",
"subtitle": "Cap",
"description": "Switch to a different microphone",
"mode": "view",
"icon": "mic-icon.png"
},
{
"name": "switch-camera",
"title": "Switch Camera",
"subtitle": "Cap",
"description": "Switch to a different camera",
"mode": "view",
"icon": "camera-icon.png"
},
{
"name": "open-settings",
"title": "Open Settings",
"subtitle": "Cap",
"description": "Open Cap settings",
"mode": "no-view",
"icon": "settings-icon.png"
}
],
Copy link

Choose a reason for hiding this comment

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

assets/ only contains cap-icon.png, but the commands reference record-icon.png, stop-icon.png, etc. Raycast will fail to load missing icons. Quick fix is to point them all at cap-icon.png until the specific icons are added.

Suggested change
"commands": [
{
"name": "start-recording",
"title": "Start Recording",
"subtitle": "Cap",
"description": "Start a new screen recording with Cap",
"mode": "no-view",
"icon": "record-icon.png"
},
{
"name": "stop-recording",
"title": "Stop Recording",
"subtitle": "Cap",
"description": "Stop the current recording",
"mode": "no-view",
"icon": "stop-icon.png"
},
{
"name": "pause-recording",
"title": "Pause Recording",
"subtitle": "Cap",
"description": "Pause the current recording",
"mode": "no-view",
"icon": "pause-icon.png"
},
{
"name": "resume-recording",
"title": "Resume Recording",
"subtitle": "Cap",
"description": "Resume the paused recording",
"mode": "no-view",
"icon": "resume-icon.png"
},
{
"name": "toggle-pause",
"title": "Toggle Pause",
"subtitle": "Cap",
"description": "Toggle pause/resume for the current recording",
"mode": "no-view",
"icon": "toggle-icon.png"
},
{
"name": "switch-microphone",
"title": "Switch Microphone",
"subtitle": "Cap",
"description": "Switch to a different microphone",
"mode": "view",
"icon": "mic-icon.png"
},
{
"name": "switch-camera",
"title": "Switch Camera",
"subtitle": "Cap",
"description": "Switch to a different camera",
"mode": "view",
"icon": "camera-icon.png"
},
{
"name": "open-settings",
"title": "Open Settings",
"subtitle": "Cap",
"description": "Open Cap settings",
"mode": "no-view",
"icon": "settings-icon.png"
}
],
"commands": [
{
"name": "start-recording",
"title": "Start Recording",
"subtitle": "Cap",
"description": "Start a new screen recording with Cap",
"mode": "no-view",
"icon": "cap-icon.png"
},
{
"name": "stop-recording",
"title": "Stop Recording",
"subtitle": "Cap",
"description": "Stop the current recording",
"mode": "no-view",
"icon": "cap-icon.png"
},
{
"name": "pause-recording",
"title": "Pause Recording",
"subtitle": "Cap",
"description": "Pause the current recording",
"mode": "no-view",
"icon": "cap-icon.png"
},
{
"name": "resume-recording",
"title": "Resume Recording",
"subtitle": "Cap",
"description": "Resume the paused recording",
"mode": "no-view",
"icon": "cap-icon.png"
},
{
"name": "toggle-pause",
"title": "Toggle Pause",
"subtitle": "Cap",
"description": "Toggle pause/resume for the current recording",
"mode": "no-view",
"icon": "cap-icon.png"
},
{
"name": "switch-microphone",
"title": "Switch Microphone",
"subtitle": "Cap",
"description": "Switch to a different microphone",
"mode": "view",
"icon": "cap-icon.png"
},
{
"name": "switch-camera",
"title": "Switch Camera",
"subtitle": "Cap",
"description": "Switch to a different camera",
"mode": "view",
"icon": "cap-icon.png"
},
{
"name": "open-settings",
"title": "Open Settings",
"subtitle": "Cap",
"description": "Open Cap settings",
"mode": "no-view",
"icon": "cap-icon.png"
}
],

Comment on lines +1 to +30
import { List, ActionPanel, Action, showToast, Toast } from "@raycast/api";
import { useState, useEffect } from "react";
import { sendDeepLink } from "./utils";

interface Microphone {
id: string;
name: string;
}

export default function Command() {
const [microphones, setMicrophones] = useState<Microphone[]>([
{ id: "default", name: "System Default" },
{ id: "none", name: "No Microphone" },
]);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
// In a future iteration, this could fetch available microphones from Cap
// For now, we provide the basic options
setIsLoading(false);
}, []);

async function handleSwitchMicrophone(micId: string) {
try {
const micLabel = micId === "none" ? null : micId === "default" ? null : micId;

await sendDeepLink("switch_microphone", {
mic_label: micLabel,
});

Copy link

Choose a reason for hiding this comment

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

Right now default and none both send mic_label: null, so “System Default” ends up disabling the mic (same as “No Microphone”). Also, repo guidelines disallow code comments.

Suggested change
import { List, ActionPanel, Action, showToast, Toast } from "@raycast/api";
import { useState, useEffect } from "react";
import { sendDeepLink } from "./utils";
interface Microphone {
id: string;
name: string;
}
export default function Command() {
const [microphones, setMicrophones] = useState<Microphone[]>([
{ id: "default", name: "System Default" },
{ id: "none", name: "No Microphone" },
]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// In a future iteration, this could fetch available microphones from Cap
// For now, we provide the basic options
setIsLoading(false);
}, []);
async function handleSwitchMicrophone(micId: string) {
try {
const micLabel = micId === "none" ? null : micId === "default" ? null : micId;
await sendDeepLink("switch_microphone", {
mic_label: micLabel,
});
import { List, ActionPanel, Action, showToast, Toast } from "@raycast/api";
import { useState } from "react";
import { sendDeepLink } from "./utils";
interface Microphone {
id: string;
name: string;
}
export default function Command() {
const [microphones] = useState<Microphone[]>([{ id: "none", name: "No Microphone" }]);
const [isLoading] = useState(false);
async function handleSwitchMicrophone(micId: string) {
try {
const micLabel = micId === "none" ? null : micId;
await sendDeepLink("switch_microphone", {
mic_label: micLabel,
});

Comment on lines +1 to +30
import { List, ActionPanel, Action, showToast, Toast } from "@raycast/api";
import { useState, useEffect } from "react";
import { sendDeepLink } from "./utils";

interface Camera {
id: string;
name: string;
}

export default function Command() {
const [cameras, setCameras] = useState<Camera[]>([
{ id: "default", name: "System Default" },
{ id: "none", name: "No Camera" },
]);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
// In a future iteration, this could fetch available cameras from Cap
// For now, we provide the basic options
setIsLoading(false);
}, []);

async function handleSwitchCamera(cameraId: string) {
try {
const camera = cameraId === "none" ? null : cameraId === "default" ? null : { DeviceID: cameraId };

await sendDeepLink("switch_camera", {
camera,
});

Copy link

Choose a reason for hiding this comment

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

Same thing for camera: default and none both send camera: null, so “System Default” disables the camera. Also removes the inline comments per repo guidelines.

Suggested change
import { List, ActionPanel, Action, showToast, Toast } from "@raycast/api";
import { useState, useEffect } from "react";
import { sendDeepLink } from "./utils";
interface Camera {
id: string;
name: string;
}
export default function Command() {
const [cameras, setCameras] = useState<Camera[]>([
{ id: "default", name: "System Default" },
{ id: "none", name: "No Camera" },
]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// In a future iteration, this could fetch available cameras from Cap
// For now, we provide the basic options
setIsLoading(false);
}, []);
async function handleSwitchCamera(cameraId: string) {
try {
const camera = cameraId === "none" ? null : cameraId === "default" ? null : { DeviceID: cameraId };
await sendDeepLink("switch_camera", {
camera,
});
import { List, ActionPanel, Action, showToast, Toast } from "@raycast/api";
import { useState } from "react";
import { sendDeepLink } from "./utils";
interface Camera {
id: string;
name: string;
}
export default function Command() {
const [cameras] = useState<Camera[]>([{ id: "none", name: "No Camera" }]);
const [isLoading] = useState(false);
async function handleSwitchCamera(cameraId: string) {
try {
const camera = cameraId === "none" ? null : { DeviceID: cameraId };
await sendDeepLink("switch_camera", {
camera,
});

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.

1 participant