-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat: Deeplinks support (Pause/Resume/SwitchMic/SwitchCamera) + Raycast Extension #1757
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,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 | ||
| ``` |
| 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" | ||
| } | ||
| } |
| 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
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.
The backend emits the status as a Tauri event ( Prompt To Fix With AIThis 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. |
||
| } | ||
| 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); | ||
| } |
| 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
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.
The pause/resume toggle is driven entirely by a Prompt To Fix With AIThis 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. |
||
| } | ||
| 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"); | ||
| } |
| 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"); | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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
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.
Prompt To Fix With AIThis 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> | ||
| ); | ||
| } | ||
| 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"] | ||
| } |
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.
Deserializederive — will not compileThe PR removes
#[derive(Debug, Serialize, Deserialize)]from bothCaptureModeandDeepLinkAction. TheTryFrom<&Url>implementation at line 107 callsserde_json::from_str::<DeepLinkAction>(json_value), which requiresDeepLinkAction: Deserialize<'_>. Without the derive, the crate will fail to compile with "the traitDeserialize<'_>is not implemented forDeepLinkAction". The original derive also coveredCaptureMode, which is a nested variant field and must be deserializable as well.Prompt To Fix With AI