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
26 changes: 26 additions & 0 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
TogglePauseRecording,
SwitchMicrophone {
label: Option<String>,
},
SwitchCamera {
camera: Option<DeviceOrModelID>,
},
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -147,6 +156,23 @@ 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::SwitchMicrophone { label } => {
let state = app.state::<ArcLock<App>>();
crate::set_mic_input(state, label).await
}
DeepLinkAction::SwitchCamera { camera } => {
let state = app.state::<ArcLock<App>>();
crate::set_camera_input(app.clone(), state, camera, None).await
}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand Down
43 changes: 43 additions & 0 deletions extensions/raycast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 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 |
| **Toggle Pause** | Pause or resume the current recording |
| **Open Cap** | Open the Cap application |
| **Open Settings** | Open Cap settings |
| **Recent Recordings** | Browse your recent recordings |

## Requirements

- [Cap](https://cap.so) desktop app installed
- macOS

## How it works

This extension communicates with Cap via the `cap-desktop://` deep link URL scheme. All commands are executed instantly without opening any UI (except "Recent Recordings" which shows a list view).

## Deep Link Format

Cap uses the following deep link format:

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

### Supported Actions

- `start_recording` β€” Start recording with capture mode, camera, mic options
- `stop_recording` β€” Stop the current recording
- `pause_recording` β€” Pause the current recording
- `resume_recording` β€” Resume a paused recording
- `toggle_pause_recording` β€” Toggle pause/resume
- `open_editor` β€” Open a recording in the editor
- `open_settings` β€” Open Cap settings
- `switch_microphone` β€” Switch to a different microphone
- `switch_camera` β€” Switch to a different camera
72 changes: 72 additions & 0 deletions extensions/raycast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "cap",
"title": "Cap",
"description": "Control Cap screen recorder β€” start, stop, pause, and resume recordings directly from Raycast",
"icon": "cap-icon.png",
"author": "cap",
"categories": ["Productivity", "Applications"],
"license": "AGPL-3.0",
"commands": [
{
"name": "start-recording",
"title": "Start Recording",
"description": "Start a new screen recording with Cap",
"mode": "no-view",
"keywords": ["record", "screen", "capture"]
},
{
"name": "stop-recording",
"title": "Stop Recording",
"description": "Stop the current recording",
"mode": "no-view",
"keywords": ["stop", "end", "finish"]
},
{
"name": "toggle-pause",
"title": "Toggle Pause",
"description": "Pause or resume the current recording",
"mode": "no-view",
"keywords": ["pause", "resume", "toggle"]
},
{
"name": "open-cap",
"title": "Open Cap",
"description": "Open the Cap application",
"mode": "no-view",
"keywords": ["open", "launch"]
},
{
"name": "open-settings",
"title": "Open Settings",
"description": "Open Cap settings",
"mode": "no-view",
"keywords": ["settings", "preferences", "config"]
},
{
"name": "recent-recordings",
"title": "Recent Recordings",
"description": "Browse your recent Cap recordings",
"mode": "view",
"keywords": ["recent", "history", "list", "browse"]
}
],
"dependencies": {
"@raycast/api": "^1.83.1",
"@raycast/utils": "^1.17.0"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.11",
"@types/node": "22.5.4",
"@types/react": "18.3.3",
"eslint": "^8.57.0",
"prettier": "^3.3.3",
"typescript": "^5.4.5"
},
"scripts": {
"build": "ray build",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint"
}
}
11 changes: 11 additions & 0 deletions extensions/raycast/src/open-cap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { showHUD } from "@raycast/api";
import { openCap } from "./utils";

export default async function command() {
try {
await openCap();
await showHUD("🎬 Opening Cap...");
} catch {
await showHUD("❌ Failed to open Cap. Is it installed?");
}
}
12 changes: 12 additions & 0 deletions extensions/raycast/src/open-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { showHUD } from "@raycast/api";
import { sendDeepLink } from "./utils";

export default async function command() {
try {
// serde struct variant with optional field
await sendDeepLink({ open_settings: { page: null } });
await showHUD("βš™οΈ Cap: Opening settings...");
} catch {
await showHUD("❌ Failed to open settings. Is Cap running?");
}
}
71 changes: 71 additions & 0 deletions extensions/raycast/src/recent-recordings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Action, ActionPanel, List, open, showHUD } from "@raycast/api";
import { useState, useEffect } from "react";
import { readdirSync, statSync, existsSync } from "fs";
import { join } from "path";
import { getRecordingsDir } from "./utils";

interface Recording {
name: string;
path: string;
date: Date;
}

function getRecordings(): Recording[] {
const dir = getRecordingsDir();
if (!existsSync(dir)) return [];

try {
return readdirSync(dir)
.map((name) => {
const fullPath = join(dir, name);
const stat = statSync(fullPath);
return { name, path: fullPath, stat };
})
.filter(({ name, stat }) => name.endsWith(".cap") && stat.isDirectory())
.map(({ name, path, stat }) => ({ name, path, date: stat.mtime }))
.sort((a, b) => b.date.getTime() - a.date.getTime())
.slice(0, 50);
Comment on lines +18 to +27
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 statSync called twice per directory entry

In the current implementation, every entry that passes the isDirectory() filter has statSync called again in the map step to retrieve mtime. This doubles the number of filesystem calls. A single map-then-filter pattern avoids the redundancy:

Suggested change
return readdirSync(dir)
.filter((name) => {
const fullPath = join(dir, name);
return statSync(fullPath).isDirectory();
})
.map((name) => {
const fullPath = join(dir, name);
const stat = statSync(fullPath);
return { name, path: fullPath, date: stat.mtime };
})
.sort((a, b) => b.date.getTime() - a.date.getTime())
.slice(0, 50);
return readdirSync(dir)
.map((name) => {
const fullPath = join(dir, name);
const stat = statSync(fullPath);
return { name, path: fullPath, stat };
})
.filter(({ stat }) => stat.isDirectory())
.map(({ name, path, stat }) => ({ name, path, date: stat.mtime }))
.sort((a, b) => b.date.getTime() - a.date.getTime())
.slice(0, 50);
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/raycast/src/recent-recordings.tsx
Line: 18-29

Comment:
**`statSync` called twice per directory entry**

In the current implementation, every entry that passes the `isDirectory()` filter has `statSync` called again in the `map` step to retrieve `mtime`. This doubles the number of filesystem calls. A single `map`-then-filter pattern avoids the redundancy:

```suggestion
    return readdirSync(dir)
      .map((name) => {
        const fullPath = join(dir, name);
        const stat = statSync(fullPath);
        return { name, path: fullPath, stat };
      })
      .filter(({ stat }) => stat.isDirectory())
      .map(({ name, path, stat }) => ({ name, path, date: stat.mtime }))
      .sort((a, b) => b.date.getTime() - a.date.getTime())
      .slice(0, 50);
```

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

Comment on lines +18 to +27
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 No filter for .cap directory extension

getRecordings() lists all subdirectories in the recordings folder, including any non-recording artifacts. The Rust side treats only paths ending in .cap as valid recording projects (e.g. "my-recording.cap"). Adding a name filter keeps the list accurate:

Suggested change
return readdirSync(dir)
.filter((name) => {
const fullPath = join(dir, name);
return statSync(fullPath).isDirectory();
})
.map((name) => {
const fullPath = join(dir, name);
const stat = statSync(fullPath);
return { name, path: fullPath, date: stat.mtime };
})
.sort((a, b) => b.date.getTime() - a.date.getTime())
.slice(0, 50);
return readdirSync(dir)
.filter((name) => {
const fullPath = join(dir, name);
return name.endsWith(".cap") && statSync(fullPath).isDirectory();
})
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/raycast/src/recent-recordings.tsx
Line: 18-29

Comment:
**No filter for `.cap` directory extension**

`getRecordings()` lists all subdirectories in the recordings folder, including any non-recording artifacts. The Rust side treats only paths ending in `.cap` as valid recording projects (e.g. `"my-recording.cap"`). Adding a name filter keeps the list accurate:

```suggestion
    return readdirSync(dir)
      .filter((name) => {
        const fullPath = join(dir, name);
        return name.endsWith(".cap") && statSync(fullPath).isDirectory();
      })
```

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

} catch {
return [];
}
}

export default function Command() {
const [recordings, setRecordings] = useState<Recording[]>([]);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
setRecordings(getRecordings());
setIsLoading(false);
}, []);

return (
<List isLoading={isLoading} searchBarPlaceholder="Search recordings...">
{recordings.length === 0 ? (
<List.EmptyView title="No recordings found" description="Record something with Cap first!" />
) : (
recordings.map((rec) => (
<List.Item
key={rec.path}
title={rec.name}
subtitle={rec.date.toLocaleDateString()}
accessories={[{ date: rec.date }]}
actions={
<ActionPanel>
<Action
title="Open in Cap"
onAction={async () => {
await open(`file://${encodeURI(rec.path)}`);
await showHUD("Opening in Cap...");
Comment on lines +57 to +59
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 Unencoded spaces in file:// URL will break deep link handler

rec.path is a raw filesystem path. On macOS the recordings directory lives under ~/Library/Application Support/…, which contains a space. Passing file:///Users/alice/Library/Application Support/… as-is creates an invalid URL β€” the Rust Url parser will reject it (or produce unexpected results), so OpenEditor will never be triggered.

The path must be percent-encoded before constructing the URL:

Suggested change
onAction={async () => {
await open(`file://${rec.path}`);
await showHUD("Opening in Cap...");
await open(`file://${encodeURI(rec.path)}`);
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/raycast/src/recent-recordings.tsx
Line: 59-61

Comment:
**Unencoded spaces in `file://` URL will break deep link handler**

`rec.path` is a raw filesystem path. On macOS the recordings directory lives under `~/Library/Application Support/…`, which contains a space. Passing `file:///Users/alice/Library/Application Support/…` as-is creates an invalid URL β€” the Rust `Url` parser will reject it (or produce unexpected results), so `OpenEditor` will never be triggered.

The path must be percent-encoded before constructing the URL:

```suggestion
                    await open(`file://${encodeURI(rec.path)}`);
```

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

}}
/>
<Action.ShowInFinder path={rec.path} />
<Action.CopyToClipboard title="Copy Path" content={rec.path} />
</ActionPanel>
}
/>
))
)}
</List>
);
}
20 changes: 20 additions & 0 deletions extensions/raycast/src/start-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { showHUD } from "@raycast/api";
import { sendDeepLink } from "./utils";

export default async function command() {
try {
// serde externally-tagged struct variant
await sendDeepLink({
start_recording: {
capture_mode: { screen: "Main Display" },
camera: null,
mic_label: null,
capture_system_audio: false,
mode: "instant",
},
});
await showHUD("πŸ”΄ Cap: Recording started");
} catch {
await showHUD("❌ Failed to start recording. Is Cap running?");
}
}
12 changes: 12 additions & 0 deletions extensions/raycast/src/stop-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { showHUD } from "@raycast/api";
import { sendDeepLink } from "./utils";

export default async function command() {
try {
// serde unit variant
await sendDeepLink("stop_recording");
await showHUD("⏹ Cap: Recording stopped");
} catch {
await showHUD("❌ Failed to stop recording. Is Cap running?");
}
}
12 changes: 12 additions & 0 deletions extensions/raycast/src/toggle-pause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { showHUD } from "@raycast/api";
import { sendDeepLink } from "./utils";

export default async function command() {
try {
// serde unit variant
await sendDeepLink("toggle_pause_recording");
await showHUD("⏯ Cap: Toggled pause");
} catch {
await showHUD("❌ Failed to toggle pause. Is Cap running?");
}
}
33 changes: 33 additions & 0 deletions extensions/raycast/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { open, showHUD } from "@raycast/api";

const CAP_DEEPLINK_SCHEME = "cap-desktop://";

/**
* Send a deep link action to the Cap desktop app.
* URL format: cap-desktop://action?value=<JSON-encoded serde enum>
*
* The `value` parameter is the serde-serialized DeepLinkAction enum.
* For unit variants: `"stop_recording"` (just a string)
* For struct variants: `{"start_recording": { ... }}`
*/
export async function sendDeepLink(value: string | Record<string, unknown>): Promise<void> {
const encodedValue = encodeURIComponent(JSON.stringify(value));
const url = `${CAP_DEEPLINK_SCHEME}action?value=${encodedValue}`;
await open(url);
}

/**
* Open the Cap app without a specific action.
*/
export async function openCap(): Promise<void> {
await open(CAP_DEEPLINK_SCHEME);
}

/**
* Get the Cap recordings directory path.
* Cap (Tauri) stores recordings under the app data directory.
*/
export function getRecordingsDir(): string {
const home = process.env.HOME || "~";
return `${home}/Library/Application Support/so.cap.desktop/recordings`;
}
Comment on lines +30 to +33
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 Incorrect recordings directory path

getRecordingsDir() returns $HOME/.cap/recordings, but Cap (built with Tauri) stores recordings under the Tauri app_data_dir(), which on macOS resolves to ~/Library/Application Support/<bundle-identifier>/recordings (e.g. ~/Library/Application Support/so.cap.desktop/recordings). The hardcoded ~/.cap/recordings path does not exist, so the Recent Recordings command will always return an empty list regardless of how many recordings exist.

The correct absolute path cannot be derived from the environment variable alone β€” it depends on the Tauri bundle identifier. One option is to expose the recordings directory via a dedicated deeplink/URL query so the extension can ask the app, or document the real path for users to override via a Raycast preference.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/raycast/src/utils.ts
Line: 29-32

Comment:
**Incorrect recordings directory path**

`getRecordingsDir()` returns `$HOME/.cap/recordings`, but Cap (built with Tauri) stores recordings under the Tauri `app_data_dir()`, which on macOS resolves to `~/Library/Application Support/<bundle-identifier>/recordings` (e.g. `~/Library/Application Support/so.cap.desktop/recordings`). The hardcoded `~/.cap/recordings` path does not exist, so the **Recent Recordings** command will always return an empty list regardless of how many recordings exist.

The correct absolute path cannot be derived from the environment variable alone β€” it depends on the Tauri bundle identifier. One option is to expose the recordings directory via a dedicated deeplink/URL query so the extension can ask the app, or document the real path for users to override via a Raycast preference.

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

16 changes: 16 additions & 0 deletions extensions/raycast/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"module": "ES2022",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022"],
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src/**/*", "env.d.ts"]
}