-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat: add recording deeplinks and Raycast extension #1647
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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>` | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| 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" | ||
| } | ||
| ] | ||
| } | ||
| } |
| 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"); | ||
| } |
| 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"); | ||
| } |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Race condition: The form dispatches two sequential deeplinks: If Since Prompt To Fix With AIThis 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" /> | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Prompt To Fix With AIThis 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> | ||||||
| ); | ||||||
| } | ||||||
| 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"); | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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"); | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function dispatchSimpleAction(actionName: string) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await dispatchAction({ [actionName]: null }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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"] | ||
| } |
There was a problem hiding this comment.
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_elseavoids allocating unless we actually error.