Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 53 additions & 13 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,47 @@ pub enum CaptureMode {
Window(String),
}

impl CaptureMode {
/// Resolve a CaptureMode into a ScreenCaptureTarget, or default to the primary display.
fn resolve(mode: Option<Self>) -> Result<ScreenCaptureTarget, String> {
match mode {
Some(CaptureMode::Screen(name)) => cap_recording::screen_capture::list_displays()
.into_iter()
.find(|(s, _)| s.name == name)
.map(|(s, _)| ScreenCaptureTarget::Display { id: s.id })
.ok_or(format!("No screen with name \"{}\"", &name)),
Some(CaptureMode::Window(name)) => cap_recording::screen_capture::list_windows()
.into_iter()
.find(|(w, _)| w.name == name)
.map(|(w, _)| ScreenCaptureTarget::Window { id: w.id })
.ok_or(format!("No window with name \"{}\"", &name)),
None => cap_recording::screen_capture::list_displays()
.into_iter()
.next()
.map(|(s, _)| ScreenCaptureTarget::Display { id: s.id })
.ok_or_else(|| "No displays available".to_string()),
}
}
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeepLinkAction {
StartRecording {
capture_mode: CaptureMode,
capture_mode: Option<CaptureMode>,
camera: Option<DeviceOrModelID>,
mic_label: Option<String>,
capture_system_audio: bool,
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
TogglePauseRecording,
RestartRecording,
TakeScreenshot {
capture_mode: Option<CaptureMode>,
},
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -120,18 +150,7 @@ impl DeepLinkAction {
crate::set_camera_input(app.clone(), state.clone(), camera, None).await?;
crate::set_mic_input(state.clone(), mic_label).await?;

let capture_target: ScreenCaptureTarget = match capture_mode {
CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays()
.into_iter()
.find(|(s, _)| s.name == name)
.map(|(s, _)| ScreenCaptureTarget::Display { id: s.id })
.ok_or(format!("No screen with name \"{}\"", &name))?,
CaptureMode::Window(name) => cap_recording::screen_capture::list_windows()
.into_iter()
.find(|(w, _)| w.name == name)
.map(|(w, _)| ScreenCaptureTarget::Window { id: w.id })
.ok_or(format!("No window with name \"{}\"", &name))?,
};
let capture_target = CaptureMode::resolve(capture_mode)?;

let inputs = StartRecordingInputs {
mode,
Expand All @@ -147,6 +166,27 @@ impl DeepLinkAction {
DeepLinkAction::StopRecording => {
crate::recording::stop_recording(app.clone(), app.state()).await
}
DeepLinkAction::PauseRecording => {
crate::recording::pause_recording(app.clone(), app.state()).await
}
DeepLinkAction::ResumeRecording => {
crate::recording::resume_recording(app.clone(), app.state()).await
}
DeepLinkAction::TogglePauseRecording => {
crate::recording::toggle_pause_recording(app.clone(), app.state()).await
}
DeepLinkAction::RestartRecording => {
crate::recording::restart_recording(app.clone(), app.state())
.await
.map(|_| ())
}
DeepLinkAction::TakeScreenshot { capture_mode } => {
let capture_target = CaptureMode::resolve(capture_mode)?;

crate::recording::take_screenshot(app.clone(), capture_target)
.await
.map(|_| ())
}
Comment on lines +183 to +189
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 Duplicated capture target resolution logic

The CaptureModeScreenCaptureTarget mapping (lines 172–183) is an exact copy of the same block already present in the StartRecording arm (lines 130–141). If the display/window lookup logic ever changes (error messages, fallback behaviour, etc.) both copies would need to be updated.

Consider extracting it into a small helper function:

fn resolve_capture_target(capture_mode: CaptureMode) -> Result<ScreenCaptureTarget, String> {
    match capture_mode {
        CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays()
            .into_iter()
            .find(|(s, _)| s.name == name)
            .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id })
            .ok_or(format!("No screen with name \"{}\"", &name)),
        CaptureMode::Window(name) => cap_recording::screen_capture::list_windows()
            .into_iter()
            .find(|(w, _)| w.name == name)
            .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id })
            .ok_or(format!("No window with name \"{}\"", &name)),
    }
}

Then both arms become let capture_target = resolve_capture_target(capture_mode)?;.

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

Comment:
**Duplicated capture target resolution logic**

The `CaptureMode``ScreenCaptureTarget` mapping (lines 172–183) is an exact copy of the same block already present in the `StartRecording` arm (lines 130–141). If the display/window lookup logic ever changes (error messages, fallback behaviour, etc.) both copies would need to be updated.

Consider extracting it into a small helper function:

```rust
fn resolve_capture_target(capture_mode: CaptureMode) -> Result<ScreenCaptureTarget, String> {
    match capture_mode {
        CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays()
            .into_iter()
            .find(|(s, _)| s.name == name)
            .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id })
            .ok_or(format!("No screen with name \"{}\"", &name)),
        CaptureMode::Window(name) => cap_recording::screen_capture::list_windows()
            .into_iter()
            .find(|(w, _)| w.name == name)
            .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id })
            .ok_or(format!("No window with name \"{}\"", &name)),
    }
}
```

Then both arms become `let capture_target = resolve_capture_target(capture_mode)?;`.

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

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!

DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand Down
44 changes: 44 additions & 0 deletions extensions/raycast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Cap for Raycast

Control [Cap](https://cap.so) screen recorder directly from Raycast.

## Commands

| Command | Description |
| --- | --- |
| Start Recording | Start a new screen recording |
| Stop Recording | Stop the current recording |
| Pause Recording | Pause the current recording |
| Resume Recording | Resume a paused recording |
| Toggle Pause | Toggle pause/resume |
| Restart Recording | Restart the current recording |
| Take Screenshot | Take a screenshot |
| Open Settings | Open Cap settings |

## How It Works

This extension uses Cap's deeplink protocol (`cap-desktop://action?value=<json>`) to communicate with the desktop app. Make sure Cap is running before using the commands.

## Deeplink Protocol

Cap supports the following deeplink actions:

```
cap-desktop://action?value="stop_recording"
cap-desktop://action?value="pause_recording"
cap-desktop://action?value="resume_recording"
cap-desktop://action?value="toggle_pause_recording"
cap-desktop://action?value="restart_recording"
cap-desktop://action?value={"start_recording":{"capture_mode":{"screen":"Main Display"},"camera":null,"mic_label":null,"capture_system_audio":false,"mode":"studio"}}
cap-desktop://action?value={"take_screenshot":{"capture_mode":{"screen":"Main Display"}}}
cap-desktop://action?value={"open_editor":{"project_path":"/path/to/project"}}
cap-desktop://action?value={"open_settings":{"page":null}}
```

## Development

```bash
cd extensions/raycast
npm install
npm run dev
```
Binary file added extensions/raycast/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
88 changes: 88 additions & 0 deletions extensions/raycast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "cap",
"title": "Cap",
"description": "Control Cap screen recorder — start/stop recording, pause, resume, take screenshots, and more.",
"icon": "icon.png",
"author": "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 Missing icon file

package.json declares "icon": "command-icon.png" but no such file exists anywhere in the extensions/raycast/ directory (confirmed by inspecting the full commit tree). Raycast validates the icon at build time, so ray build and ray develop will fail with a missing-asset error until this PNG is added to the extension root.

You need to add a command-icon.png (typically 512×512 px) to extensions/raycast/ before this extension can be built or published.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/raycast/package.json
Line: 7

Comment:
**Missing icon file**

`package.json` declares `"icon": "command-icon.png"` but no such file exists anywhere in the `extensions/raycast/` directory (confirmed by inspecting the full commit tree). Raycast validates the icon at build time, so `ray build` and `ray develop` will fail with a missing-asset error until this PNG is added to the extension root.

You need to add a `command-icon.png` (typically 512×512 px) to `extensions/raycast/` before this extension can be built or published.

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

"categories": ["Productivity", "Applications"],
"license": "AGPL-3.0",
"commands": [
{
"name": "start-recording",
"title": "Start Recording",
"subtitle": "Cap",
"description": "Start a new screen recording with Cap",
"mode": "no-view"
},
{
"name": "stop-recording",
"title": "Stop Recording",
"subtitle": "Cap",
"description": "Stop the current recording",
"mode": "no-view"
},
{
"name": "pause-recording",
"title": "Pause Recording",
"subtitle": "Cap",
"description": "Pause the current recording",
"mode": "no-view"
},
{
"name": "resume-recording",
"title": "Resume Recording",
"subtitle": "Cap",
"description": "Resume a paused recording",
"mode": "no-view"
},
{
"name": "toggle-pause",
"title": "Toggle Pause",
"subtitle": "Cap",
"description": "Toggle pause/resume on the current recording",
"mode": "no-view"
},
{
"name": "restart-recording",
"title": "Restart Recording",
"subtitle": "Cap",
"description": "Restart the current recording",
"mode": "no-view"
},
{
"name": "take-screenshot",
"title": "Take Screenshot",
"subtitle": "Cap",
"description": "Take a screenshot with Cap",
"mode": "no-view"
},
{
"name": "open-settings",
"title": "Open Settings",
"subtitle": "Cap",
"description": "Open Cap settings",
"mode": "no-view"
}
],
"dependencies": {
"@raycast/api": "^1.93.2",
"@raycast/utils": "^1.19.1"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.11",
"@types/node": "22.13.14",
"@types/react": "19.0.12",
"eslint": "^9.23.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
},
"scripts": {
"build": "ray build",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint",
"prepublishOnly": "echo \"Error: no publish\" && exit 1",
"publish": "npx @raycast/api@latest publish"
}
}
8 changes: 8 additions & 0 deletions extensions/raycast/src/open-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { triggerDeepLink } from "./utils";

export default async function Command() {
await triggerDeepLink(
{ open_settings: { page: null } },
"⚙️ Opening Cap settings…",
);
}
5 changes: 5 additions & 0 deletions extensions/raycast/src/pause-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { triggerDeepLink } from "./utils";

export default async function Command() {
await triggerDeepLink("pause_recording", "⏸ Pausing Cap recording…");
}
5 changes: 5 additions & 0 deletions extensions/raycast/src/restart-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { triggerDeepLink } from "./utils";

export default async function Command() {
await triggerDeepLink("restart_recording", "🔄 Restarting Cap recording…");
}
5 changes: 5 additions & 0 deletions extensions/raycast/src/resume-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { triggerDeepLink } from "./utils";

export default async function Command() {
await triggerDeepLink("resume_recording", "▶️ Resuming Cap recording…");
}
16 changes: 16 additions & 0 deletions extensions/raycast/src/start-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { triggerDeepLink } from "./utils";

export default async function Command() {
await triggerDeepLink(
{
start_recording: {
capture_mode: null,
camera: null,
mic_label: null,
capture_system_audio: false,
mode: "studio",
},
},
"📹 Starting Cap recording…",
);
}
5 changes: 5 additions & 0 deletions extensions/raycast/src/stop-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { triggerDeepLink } from "./utils";

export default async function Command() {
await triggerDeepLink("stop_recording", "⏹ Stopping Cap recording…");
}
12 changes: 12 additions & 0 deletions extensions/raycast/src/take-screenshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { triggerDeepLink } from "./utils";

export default async function Command() {
await triggerDeepLink(
{
take_screenshot: {
capture_mode: null,
},
},
"📸 Taking screenshot with Cap…",
);
}
5 changes: 5 additions & 0 deletions extensions/raycast/src/toggle-pause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { triggerDeepLink } from "./utils";

export default async function Command() {
await triggerDeepLink("toggle_pause_recording", "⏯ Toggling Cap recording pause…");
}
35 changes: 35 additions & 0 deletions extensions/raycast/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { open, showHUD } from "@raycast/api";

const SCHEME = "cap-desktop";

type DeepLinkAction = string | Record<string, unknown>;

/**
* Build a Cap deeplink URL.
*
* Actions are sent as: cap-desktop://action?value=<json>
*
* Unit variants (e.g. StopRecording) serialize as a plain string: "stop_recording"
* Struct variants serialize as: {"start_recording": {...}}
*/
export function buildDeepLink(action: DeepLinkAction): string {
const json = JSON.stringify(action);
return `${SCHEME}://action?value=${encodeURIComponent(json)}`;
}

/**
* Open a Cap deeplink and show a HUD message.
* Shows an error HUD if Cap is not running or the deeplink fails.
*/
export async function triggerDeepLink(
action: DeepLinkAction,
hudMessage: string,
): Promise<void> {
const url = buildDeepLink(action);
try {
await open(url);
await showHUD(hudMessage);
} catch {
await showHUD("❌ Failed — is Cap running?");
}
}
17 changes: 17 additions & 0 deletions extensions/raycast/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Node 22",
"compilerOptions": {
"lib": ["ES2023"],
"module": "Node16",
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "Node16",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx"
},
"include": ["src/**/*"]
}