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
33 changes: 33 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,
SetMicrophone {
mic_label: Option<String>,
},
SetCamera {
camera_label: Option<String>,
},
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -146,6 +155,30 @@ 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::SetMicrophone { mic_label } => {
crate::set_mic_input(app.state(), mic_label).await
}
DeepLinkAction::SetCamera { camera_label } => {
let camera = match camera_label {
Some(label) => crate::recording::list_cameras()
.into_iter()
.find(|camera| camera.display_name() == label)
.map(|camera| DeviceOrModelID::from_info(&camera))
.ok_or(format!("No camera with label \"{}\"", &label))?,
Copy link

Choose a reason for hiding this comment

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

format! here runs even on the happy path. ok_or_else avoids allocating unless we actually error.

Suggested change
.ok_or(format!("No camera with label \"{}\"", &label))?,
.ok_or_else(|| format!("No camera with label \"{}\"", &label))?,

None => return crate::set_camera_input(app.clone(), app.state(), None, None).await,
};

crate::set_camera_input(app.clone(), app.state(), Some(camera), None).await
}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand Down
17 changes: 17 additions & 0 deletions apps/raycast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Cap Raycast Extension

This extension controls Cap Desktop through the `cap-desktop://action` deeplink.

## Commands

- Start Recording
- Stop Recording
- Pause Recording
- Resume Recording
- Toggle Pause Recording
- Switch Microphone
- Switch Camera

All commands serialize a `DeepLinkAction` payload and open:

`cap-desktop://action?value=<json>`
Copy link

Choose a reason for hiding this comment

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

value needs to be URL-encoded JSON (otherwise spaces/quotes/etc can break the deeplink).

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

68 changes: 68 additions & 0 deletions apps/raycast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"name": "@cap/raycast",
"private": true,
"version": "0.0.1",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@raycast/api": "^1.83.1"
},
"devDependencies": {
"@raycast/eslint-config": "^2.0.9",
"@types/node": "^22.13.10",
"@types/react": "^19.2.14",
"react": "^19.2.4",
"typescript": "^5.8.3"
},
"raycast": {
"schemaVersion": 1,
"title": "Cap",
"description": "Control Cap recording through deeplinks",
"author": "capsoftware",
"commands": [
{
"name": "start-recording",
"title": "Start Recording",
"description": "Start a Cap recording",
"mode": "view"
},
{
"name": "stop-recording",
"title": "Stop Recording",
"description": "Stop the current Cap recording",
"mode": "no-view"
},
{
"name": "pause-recording",
"title": "Pause Recording",
"description": "Pause the current Cap recording",
"mode": "no-view"
},
{
"name": "resume-recording",
"title": "Resume Recording",
"description": "Resume a paused Cap recording",
"mode": "no-view"
},
{
"name": "toggle-pause-recording",
"title": "Toggle Pause Recording",
"description": "Toggle pause/resume for the current recording",
"mode": "no-view"
},
{
"name": "switch-microphone",
"title": "Switch Microphone",
"description": "Switch microphone by label",
"mode": "view"
},
{
"name": "switch-camera",
"title": "Switch Camera",
"description": "Switch camera by label",
"mode": "view"
}
]
}
}
5 changes: 5 additions & 0 deletions apps/raycast/src/pause-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { dispatchSimpleAction } from "./utils";

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

export default async function Command() {
await dispatchSimpleAction("resume_recording");
}
58 changes: 58 additions & 0 deletions apps/raycast/src/start-recording.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Action, ActionPanel, Form } from "@raycast/api";
import { dispatchAction } from "./utils";

type Values = {
targetType: "screen" | "window";
targetName: string;
mode: "studio" | "instant";
micLabel: string;
cameraLabel: string;
captureSystemAudio: boolean;
};

export default function Command() {
return (
<Form
actions={
<ActionPanel>
<Action.SubmitForm
title="Start Recording"
onSubmit={async (values: Values) => {
const captureMode =
values.targetType === "window"
? { window: values.targetName }
: { screen: values.targetName };

await dispatchAction({
start_recording: {
capture_mode: captureMode,
camera: null,
mic_label: values.micLabel || null,
capture_system_audio: values.captureSystemAudio,
mode: values.mode,
},
});

if (values.cameraLabel) {
await dispatchAction({ set_camera: { camera_label: values.cameraLabel } });
}
Comment on lines +26 to +38
Copy link
Contributor

Choose a reason for hiding this comment

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

Race condition: start_recording can overwrite camera set by set_camera

The form dispatches two sequential deeplinks: start_recording (with camera: null) followed by set_camera. Because both open url calls return immediately to Raycast (before Cap processes the URLs), there is no guaranteed ordering between the two actions in the Tauri handler.

If set_camera is processed first and start_recording second (possible since Tauri spawns each deep-link handler as an async task), the start_recording handler explicitly calls set_camera_input(..., None, None), overwriting the user's camera choice back to null.

Since StartRecording already accepts camera: Option<DeviceOrModelID>, the cleanest fix is to avoid the two-step dispatch pattern entirely by resolving the camera label to DeviceOrModelID in Raycast (matching the logic in SetCamera), then passing the result in a single start_recording deeplink. Alternatively, ensure set_camera is always processed last by firing it in a separate non-awaited call, though this still leaves a small race window.

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

Comment:
**Race condition: `start_recording` can overwrite camera set by `set_camera`**

The form dispatches two sequential deeplinks: `start_recording` (with `camera: null`) followed by `set_camera`. Because both `open url` calls return immediately to Raycast (before Cap processes the URLs), there is no guaranteed ordering between the two actions in the Tauri handler.

If `set_camera` is processed first and `start_recording` second (possible since Tauri spawns each deep-link handler as an async task), the `start_recording` handler explicitly calls `set_camera_input(..., None, None)`, overwriting the user's camera choice back to `null`.

Since `StartRecording` already accepts `camera: Option<DeviceOrModelID>`, the cleanest fix is to avoid the two-step dispatch pattern entirely by resolving the camera label to `DeviceOrModelID` in Raycast (matching the logic in `SetCamera`), then passing the result in a single `start_recording` deeplink. Alternatively, ensure `set_camera` is always processed last by firing it in a separate non-awaited call, though this still leaves a small race window.

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

}}
/>
</ActionPanel>
}
>
<Form.Dropdown id="targetType" title="Target Type" defaultValue="screen">
<Form.Dropdown.Item value="screen" title="Screen" />
<Form.Dropdown.Item value="window" title="Window" />
</Form.Dropdown>
<Form.TextField id="targetName" title="Target Name" placeholder="Built-in Retina Display" />
Copy link
Contributor

Choose a reason for hiding this comment

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

targetName is required by the Rust handler (used in list_displays()/list_windows() lookup), but the form allows empty submission. An empty string will fail silently with "No screen with name """ on the Rust side. Mark the field as required to prevent invalid form submission:

Suggested change
<Form.TextField id="targetName" title="Target Name" placeholder="Built-in Retina Display" />
<Form.TextField id="targetName" title="Target Name" placeholder="Built-in Retina Display" isRequired />
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast/src/start-recording.tsx
Line: 48

Comment:
`targetName` is required by the Rust handler (used in `list_displays()`/`list_windows()` lookup), but the form allows empty submission. An empty string will fail silently with "No screen with name \"\"" on the Rust side. Mark the field as required to prevent invalid form submission:

```suggestion
      <Form.TextField id="targetName" title="Target Name" placeholder="Built-in Retina Display" isRequired />
```

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

<Form.Dropdown id="mode" title="Mode" defaultValue="studio">
<Form.Dropdown.Item value="studio" title="Studio" />
<Form.Dropdown.Item value="instant" title="Instant" />
</Form.Dropdown>
<Form.TextField id="micLabel" title="Microphone Label" placeholder="Optional" />
<Form.TextField id="cameraLabel" title="Camera Label" placeholder="Optional" />
<Form.Checkbox id="captureSystemAudio" label="Capture System Audio" defaultValue={true} />
</Form>
);
}
5 changes: 5 additions & 0 deletions apps/raycast/src/stop-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { dispatchSimpleAction } from "./utils";

export default async function Command() {
await dispatchSimpleAction("stop_recording");
}
21 changes: 21 additions & 0 deletions apps/raycast/src/switch-camera.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ActionPanel, Action, Form } from "@raycast/api";
import { dispatchAction } from "./utils";

export default function Command() {
return (
<Form
actions={
<ActionPanel>
<Action.SubmitForm
title="Switch Camera"
onSubmit={async (values: { cameraLabel: string }) => {
await dispatchAction({ set_camera: { camera_label: values.cameraLabel || null } });
}}
/>
</ActionPanel>
}
>
<Form.TextField id="cameraLabel" title="Camera Label" placeholder="FaceTime HD Camera" />
</Form>
);
}
21 changes: 21 additions & 0 deletions apps/raycast/src/switch-microphone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ActionPanel, Action, Form } from "@raycast/api";
import { dispatchAction } from "./utils";

export default function Command() {
return (
<Form
actions={
<ActionPanel>
<Action.SubmitForm
title="Switch Microphone"
onSubmit={async (values: { micLabel: string }) => {
await dispatchAction({ set_microphone: { mic_label: values.micLabel || null } });
}}
/>
</ActionPanel>
}
>
<Form.TextField id="micLabel" title="Microphone Label" placeholder="MacBook Pro Microphone" />
</Form>
);
}
5 changes: 5 additions & 0 deletions apps/raycast/src/toggle-pause-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { dispatchSimpleAction } from "./utils";

export default async function Command() {
await dispatchSimpleAction("toggle_pause_recording");
}
35 changes: 35 additions & 0 deletions apps/raycast/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { closeMainWindow, showToast, Toast } from "@raycast/api";
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

function toDeepLink(action: unknown) {
const value = encodeURIComponent(JSON.stringify(action));
return `cap-desktop://action?value=${value}`;
}

export async function dispatchAction(action: unknown) {
const url = toDeepLink(action);
await closeMainWindow();

try {
await execFileAsync("open", [url]);
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to trigger Cap",
message: error instanceof Error ? error.message : String(error),
});
return;
}

await showToast({
style: Toast.Style.Success,
title: "Sent to Cap",
});
}
Comment on lines +12 to +31
Copy link

Choose a reason for hiding this comment

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

toDeepLink() can throw (e.g. JSON.stringify), but right now that bypasses the toast and fails silently. Pull it into the try so we always surface a failure.

Suggested change
export async function dispatchAction(action: unknown) {
const url = toDeepLink(action);
await closeMainWindow();
try {
await execFileAsync("open", [url]);
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to trigger Cap",
message: error instanceof Error ? error.message : String(error),
});
return;
}
await showToast({
style: Toast.Style.Success,
title: "Sent to Cap",
});
}
export async function dispatchAction(action: unknown) {
await closeMainWindow();
try {
const url = toDeepLink(action);
await execFileAsync("open", [url]);
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to trigger Cap",
message: error instanceof Error ? error.message : String(error),
});
return;
}
await showToast({
style: Toast.Style.Success,
title: "Sent to Cap",
});
}


export async function dispatchSimpleAction(actionName: string) {
await dispatchAction({ [actionName]: null });
}
13 changes: 13 additions & 0 deletions apps/raycast/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}
Loading