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,
TogglePause,
SetCamera {
camera: Option<DeviceOrModelID>,
},
SetMicrophone {
mic_label: Option<String>,
},
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -146,6 +155,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::TogglePause => {
crate::recording::toggle_pause_recording(app.clone(), app.state()).await
}
DeepLinkAction::SetCamera { camera } => {
let state = app.state::<ArcLock<App>>();
crate::set_camera_input(app.clone(), state, camera, None).await
}
DeepLinkAction::SetMicrophone { mic_label } => {
let state = app.state::<ArcLock<App>>();
crate::set_mic_input(state, mic_label).await
}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand Down
55 changes: 55 additions & 0 deletions apps/raycast-extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Cap Raycast Extension

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

## Commands

| Command | Description |
|---------|-------------|
| **Start Recording** | Start a new screen recording with default settings |
| **Stop Recording** | Stop the current recording |
| **Toggle Pause** | Pause or resume the current recording |
| **Open Settings** | Open Cap settings window |
| **Recent Recordings** | Browse and open recent recordings |

## How It Works

This extension communicates with Cap using deeplinks (`cap-desktop://action?value=...`). Cap must be running for commands to work.

## Deeplinks

Cap supports the following deeplink actions:

- `start_recording` — Start recording with capture mode, camera, mic, and mode options
- `stop_recording` — Stop the current recording
- `pause_recording` — Pause the current recording
- `resume_recording` — Resume a paused recording
- `toggle_pause` — Toggle pause/resume
- `set_camera` — Switch the active camera
- `set_microphone` — Switch the active microphone
- `open_editor` — Open a recording in the editor
- `open_settings` — Open the settings window

### URL Format

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

Unit actions (no parameters):
```
cap-desktop://action?value=%22stop_recording%22
```

Actions with parameters (URL-encode the JSON):
```
cap-desktop://action?value=%7B%22start_recording%22%3A%7B%22capture_mode%22%3A%7B%22screen%22%3A%22Main%20Display%22%7D%2C%22camera%22%3Anull%2C%22mic_label%22%3Anull%2C%22capture_system_audio%22%3Afalse%2C%22mode%22%3A%22studio%22%7D%7D
```
Comment on lines 33 to 47
Copy link

Choose a reason for hiding this comment

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

The examples look slightly off: value needs to be URL-encoded JSON (including quotes for unit actions), and mode should match the actual enum values (studio|instant|screenshot).

Suggested change
### URL Format
```
cap-desktop://action?value=<url-encoded-json>
```
Unit actions (no parameters):
```
cap-desktop://action?value="stop_recording"
```
Actions with parameters:
```
cap-desktop://action?value={"start_recording":{"capture_mode":{"screen":"Main Display"},"camera":null,"mic_label":null,"capture_system_audio":false,"mode":"normal"}}
```
### URL Format

cap-desktop://action?value=


Unit actions (no parameters):

cap-desktop://action?value=%22stop_recording%22


Actions with parameters (URL-encode the JSON):
```json
{"start_recording":{"capture_mode":{"screen":"Main Display"},"camera":null,"mic_label":null,"capture_system_audio":false,"mode":"studio"}}

Copy link
Author

Choose a reason for hiding this comment

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

fixed the examples to use proper URL encoding and corrected the mode values, thanks for catching that


## Development

```bash
cd apps/raycast-extension
pnpm install
pnpm dev
```
Binary file added apps/raycast-extension/assets/extension-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
61 changes: 61 additions & 0 deletions apps/raycast-extension/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"name": "cap",
"title": "Cap",
"description": "Control Cap screen recordings from Raycast",
"icon": "extension-icon.png",
"author": "capsoftware",
"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 Cap recording",
"mode": "no-view"
},
{
"name": "toggle-pause",
"title": "Toggle Pause",
"subtitle": "Cap",
"description": "Pause or resume the current Cap recording",
"mode": "no-view"
},
{
"name": "open-settings",
"title": "Open Settings",
"subtitle": "Cap",
"description": "Open Cap settings",
"mode": "no-view"
},
{
"name": "list-recent-recordings",
"title": "Recent Recordings",
"subtitle": "Cap",
"description": "Browse recent Cap recordings",
"mode": "view"
}
],
"dependencies": {
"@raycast/api": "^1.93.2",
"@raycast/utils": "^1.19.1"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.11",
"typescript": "^5.8.3",
"@types/node": "^22.13.4",
"eslint": "^8.57.0"
},
"scripts": {
"build": "ray build",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint"
}
}
46 changes: 46 additions & 0 deletions apps/raycast-extension/src/deeplink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { open, showToast, Toast } from "@raycast/api";

const SCHEME = "cap-desktop";

type DeepLinkAction =
| "stop_recording"
| "pause_recording"
| "resume_recording"
| "toggle_pause"
| {
start_recording: {
capture_mode: { screen: string } | { window: string };
camera: { DeviceID: string } | { ModelID: string } | null;
mic_label: string | null;
capture_system_audio: boolean;
mode: "studio" | "instant" | "screenshot";
};
}
| { set_camera: { camera: { DeviceID: string } | { ModelID: string } | null } }
| { set_microphone: { mic_label: string | null } }
| { open_editor: { project_path: string } }
| { open_settings: { page: string | null } };

function buildDeeplink(action: DeepLinkAction): string {
const json = JSON.stringify(action);
return `${SCHEME}://action?value=${encodeURIComponent(json)}`;
}

export async function openDeeplink(
action: DeepLinkAction,
successMessage?: string,
): Promise<void> {
const url = buildDeeplink(action);
try {
await open(url);
if (successMessage) {
await showToast({ style: Toast.Style.Success, title: successMessage });
}
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to communicate with Cap",
message: error instanceof Error ? error.message : String(error),
});
}
}
78 changes: 78 additions & 0 deletions apps/raycast-extension/src/list-recent-recordings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Action, ActionPanel, List } from "@raycast/api";
import { usePromise } from "@raycast/utils";
import { readdirSync, statSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { openDeeplink } from "./deeplink";

function getRecordingsDir(): string {
return join(
homedir(),
"Library",
"Application Support",
"so.cap.desktop",
"recordings",
);
Comment on lines +9 to +15
Copy link
Contributor

Choose a reason for hiding this comment

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

hardcoded macOS path will prevent extension from working on Windows

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast-extension/src/list-recent-recordings.tsx
Line: 10:16

Comment:
hardcoded macOS path will prevent extension from working on Windows

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

}
Comment on lines 8 to 16
Copy link

Choose a reason for hiding this comment

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

Minor style thing: repo has a no-comments rule, so I'd remove the inline comment here.

Suggested change
function getRecordingsDir(): string {
// Cap stores recordings in its app data directory
return join(
homedir(),
"Library",
"Application Support",
"so.cap.desktop",
"recordings",
);
}
function getRecordingsDir(): string {
return join(
homedir(),
"Library",
"Application Support",
"so.cap.desktop",
"recordings",
);
}

Copy link
Author

Choose a reason for hiding this comment

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

done, stripped out the inline comments



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

function listRecordings(): Recording[] {
const dir = getRecordingsDir();
try {
return readdirSync(dir)
Copy link

Choose a reason for hiding this comment

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

If the recordings folder grows, doing a statSync per entry can get sluggish. Small win: use withFileTypes to skip non-files and cap the list.

Suggested change
return readdirSync(dir)
return readdirSync(dir, { withFileTypes: true })
.filter((d) => d.isFile() && d.name.endsWith(".cap"))
.map((d) => {
const fullPath = join(dir, d.name);
const stat = statSync(fullPath);
return {
name: d.name.replace(/\.cap$/, ""),
path: fullPath,
modifiedAt: stat.mtime,
};
})
.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime())
.slice(0, 200);

.filter((f) => f.endsWith(".cap"))
.map((f) => {
const fullPath = join(dir, f);
const stat = statSync(fullPath);
return {
name: f.replace(/\.cap$/, ""),
path: fullPath,
modifiedAt: stat.mtime,
};
})
.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime());
} catch {
return [];
}
}

export default function RecentRecordings() {
const { data: recordings, isLoading } = usePromise(async () => listRecordings());

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.modifiedAt.toLocaleDateString()}
accessories={[{ date: rec.modifiedAt }]}
actions={
<ActionPanel>
<Action
title="Open in Editor"
onAction={async () =>
openDeeplink({ open_editor: { project_path: rec.path } })
}
Comment on lines 64 to 68
Copy link

Choose a reason for hiding this comment

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

Action handlers can be async; making this async ensures the openDeeplink promise is handled.

Suggested change
<Action
title="Open in Editor"
onAction={() =>
openDeeplink({ open_editor: { project_path: rec.path } })
}
onAction={async () =>
openDeeplink({ open_editor: { project_path: rec.path } })
}

Copy link
Author

Choose a reason for hiding this comment

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

makes sense, added async to the handler

/>
<Action.ShowInFinder path={rec.path} />
</ActionPanel>
}
/>
))
)}
</List>
);
}
7 changes: 7 additions & 0 deletions apps/raycast-extension/src/open-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { closeMainWindow } from "@raycast/api";
import { openDeeplink } from "./deeplink";

export default async function openSettings() {
await openDeeplink({ open_settings: { page: null } }, "Opening settings");
await closeMainWindow();
}
18 changes: 18 additions & 0 deletions apps/raycast-extension/src/start-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { closeMainWindow } from "@raycast/api";
import { openDeeplink } from "./deeplink";

export default async function startRecording() {
await openDeeplink(
{
start_recording: {
capture_mode: { screen: "Main Display" },
Copy link
Contributor

Choose a reason for hiding this comment

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

hardcoded "Main Display" will fail if user's primary display has different name

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast-extension/src/start-recording.ts
Line: 8:8

Comment:
hardcoded `"Main Display"` will fail if user's primary display has different name

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

camera: null,
mic_label: null,
capture_system_audio: false,
mode: "studio",
},
},
"Recording started",
);
await closeMainWindow();
}
7 changes: 7 additions & 0 deletions apps/raycast-extension/src/stop-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { closeMainWindow } from "@raycast/api";
import { openDeeplink } from "./deeplink";

export default async function stopRecording() {
await openDeeplink("stop_recording", "Recording stopped");
await closeMainWindow();
}
7 changes: 7 additions & 0 deletions apps/raycast-extension/src/toggle-pause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { closeMainWindow } from "@raycast/api";
import { openDeeplink } from "./deeplink";

export default async function togglePause() {
await openDeeplink("toggle_pause", "Toggled pause");
await closeMainWindow();
}
15 changes: 15 additions & 0 deletions apps/raycast-extension/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}