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
42 changes: 40 additions & 2 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,12 @@ use tracing::trace;
use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CaptureMode {
Screen(String),
Window(String),
}
Comment on lines 12 to 15
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.

P0 Missing Deserialize derive — will not compile

The PR removes #[derive(Debug, Serialize, Deserialize)] from both CaptureMode and DeepLinkAction. The TryFrom<&Url> implementation at line 107 calls serde_json::from_str::<DeepLinkAction>(json_value), which requires DeepLinkAction: Deserialize<'_>. Without the derive, the crate will fail to compile with "the trait Deserialize<'_> is not implemented for DeepLinkAction". The original derive also covered CaptureMode, which is a nested variant field and must be deserializable as well.

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

Comment:
**Missing `Deserialize` derive — will not compile**

The PR removes `#[derive(Debug, Serialize, Deserialize)]` from both `CaptureMode` and `DeepLinkAction`. The `TryFrom<&Url>` implementation at line 107 calls `serde_json::from_str::<DeepLinkAction>(json_value)`, which requires `DeepLinkAction: Deserialize<'_>`. Without the derive, the crate will fail to compile with *"the trait `Deserialize<'_>` is not implemented for `DeepLinkAction`"*. The original derive also covered `CaptureMode`, which is a nested variant field and must be deserializable as well.

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


#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeepLinkAction {
StartRecording {
capture_mode: CaptureMode,
Expand All @@ -26,6 +24,15 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
SwitchMicrophone {
label: String,
},
SwitchCamera {
label: String,
},
GetStatus,
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -147,6 +154,37 @@ impl DeepLinkAction {
DeepLinkAction::StopRecording => {
crate::recording::stop_recording(app.clone(), app.state()).await
}
// ---- NEW ACTIONS ----
DeepLinkAction::PauseRecording => {
let state = app.state::<ArcLock<App>>();
crate::recording::pause_recording(app.clone(), state).await
}
DeepLinkAction::ResumeRecording => {
let state = app.state::<ArcLock<App>>();
crate::recording::resume_recording(app.clone(), state).await
}
DeepLinkAction::SwitchMicrophone { label } => {
let state = app.state::<ArcLock<App>>();
crate::set_mic_input(state.clone(), Some(label)).await
}
DeepLinkAction::SwitchCamera { label } => {
let state = app.state::<ArcLock<App>>();
let camera = Some(DeviceOrModelID::Label(label));
crate::set_camera_input(app.clone(), state.clone(), camera, None).await
}
DeepLinkAction::GetStatus => {
let state = app.state::<ArcLock<App>>();
let locked = state.read().await;
let status = if locked.current_recording.is_some() {
"recording"
} else {
"idle"
};
// Emit status to frontend as a tauri event so Raycast can poll it
app.emit("cap://status", serde_json::json!({ "status": status }))
.map_err(|e| e.to_string())
}
// ---- END NEW ACTIONS ----
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand Down
Binary file added pr_demo.mp4
Binary file not shown.
31 changes: 31 additions & 0 deletions raycast-extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Cap Raycast Extension

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

## Commands

| Command | Description |
|---|---|
| **Start Recording** | Start a new Cap screen recording instantly |
| **Stop Recording** | Stop the active recording |
| **Pause / Resume Recording** | Toggle pause state of the current recording |
| **Switch Microphone** | Choose from a list of available microphones |
| **Switch Camera** | Choose from a list of available cameras |
| **Recording Status** | Check whether Cap is currently recording |

## Requirements

- [Cap](https://cap.so) installed and running on macOS
- [Raycast](https://raycast.com) installed

## How It Works

Each command triggers a `cap-desktop://action?value=...` deeplink that is handled natively by the Cap desktop app's [`deeplink_actions.rs`](../../apps/desktop/src-tauri/src/deeplink_actions.rs) handler.

## Development

```bash
cd raycast-extension
npm install
npm run dev
```
78 changes: 78 additions & 0 deletions raycast-extension/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"name": "cap-screen-recorder",
"title": "Cap Screen Recorder",
"description": "Control Cap screen recorder from Raycast — start, stop, pause, resume, and switch devices",
"icon": "cap-icon.png",
"author": "Ojas2095",
"categories": ["Applications", "Productivity"],
"license": "MIT",
"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 Cap screen recording",
"mode": "no-view",
"icon": "stop-icon.png"
},
{
"name": "pause-resume-recording",
"title": "Pause / Resume Recording",
"subtitle": "Cap",
"description": "Toggle pause state of the current Cap recording",
"mode": "no-view",
"icon": "pause-icon.png"
},
{
"name": "switch-microphone",
"title": "Switch Microphone",
"subtitle": "Cap",
"description": "Choose an active microphone for Cap recording",
"mode": "view",
"icon": "mic-icon.png"
},
{
"name": "switch-camera",
"title": "Switch Camera",
"subtitle": "Cap",
"description": "Choose an active camera for Cap recording",
"mode": "view",
"icon": "camera-icon.png"
},
{
"name": "get-status",
"title": "Recording Status",
"subtitle": "Cap",
"description": "Check if Cap is currently recording",
"mode": "no-view",
"icon": "status-icon.png"
}
],
"dependencies": {
"@raycast/api": "^1.77.0",
"@raycast/utils": "^1.16.0"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.8",
"@types/node": "20.8.10",
"@types/react": "18.3.3",
"eslint": "^8.57.0",
"prettier": "^3.3.3",
"typescript": "^5.4.5"
},
"scripts": {
"build": "ray build -e dist",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint",
"publish": "npx @raycast/api@latest publish"
}
}
7 changes: 7 additions & 0 deletions raycast-extension/src/get-status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { showHUD } from "@raycast/api";
import { triggerDeeplink } from "./lib/deeplink";

export default async function Command() {
await triggerDeeplink({ GetStatus: {} });
await showHUD("📡 Status request sent to Cap");
Comment on lines +4 to +6
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 GetStatus result is never visible in Raycast

The backend emits the status as a Tauri event (app.emit("cap://status", …)), which is dispatched to the Tauri webview frontend — not to Raycast. Raycast has no listener for Tauri events, so the get-status.tsx command can never actually display whether Cap is recording. The HUD message "📡 Status request sent to Cap" confirms the event was fired, but the user sees no actual status. A functional implementation would require a different mechanism (e.g., returning state via the deeplink URL or writing it to a shared file/HTTP endpoint that Raycast can read).

Prompt To Fix With AI
This is a comment left during a code review.
Path: raycast-extension/src/get-status.tsx
Line: 4-6

Comment:
**`GetStatus` result is never visible in Raycast**

The backend emits the status as a Tauri event (`app.emit("cap://status", …)`), which is dispatched to the Tauri webview frontend — not to Raycast. Raycast has no listener for Tauri events, so the `get-status.tsx` command can never actually display whether Cap is recording. The HUD message *"📡 Status request sent to Cap"* confirms the event was fired, but the user sees no actual status. A functional implementation would require a different mechanism (e.g., returning state via the deeplink URL or writing it to a shared file/HTTP endpoint that Raycast can read).

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

}
10 changes: 10 additions & 0 deletions raycast-extension/src/lib/deeplink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { open, showHUD } from "@raycast/api";

/**
* Triggers a Cap deeplink action using the cap-desktop:// URL scheme.
*/
export async function triggerDeeplink(action: object): Promise<void> {
const encoded = encodeURIComponent(JSON.stringify(action));
const url = `cap-desktop://action?value=${encoded}`;
await open(url);
}
19 changes: 19 additions & 0 deletions raycast-extension/src/pause-resume-recording.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { showHUD } from "@raycast/api";
import { triggerDeeplink } from "./lib/deeplink";
import { LocalStorage } from "@raycast/api";

const PAUSED_KEY = "cap_is_paused";

export default async function Command() {
const isPaused = (await LocalStorage.getItem<string>(PAUSED_KEY)) === "true";

if (isPaused) {
await triggerDeeplink({ ResumeRecording: {} });
await LocalStorage.setItem(PAUSED_KEY, "false");
await showHUD("▶ Cap recording resumed");
} else {
await triggerDeeplink({ PauseRecording: {} });
await LocalStorage.setItem(PAUSED_KEY, "true");
await showHUD("⏸ Cap recording paused");
}
Comment on lines +8 to +18
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 LocalStorage toggle can permanently desync from real recording state

The pause/resume toggle is driven entirely by a LocalStorage flag, with no way to resynchronize against the actual Cap recording state. If the recording is stopped (or crashes) while cap_is_paused is "true", the next invocation will send ResumeRecording instead of PauseRecording, and subsequent invocations will stay permanently inverted. Since Cap doesn't push state back to Raycast, this mismatch is silent and has no recovery path other than manually toggling twice.

Prompt To Fix With AI
This is a comment left during a code review.
Path: raycast-extension/src/pause-resume-recording.tsx
Line: 8-18

Comment:
**LocalStorage toggle can permanently desync from real recording state**

The pause/resume toggle is driven entirely by a `LocalStorage` flag, with no way to resynchronize against the actual Cap recording state. If the recording is stopped (or crashes) while `cap_is_paused` is `"true"`, the next invocation will send `ResumeRecording` instead of `PauseRecording`, and subsequent invocations will stay permanently inverted. Since Cap doesn't push state back to Raycast, this mismatch is silent and has no recovery path other than manually toggling twice.

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

}
15 changes: 15 additions & 0 deletions raycast-extension/src/start-recording.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { showHUD } from "@raycast/api";
import { triggerDeeplink } from "./lib/deeplink";

export default async function Command() {
await triggerDeeplink({
StartRecording: {
capture_mode: { Screen: "Main" },
camera: null,
mic_label: null,
capture_system_audio: false,
mode: "instant",
},
});
await showHUD("▶ Cap recording started");
}
7 changes: 7 additions & 0 deletions raycast-extension/src/stop-recording.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { showHUD } from "@raycast/api";
import { triggerDeeplink } from "./lib/deeplink";

export default async function Command() {
await triggerDeeplink({ StopRecording: {} });
await showHUD("⏹ Cap recording stopped");
}
38 changes: 38 additions & 0 deletions raycast-extension/src/switch-camera.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ActionPanel, Action, List, showHUD, Icon } from "@raycast/api";
import { triggerDeeplink } from "./lib/deeplink";

// Common camera labels — users can extend this list
const CAMERAS = [
"FaceTime HD Camera",
"FaceTime HD Camera (Built-in)",
"Continuity Camera",
"OBS Virtual Camera",
"Logitech C920",
"Logitech StreamCam",
"Sony Alpha",
];

export default function Command() {
return (
<List navigationTitle="Switch Camera" searchBarPlaceholder="Search cameras...">
{CAMERAS.map((cam) => (
<List.Item
key={cam}
icon={Icon.Camera}
title={cam}
actions={
<ActionPanel>
<Action
title="Select Camera"
onAction={async () => {
await triggerDeeplink({ SwitchCamera: { label: cam } });
await showHUD(`📷 Switched to ${cam}`);
}}
/>
</ActionPanel>
}
/>
))}
</List>
);
}
38 changes: 38 additions & 0 deletions raycast-extension/src/switch-microphone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ActionPanel, Action, List, showHUD, Icon } from "@raycast/api";
import { triggerDeeplink } from "./lib/deeplink";

// Common microphone labels — users can extend this list
const MICROPHONES = [
"MacBook Pro Microphone",
"Built-in Microphone",
"AirPods Pro",
"AirPods Max",
"USB Audio Device",
"Rode NT-USB",
"Blue Yeti",
Comment on lines +4 to +12
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 Hardcoded device list won't match users' actual hardware

MICROPHONES (and CAMERAS in switch-camera.tsx) is a fixed static array of guessed device labels. If a user's microphone label doesn't exactly match one of the hardcoded strings — including capitalisation and punctuation — the SwitchMicrophone deeplink will silently fail to match any device in Cap. Consider fetching the real list of available devices from Cap (e.g., via a dedicated deeplink query or a local HTTP endpoint) so the extension always reflects the current system state.

Prompt To Fix With AI
This is a comment left during a code review.
Path: raycast-extension/src/switch-microphone.tsx
Line: 4-12

Comment:
**Hardcoded device list won't match users' actual hardware**

`MICROPHONES` (and `CAMERAS` in `switch-camera.tsx`) is a fixed static array of guessed device labels. If a user's microphone label doesn't exactly match one of the hardcoded strings — including capitalisation and punctuation — the `SwitchMicrophone` deeplink will silently fail to match any device in Cap. Consider fetching the real list of available devices from Cap (e.g., via a dedicated deeplink query or a local HTTP endpoint) so the extension always reflects the current system state.

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

];

export default function Command() {
return (
<List navigationTitle="Switch Microphone" searchBarPlaceholder="Search microphones...">
{MICROPHONES.map((mic) => (
<List.Item
key={mic}
icon={Icon.Microphone}
title={mic}
actions={
<ActionPanel>
<Action
title="Select Microphone"
onAction={async () => {
await triggerDeeplink({ SwitchMicrophone: { label: mic } });
await showHUD(`🎙 Switched to ${mic}`);
}}
/>
</ActionPanel>
}
/>
))}
</List>
);
}
14 changes: 14 additions & 0 deletions raycast-extension/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"outDir": "dist"
},
"include": ["src"]
}