feat: extend deeplink actions for Raycast extension support#1543
feat: extend deeplink actions for Raycast extension support#1543onyedikachi-david wants to merge 5 commits intoCapSoftware:mainfrom
Conversation
|
Ready for review @richiemcilroy |
| export default async function Command() { | ||
| await executeCapAction(createGetStatusAction(), { | ||
| feedbackMessage: "Checking recording status...", | ||
| feedbackType: "hud", | ||
| }); |
There was a problem hiding this comment.
logic: GetStatus action prints response to stdout but Raycast extension doesn't capture it. The status is sent via deeplink but never displayed to the user.
| export default async function Command() { | |
| await executeCapAction(createGetStatusAction(), { | |
| feedbackMessage: "Checking recording status...", | |
| feedbackType: "hud", | |
| }); | |
| import { showHUD } from "@raycast/api"; | |
| import { executeCapAction, createGetStatusAction, RecordingStatus } from "./utils"; | |
| export default async function Command() { | |
| const status = await getRecordingStatus(); | |
| if (status) { | |
| const message = status.is_recording | |
| ? `Recording (${status.recording_mode})${status.is_paused ? ' - Paused' : ''}` | |
| : 'Not recording'; | |
| await showHUD(message); | |
| } | |
| } | |
| async function getRecordingStatus(): Promise<RecordingStatus | null> { | |
| return null; | |
| } |
How should the Raycast extension receive the JSON response that's printed to stdout via CAP_DEEPLINK_RESPONSE:?
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/raycast/src/recording-status.tsx
Line: 3:7
Comment:
**logic:** `GetStatus` action prints response to stdout but Raycast extension doesn't capture it. The status is sent via deeplink but never displayed to the user.
```suggestion
import { showHUD } from "@raycast/api";
import { executeCapAction, createGetStatusAction, RecordingStatus } from "./utils";
export default async function Command() {
const status = await getRecordingStatus();
if (status) {
const message = status.is_recording
? `Recording (${status.recording_mode})${status.is_paused ? ' - Paused' : ''}`
: 'Not recording';
await showHUD(message);
}
}
async function getRecordingStatus(): Promise<RecordingStatus | null> {
return null;
}
```
How should the Raycast extension receive the JSON response that's printed to stdout via `CAP_DEEPLINK_RESPONSE:`?
How can I resolve this? If you propose a fix, please make it concise.| DeepLinkAction::ListDevices => { | ||
| let devices = get_available_devices(); | ||
| let json = serde_json::to_string(&devices).map_err(|e| e.to_string())?; | ||
| println!("CAP_DEEPLINK_RESPONSE:{}", json); | ||
| Ok(()) |
There was a problem hiding this comment.
logic: ListDevices and GetStatus actions print JSON to stdout, but there's no mechanism in the Raycast extension to capture this output. The deeplink protocol is fire-and-forget via URL scheme, so the extension cannot receive the response. Should these actions use IPC commands instead of deeplinks, or is there a planned mechanism for capturing stdout from the deeplink handler?
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 238:242
Comment:
**logic:** `ListDevices` and `GetStatus` actions print JSON to stdout, but there's no mechanism in the Raycast extension to capture this output. The deeplink protocol is fire-and-forget via URL scheme, so the extension cannot receive the response. Should these actions use IPC commands instead of deeplinks, or is there a planned mechanism for capturing stdout from the deeplink handler?
How can I resolve this? If you propose a fix, please make it concise.| <Form.Dropdown.Item value="instant" title="Instant" icon={Icon.Video} /> | ||
| <Form.Dropdown.Item value="studio" title="Studio" icon={Icon.Camera} /> | ||
| </Form.Dropdown> | ||
| <Form.Description text="Tip: Run 'List Devices' command in Cap to see available screen and window names." /> |
There was a problem hiding this comment.
logic: References non-existent "List Devices" command. The package.json doesn't define a list-devices command, only the deeplink action exists.
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/raycast/src/start-recording.tsx
Line: 53:53
Comment:
**logic:** References non-existent "List Devices" command. The `package.json` doesn't define a `list-devices` command, only the deeplink action exists.
How can I resolve this? If you propose a fix, please make it concise.| value={targetName} | ||
| onChange={setTargetName} | ||
| /> | ||
| <Form.Description text="Tip: Run 'List Devices' command in Cap to see available screen and window names." /> |
There was a problem hiding this comment.
logic: References non-existent "List Devices" command. The package.json doesn't define a list-devices command.
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/raycast/src/take-screenshot.tsx
Line: 47:47
Comment:
**logic:** References non-existent "List Devices" command. The `package.json` doesn't define a `list-devices` command.
How can I resolve this? If you propose a fix, please make it concise.
extensions/raycast/package.json
Outdated
| @@ -0,0 +1,96 @@ | |||
| { | |||
| "$schema": "https://www.raycast.com/schemas/extension.json", | |||
| "name": "cap", | |||
There was a problem hiding this comment.
Since pnpm-workspace.yaml includes extensions/*, this package ends up in the pnpm workspace. The repo root already has a package named cap, so this duplicate name will likely break workspace resolution.
| "name": "cap", | |
| "name": "cap-raycast", |
| let state = app.state::<ArcLock<App>>(); | ||
| let app_state = state.read().await; | ||
| let status = if let Some(recording) = app_state.current_recording() { | ||
| let is_paused = recording.is_paused().await.unwrap_or(false); |
There was a problem hiding this comment.
unwrap_or(false) will silently hide failures from is_paused() and report an incorrect status. It might be better to surface the error through the deeplink response.
| let is_paused = recording.is_paused().await.unwrap_or(false); | |
| let is_paused = recording.is_paused().await.map_err(|e| e.to_string())?; |
| DeepLinkAction::ListDevices => { | ||
| let devices = get_available_devices(); | ||
| let json = serde_json::to_string(&devices).map_err(|e| e.to_string())?; | ||
| println!("CAP_DEEPLINK_RESPONSE:{}", json); |
There was a problem hiding this comment.
list_devices/get_status currently print the JSON response to stdout. When invoked via a URL scheme there usually isn’t a stdout consumer, so these responses may never be observable from Raycast. If you need Raycast to read the response, consider a transport it can actually access (clipboard, file, notification, or callback URL).
| }) | ||
| .collect(); | ||
|
|
||
| let microphones: Vec<String> = MicrophoneFeed::list().keys().cloned().collect(); |
There was a problem hiding this comment.
Microphone list ordering can be nondeterministic (map key iteration). Sorting makes the output stable.
| let microphones: Vec<String> = MicrophoneFeed::list().keys().cloned().collect(); | |
| let mut microphones: Vec<String> = MicrophoneFeed::list().keys().cloned().collect(); | |
| microphones.sort(); |
…sion - Switch deeplink GetStatus/ListDevices responses from stdout (println to an atomic JSON file written to the app data directory, enabling Raycast to read responses via file polling - Rewrite start-recording and take-screenshot commands to fetch available screens/windows from Cap via ListDevices deeplink and present them in dropdowns instead of requiring manual text input - Implement recording-status command as a Detail view showing live recording state with contextual actions (pause/resume, stop, restart) - Add restart-recording Raycast command (Rust handler already existed) - Add executeCapActionWithResponse() utility for deeplink round-trip communication
| std::fs::rename(&temp_path, &response_path) | ||
| .map_err(|e| format!("Failed to rename response file: {e}"))?; |
There was a problem hiding this comment.
std::fs::rename fails on Windows if the destination already exists. Removing the old response file first keeps the atomic-write pattern working cross-platform.
| std::fs::rename(&temp_path, &response_path) | |
| .map_err(|e| format!("Failed to rename response file: {e}"))?; | |
| let _ = std::fs::remove_file(&response_path); | |
| std::fs::rename(&temp_path, &response_path) | |
| .map_err(|e| format!("Failed to rename response file: {e}"))?; |
extensions/raycast/src/utils.ts
Outdated
| return true; | ||
| } | ||
|
|
||
| function getResponseFilePath(): string | null { |
There was a problem hiding this comment.
getResponseFilePath() is unused now (and Raycast never writes the response file), so it looks like dead code that can be removed. Also, this repo generally avoids code comments—worth dropping the new // ... and JSDoc blocks here since the code reads fine without them.
| ? (devices?.screens ?? []) | ||
| : (devices?.windows ?? []); | ||
| if (newTargets.length > 0) { | ||
| setSelectedTarget(v === "screen" ? newTargets[0].name : newTargets[0].name); |
There was a problem hiding this comment.
This ternary is redundant.
| setSelectedTarget(v === "screen" ? newTargets[0].name : newTargets[0].name); | |
| setSelectedTarget(newTargets[0].name); |
Also: using window name as the deeplink selector can be ambiguous when multiple windows share the same title; if that happens, resolve_capture_target will pick the first match. If the underlying API exposes a stable window id, it might be worth plumbing that through the deeplink payload instead.
| fetchStatus(); | ||
| }, []); | ||
|
|
||
| const statusIcon = status?.is_recording |
There was a problem hiding this comment.
statusIcon is computed but never used, so this will trip unused-variable checks in stricter setups. Probably just delete it (or wire it into the UI if you intended to show it somewhere).
| export default async function Command() { | ||
| await executeCapAction(createRestartRecordingAction(), { | ||
| feedbackMessage: "Restarting recording...", | ||
| feedbackType: "hud", | ||
| }); | ||
| } |
There was a problem hiding this comment.
Formatting nit: this file is indented differently from the rest of the extension.
| export default async function Command() { | |
| await executeCapAction(createRestartRecordingAction(), { | |
| feedbackMessage: "Restarting recording...", | |
| feedbackType: "hud", | |
| }); | |
| } | |
| export default async function Command() { | |
| await executeCapAction(createRestartRecordingAction(), { | |
| feedbackMessage: "Restarting recording...", | |
| feedbackType: "hud", | |
| }); | |
| } |
- recording-status: replace markdown table with Detail.Metadata using colored TagList for status (red/yellow/grey), add ActionPanel.Section for recording controls, add keyboard shortcuts (⌘R refresh, ⌘P pause, ⌘⇧R restart, ⌫ stop) - switch-microphone: add List.Section separating disable/available, tinted icons (blue for devices, secondary for disable), accessories with ArrowRight tooltip, ⌘↩ shortcut, subtitle on disable item - switch-camera: same improvements as switch-microphone - start-recording: add Form.Separator between target and mode dropdowns, add info tooltip on Recording Mode explaining Instant vs Studio - take-screenshot: add info tooltip on Capture Type dropdown - stop-recording: add confirmAlert destructive confirmation before stopping
| onAction: () => { | ||
| open("https://cap.so/download"); | ||
| }, |
There was a problem hiding this comment.
Minor: since open() is async, making this handler async and awaiting it avoids floating promises in stricter lint setups.
| onAction: () => { | |
| open("https://cap.so/download"); | |
| }, | |
| onAction: async () => { | |
| await open("https://cap.so/download"); | |
| }, |
| /** Preferences accessible in the `switch-microphone` command */ | ||
| export type SwitchMicrophone = ExtensionPreferences & {} | ||
| /** Preferences accessible in the `switch-camera` command */ | ||
| export type SwitchCamera = ExtensionPreferences & {} |
There was a problem hiding this comment.
Repo convention here is to avoid code comments; can we keep the generated preference typings commentless?
| /** Preferences accessible in the `switch-microphone` command */ | |
| export type SwitchMicrophone = ExtensionPreferences & {} | |
| /** Preferences accessible in the `switch-camera` command */ | |
| export type SwitchCamera = ExtensionPreferences & {} | |
| export type SwitchMicrophone = ExtensionPreferences & {} | |
| export type SwitchCamera = ExtensionPreferences & {} |
| /** Arguments passed to the `switch-microphone` command */ | ||
| export type SwitchMicrophone = {} | ||
| /** Arguments passed to the `switch-camera` command */ | ||
| export type SwitchCamera = {} |
There was a problem hiding this comment.
Same here for the generated argument typings.
| /** Arguments passed to the `switch-microphone` command */ | |
| export type SwitchMicrophone = {} | |
| /** Arguments passed to the `switch-camera` command */ | |
| export type SwitchCamera = {} | |
| export type SwitchMicrophone = {} | |
| export type SwitchCamera = {} |
…in Raycast extension
- deeplink_actions.rs: fix inverted domain match in try_from that caused
all cap-desktop://action URLs to silently fail
- deeplink_actions.rs: add DeepLinkStartRecordingResult response type;
write success/error response for StartRecording so Raycast can surface
auth and upgrade errors instead of silently failing
- utils.ts: fix unit variant creators sending {} instead of null (affects
list_devices, get_status, stop/pause/resume/toggle/restart recording)
- utils.ts: fix DeviceOrModelID key casing to PascalCase (DeviceID/ModelID)
to match Rust serde output
- utils.ts: use open -g in executeCapActionWithResponse to open deeplinks
in the background, preventing Cap from stealing focus from Raycast
- utils.ts: add closeWindow option to executeCapAction; default true,
pass false from recording-status to keep Raycast open during controls
- utils.ts: increase executeCapActionWithResponse timeout from 3s to 6s
- switch-camera.tsx: fix camera selection passing { device } instead of
{ DeviceID }
- start-recording.tsx: use executeCapActionWithResponse to get result and
show toast on auth failure (instant mode requires Cap sign-in)
- recording-status.tsx: add closeWindow: false to all control actions
- recording-status.tsx: replace reserved cmd+P shortcut with cmd+shift+P
extensions/raycast/src/utils.ts
Outdated
| @@ -0,0 +1,279 @@ | |||
| import { execSync } from "node:child_process"; | |||
There was a problem hiding this comment.
Using execSync with an interpolated shell string makes this vulnerable to quoting issues (e.g. window titles containing ') and potential shell injection. Prefer execFileSync with args.
| import { execSync } from "node:child_process"; | |
| import { execFileSync } from "node:child_process"; |
extensions/raycast/src/utils.ts
Outdated
| const encodedValue = encodeURIComponent(jsonValue); | ||
| const url = `${CAP_URL_SCHEME}://action?value=${encodedValue}`; | ||
|
|
||
| execSync(`open -g '${url}'`); |
There was a problem hiding this comment.
Same here: avoid shell-string execution. Also consider handling failures so the command doesn't throw when open fails.
| execSync(`open -g '${url}'`); | |
| try { | |
| execFileSync("open", ["-g", url], { stdio: "ignore" }); | |
| } catch { | |
| return null; | |
| } |
| error: Some("A Cap upgrade is required to use this feature".to_string()), | ||
| }) | ||
| } | ||
| Err(e) => { |
There was a problem hiding this comment.
Minor: this currently turns start_recording errors into an Ok(()) as long as writing the response succeeds. If other deeplink callers rely on the Result for logging/handling, it might be better to preserve the error after writing the response.
| Err(e) => { | |
| Err(error) => { | |
| let error_message = error.clone(); | |
| write_deeplink_response( | |
| app, | |
| &DeepLinkStartRecordingResult { | |
| success: false, | |
| error: Some(error_message), | |
| }, | |
| )?; | |
| Err(error) | |
| } |
| <Action | ||
| title="Retry" | ||
| icon={Icon.RotateClockwise} | ||
| onAction={() => {}} |
There was a problem hiding this comment.
This Retry action is currently a no-op (onAction={() => {}}). Might be worth either wiring it to re-run device discovery (so the empty state can recover without reopening the command), or removing the action until it does something.
|
|
||
| function handleOpenCap() { | ||
| import("node:child_process").then(({ execFileSync }) => { | ||
| execFileSync("open", [CAP_URL_SCHEME]); |
There was a problem hiding this comment.
open probably needs a URL or -b <bundleId> here; passing CAP_URL_SCHEME without :// seems likely to fail and execFileSync will throw.
| execFileSync("open", [CAP_URL_SCHEME]); | |
| try { | |
| execFileSync("open", [`${CAP_URL_SCHEME}://`], { stdio: "ignore" }); | |
| } catch {} |
/claim #1540
Fixes #1540
Here is a demo video @richiemcilroy @Brendonovich
Demo
Cap.2026-02-22.at.07.19.02.mp4
Greptile Summary
Extends Cap's deeplink API with 8 new actions for recording control and adds a functional Raycast extension for controlling Cap from Raycast.
Major Changes:
pause_recording,resume_recording,toggle_pause_recording,restart_recording,take_screenshot,set_microphone,set_camera,list_devices,get_statusresolve_capture_target()helperextensions/raycast/with 8 commandsextensions/*to pnpm workspace configurationIssues Found:
GetStatusandListDevicesactions print JSON responses to stdout viaCAP_DEEPLINK_RESPONSE:prefix, but the Raycast extension has no mechanism to capture this output since deeplinks are fire-and-forget URL scheme callsrecording-statuscommand triggers the action but cannot display the actual status datapackage.json)Confidence Score: 3/5
GetStatusandListDevicesactions are incomplete as their responses cannot be received by the Raycast extension through the current deeplink architectureapps/desktop/src-tauri/src/deeplink_actions.rs(stdout response mechanism) andextensions/raycast/src/recording-status.tsx(incomplete implementation)Important Files Changed
Sequence Diagram
sequenceDiagram participant R as Raycast Extension participant OS as macOS URL Handler participant C as Cap Desktop App participant DR as Deeplink Router participant RA as Recording Actions Note over R,RA: Start Recording Flow R->>R: User fills form (screen/window, mode) R->>R: Create JSON action payload R->>OS: Open cap-desktop://action?value=<encoded-json> OS->>C: Route deeplink URL C->>DR: handle(url) DR->>DR: Parse URL & deserialize action DR->>RA: execute(StartRecording) RA->>RA: set_camera_input() RA->>RA: set_mic_input() RA->>RA: resolve_capture_target() RA->>RA: start_recording() R->>R: Show HUD "Starting recording..." Note over R,RA: Control Recording Flow R->>OS: Open cap-desktop://action?value={"pause_recording":{}} OS->>C: Route deeplink C->>DR: handle(url) DR->>RA: execute(PauseRecording) RA->>RA: pause_recording() R->>R: Show HUD "Pausing recording..." Note over R,RA: Query Status Flow (Current Issue) R->>OS: Open cap-desktop://action?value={"get_status":{}} OS->>C: Route deeplink C->>DR: handle(url) DR->>RA: execute(GetStatus) RA->>RA: Read app state RA->>RA: println!("CAP_DEEPLINK_RESPONSE:{json}") Note over R,RA: Response printed to stdout but not captured by Raycast R->>R: Show HUD (no actual status data) Note over R,RA: Screenshot Flow R->>R: User selects screen/window R->>OS: Open cap-desktop://action?value={"take_screenshot":{...}} OS->>C: Route deeplink C->>DR: handle(url) DR->>RA: execute(TakeScreenshot) RA->>RA: resolve_capture_target() RA->>RA: take_screenshot() R->>R: Show HUD "Taking screenshot..."