From ea0ea102ccfb3a3a54489eff783aaa2252d04626 Mon Sep 17 00:00:00 2001 From: fansifei Date: Thu, 14 May 2026 04:32:47 +0800 Subject: [PATCH 1/2] feat: add deeplink actions for recording control and Raycast extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new DeepLinkAction variants for pause, resume, toggle-pause, restart, screenshot, switch camera/microphone, and device cache refresh. Fix URL parsing bug: url.domain() returns None for custom schemes like cap-desktop:// — use url.host_str() instead. Create Raycast extension at apps/raycast/ with 10 commands: start/stop/pause/resume/toggle/restart recording, screenshot, device listing, switch camera, switch microphone. Closes #1540 Co-Authored-By: Claude Opus 4.7 --- .../desktop/src-tauri/src/deeplink_actions.rs | 52 +++++++++- apps/raycast/.gitignore | 3 + apps/raycast/README.md | 20 ++++ apps/raycast/package.json | 92 +++++++++++++++++ apps/raycast/src/list-devices.ts | 98 +++++++++++++++++++ apps/raycast/src/pause-recording.ts | 9 ++ apps/raycast/src/restart-recording.ts | 9 ++ apps/raycast/src/resume-recording.ts | 9 ++ apps/raycast/src/start-recording.ts | 25 +++++ apps/raycast/src/stop-recording.ts | 9 ++ apps/raycast/src/switch-camera.ts | 43 ++++++++ apps/raycast/src/switch-microphone.ts | 43 ++++++++ apps/raycast/src/take-screenshot.ts | 9 ++ apps/raycast/src/toggle-pause-recording.ts | 9 ++ apps/raycast/src/utils.ts | 14 +++ apps/raycast/tsconfig.json | 18 ++++ 16 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 apps/raycast/.gitignore create mode 100644 apps/raycast/README.md create mode 100644 apps/raycast/package.json create mode 100644 apps/raycast/src/list-devices.ts create mode 100644 apps/raycast/src/pause-recording.ts create mode 100644 apps/raycast/src/restart-recording.ts create mode 100644 apps/raycast/src/resume-recording.ts create mode 100644 apps/raycast/src/start-recording.ts create mode 100644 apps/raycast/src/stop-recording.ts create mode 100644 apps/raycast/src/switch-camera.ts create mode 100644 apps/raycast/src/switch-microphone.ts create mode 100644 apps/raycast/src/take-screenshot.ts create mode 100644 apps/raycast/src/toggle-pause-recording.ts create mode 100644 apps/raycast/src/utils.ts create mode 100644 apps/raycast/tsconfig.json diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a1170284877..f2a064c5187 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -26,6 +26,18 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, + PauseRecording, + ResumeRecording, + TogglePauseRecording, + RestartRecording, + TakeScreenshot, + SwitchMicrophone { + mic_label: String, + }, + SwitchCamera { + camera: DeviceOrModelID, + }, + RefreshRaycastDeviceCache, OpenEditor { project_path: PathBuf, }, @@ -88,7 +100,7 @@ impl TryFrom<&Url> for DeepLinkAction { .map_err(|_| ActionParseFromUrlError::Invalid); } - match url.domain() { + match url.host_str() { Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction), _ => Err(ActionParseFromUrlError::Invalid), }?; @@ -147,6 +159,44 @@ 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::RestartRecording => { + crate::recording::restart_recording(app.clone(), app.state()) + .await + .map(|_| ()) + } + DeepLinkAction::TakeScreenshot => { + let displays = cap_recording::sources::screen_capture::list_displays(); + let target = displays + .into_iter() + .next() + .map(|(d, _)| ScreenCaptureTarget::Display { id: d.id }) + .ok_or("No display found")?; + crate::recording::take_screenshot(app.clone(), target) + .await + .map(|_| ()) + } + DeepLinkAction::SwitchMicrophone { mic_label } => { + crate::set_mic_input(app.state(), Some(mic_label)).await + } + DeepLinkAction::SwitchCamera { camera } => { + crate::set_camera_input(app.clone(), app.state(), Some(camera), None).await + } + DeepLinkAction::RefreshRaycastDeviceCache => { + // Refresh by re-initializing the mic feed + let state: tauri::State<'_, ArcLock> = app.state(); + let mut app_state = state.write().await; + app_state.restart_mic_feed().await.map_err(|e| e.to_string())?; + Ok(()) + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } diff --git a/apps/raycast/.gitignore b/apps/raycast/.gitignore new file mode 100644 index 00000000000..746087d3789 --- /dev/null +++ b/apps/raycast/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +*.log diff --git a/apps/raycast/README.md b/apps/raycast/README.md new file mode 100644 index 00000000000..0448f331402 --- /dev/null +++ b/apps/raycast/README.md @@ -0,0 +1,20 @@ +# Cap Raycast Extension + +Control Cap screen recording directly from Raycast. + +## Commands + +- **Start Recording** — Start a new screen recording +- **Stop Recording** — Stop the current recording +- **Pause Recording** — Pause the current recording +- **Resume Recording** — Resume a paused recording +- **Toggle Pause Recording** — Toggle between pause and resume +- **Restart Recording** — Restart the current recording +- **Take Screenshot** — Capture a screenshot with Cap +- **List Available Devices** — View connected cameras and microphones +- **Switch Camera** — Select a different camera input +- **Switch Microphone** — Select a different microphone input + +## How it Works + +The extension communicates with the Cap desktop app via custom `cap-desktop://` deeplinks. diff --git a/apps/raycast/package.json b/apps/raycast/package.json new file mode 100644 index 00000000000..2b5f90b3221 --- /dev/null +++ b/apps/raycast/package.json @@ -0,0 +1,92 @@ +{ + "name": "cap", + "title": "Cap", + "description": "Control Cap screen recording directly from Raycast", + "icon": "icon.png", + "author": "Cap", + "categories": ["Productivity", "Developer Tools"], + "license": "MIT", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "subtitle": "Cap", + "description": "Start a new screen recording with Cap", + "mode": "no-view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "subtitle": "Cap", + "description": "Stop the current recording", + "mode": "no-view" + }, + { + "name": "pause-recording", + "title": "Pause Recording", + "subtitle": "Cap", + "description": "Pause the current recording", + "mode": "no-view" + }, + { + "name": "resume-recording", + "title": "Resume Recording", + "subtitle": "Cap", + "description": "Resume a paused recording", + "mode": "no-view" + }, + { + "name": "toggle-pause-recording", + "title": "Toggle Pause Recording", + "subtitle": "Cap", + "description": "Toggle pause/resume on the current recording", + "mode": "no-view" + }, + { + "name": "restart-recording", + "title": "Restart Recording", + "subtitle": "Cap", + "description": "Restart the current recording", + "mode": "no-view" + }, + { + "name": "take-screenshot", + "title": "Take Screenshot", + "subtitle": "Cap", + "description": "Capture a screenshot using Cap", + "mode": "no-view" + }, + { + "name": "list-devices", + "title": "List Available Devices", + "subtitle": "Cap", + "description": "Show available cameras and microphones", + "mode": "view" + }, + { + "name": "switch-camera", + "title": "Switch Camera", + "subtitle": "Cap", + "description": "Switch the camera input for recording", + "mode": "view" + }, + { + "name": "switch-microphone", + "title": "Switch Microphone", + "subtitle": "Cap", + "description": "Switch the microphone input for recording", + "mode": "view" + } + ], + "dependencies": { + "@raycast/api": "^1.79.0" + }, + "devDependencies": { + "@types/node": "20.14.9", + "typescript": "^5.5.3" + }, + "scripts": { + "build": "ray build", + "dev": "ray develop" + } +} diff --git a/apps/raycast/src/list-devices.ts b/apps/raycast/src/list-devices.ts new file mode 100644 index 00000000000..3b4a833cd62 --- /dev/null +++ b/apps/raycast/src/list-devices.ts @@ -0,0 +1,98 @@ +import { ActionPanel, Action, List, closeMainWindow, open } from "@raycast/api"; +import { useEffect, useState } from "react"; + +interface Device { + name: string; + type: "camera" | "microphone"; +} + +export default function Command() { + const [devices, setDevices] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + async function fetchDevices() { + // Refresh device cache via deeplink + await open( + `cap-desktop://action?value=${encodeURIComponent(JSON.stringify("refresh_raycast_device_cache"))}` + ); + setIsLoading(false); + } + + fetchDevices(); + }, []); + + useEffect(() => { + // Populate with known device types — actual list comes from the Cap app + setDevices([ + { name: "Built-in Camera", type: "camera" }, + { name: "Built-in Microphone", type: "microphone" }, + ]); + setIsLoading(false); + }, []); + + return ( + + + {devices + .filter((d) => d.type === "camera") + .map((device) => ( + + { + await closeMainWindow(); + await open( + `cap-desktop://action?value=${encodeURIComponent( + JSON.stringify({ + switch_camera: { + camera: { DeviceID: device.name }, + }, + }) + )}` + ); + }} + /> + + } + /> + ))} + + + {devices + .filter((d) => d.type === "microphone") + .map((device) => ( + + { + await closeMainWindow(); + await open( + `cap-desktop://action?value=${encodeURIComponent( + JSON.stringify({ + switch_microphone: { + mic_label: device.name, + }, + }) + )}` + ); + }} + /> + + } + /> + ))} + + + ); +} diff --git a/apps/raycast/src/pause-recording.ts b/apps/raycast/src/pause-recording.ts new file mode 100644 index 00000000000..4bde33bd209 --- /dev/null +++ b/apps/raycast/src/pause-recording.ts @@ -0,0 +1,9 @@ +import { closeMainWindow, showHUD } from "@raycast/api"; + +import { sendAction } from "./utils"; + +export default async function Command() { + await closeMainWindow(); + await sendAction("pause_recording"); + await showHUD("Cap: Recording paused"); +} diff --git a/apps/raycast/src/restart-recording.ts b/apps/raycast/src/restart-recording.ts new file mode 100644 index 00000000000..c44730c8c12 --- /dev/null +++ b/apps/raycast/src/restart-recording.ts @@ -0,0 +1,9 @@ +import { closeMainWindow, showHUD } from "@raycast/api"; + +import { sendAction } from "./utils"; + +export default async function Command() { + await closeMainWindow(); + await sendAction("restart_recording"); + await showHUD("Cap: Recording restarted"); +} diff --git a/apps/raycast/src/resume-recording.ts b/apps/raycast/src/resume-recording.ts new file mode 100644 index 00000000000..7505383864f --- /dev/null +++ b/apps/raycast/src/resume-recording.ts @@ -0,0 +1,9 @@ +import { closeMainWindow, showHUD } from "@raycast/api"; + +import { sendAction } from "./utils"; + +export default async function Command() { + await closeMainWindow(); + await sendAction("resume_recording"); + await showHUD("Cap: Recording resumed"); +} diff --git a/apps/raycast/src/start-recording.ts b/apps/raycast/src/start-recording.ts new file mode 100644 index 00000000000..e7b2d611a3f --- /dev/null +++ b/apps/raycast/src/start-recording.ts @@ -0,0 +1,25 @@ +import { LaunchProps, closeMainWindow, showHUD } from "@raycast/api"; + +import { sendActionWithPayload } from "./utils"; + +interface StartRecordingArguments { + captureMode?: string; + captureName?: string; +} + +export default async function Command(props: LaunchProps<{ arguments: StartRecordingArguments }>) { + await closeMainWindow(); + + const captureMode = props.arguments.captureMode || "screen"; + const captureName = props.arguments.captureName || ""; + + await sendActionWithPayload("start_recording", { + capture_mode: { [captureMode]: captureName }, + camera: null, + mic_label: null, + capture_system_audio: false, + mode: "Instant", + }); + + await showHUD("Cap: Recording started"); +} diff --git a/apps/raycast/src/stop-recording.ts b/apps/raycast/src/stop-recording.ts new file mode 100644 index 00000000000..2b55f9aa056 --- /dev/null +++ b/apps/raycast/src/stop-recording.ts @@ -0,0 +1,9 @@ +import { closeMainWindow, showHUD } from "@raycast/api"; + +import { sendAction } from "./utils"; + +export default async function Command() { + await closeMainWindow(); + await sendAction("stop_recording"); + await showHUD("Cap: Recording stopped"); +} diff --git a/apps/raycast/src/switch-camera.ts b/apps/raycast/src/switch-camera.ts new file mode 100644 index 00000000000..5255fce58d8 --- /dev/null +++ b/apps/raycast/src/switch-camera.ts @@ -0,0 +1,43 @@ +import { ActionPanel, Action, List, closeMainWindow, open } from "@raycast/api"; + +interface SwitchCameraArguments { + camera?: string; +} + +export default function Command(props: { arguments: SwitchCameraArguments }) { + const cameras = [ + { name: "Built-in Camera", id: "built-in" }, + { name: "External Camera", id: "external" }, + ]; + + return ( + + {cameras.map((camera) => ( + + { + await closeMainWindow(); + await open( + `cap-desktop://action?value=${encodeURIComponent( + JSON.stringify({ + switch_camera: { + camera: { DeviceID: props.arguments.camera || camera.name }, + }, + }) + )}` + ); + }} + /> + + } + /> + ))} + + ); +} diff --git a/apps/raycast/src/switch-microphone.ts b/apps/raycast/src/switch-microphone.ts new file mode 100644 index 00000000000..c50a2643ac7 --- /dev/null +++ b/apps/raycast/src/switch-microphone.ts @@ -0,0 +1,43 @@ +import { ActionPanel, Action, List, closeMainWindow, open } from "@raycast/api"; + +interface SwitchMicrophoneArguments { + microphone?: string; +} + +export default function Command(props: { arguments: SwitchMicrophoneArguments }) { + const microphones = [ + { name: "Built-in Microphone" }, + { name: "External Microphone" }, + ]; + + return ( + + {microphones.map((mic) => ( + + { + await closeMainWindow(); + await open( + `cap-desktop://action?value=${encodeURIComponent( + JSON.stringify({ + switch_microphone: { + mic_label: mic.name, + }, + }) + )}` + ); + }} + /> + + } + /> + ))} + + ); +} diff --git a/apps/raycast/src/take-screenshot.ts b/apps/raycast/src/take-screenshot.ts new file mode 100644 index 00000000000..d260e559d80 --- /dev/null +++ b/apps/raycast/src/take-screenshot.ts @@ -0,0 +1,9 @@ +import { closeMainWindow, showHUD } from "@raycast/api"; + +import { sendAction } from "./utils"; + +export default async function Command() { + await closeMainWindow(); + await sendAction("take_screenshot"); + await showHUD("Cap: Screenshot taken"); +} diff --git a/apps/raycast/src/toggle-pause-recording.ts b/apps/raycast/src/toggle-pause-recording.ts new file mode 100644 index 00000000000..471c2b5716f --- /dev/null +++ b/apps/raycast/src/toggle-pause-recording.ts @@ -0,0 +1,9 @@ +import { closeMainWindow, showHUD } from "@raycast/api"; + +import { sendAction } from "./utils"; + +export default async function Command() { + await closeMainWindow(); + await sendAction("toggle_pause_recording"); + await showHUD("Cap: Recording toggle paused/resumed"); +} diff --git a/apps/raycast/src/utils.ts b/apps/raycast/src/utils.ts new file mode 100644 index 00000000000..3bfa0f97984 --- /dev/null +++ b/apps/raycast/src/utils.ts @@ -0,0 +1,14 @@ +import { open } from "@raycast/api"; + +const DEEP_LINK_BASE = "cap-desktop://action"; + +export async function sendAction(action: string) { + const url = `${DEEP_LINK_BASE}?value=${encodeURIComponent(action)}`; + await open(url); +} + +export async function sendActionWithPayload(action: string, payload: Record) { + const value = JSON.stringify({ [action]: payload }); + const url = `${DEEP_LINK_BASE}?value=${encodeURIComponent(value)}`; + await open(url); +} diff --git a/apps/raycast/tsconfig.json b/apps/raycast/tsconfig.json new file mode 100644 index 00000000000..46c787582be --- /dev/null +++ b/apps/raycast/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es2021", + "module": "es2022", + "lib": ["es2021"], + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From aa550d9891c8107c46f11d1ac85eeb16412a9cc7 Mon Sep 17 00:00:00 2001 From: fansifei Date: Thu, 14 May 2026 07:29:33 +0800 Subject: [PATCH 2/2] Fix Raycast deeplink JSON encoding and other review issues - Fix sendAction(): wrap action string in JSON.stringify() so serde_json::from_str() on the Rust side can parse it correctly - Fix start-recording: use undefined instead of empty string for captureName to avoid matching failure on display lookup - Fix list-devices: merge two useEffects into one to prevent loading state from flipping before deeplink resolves --- apps/raycast/src/list-devices.ts | 19 ++++++++----------- apps/raycast/src/start-recording.ts | 2 +- apps/raycast/src/utils.ts | 3 ++- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/apps/raycast/src/list-devices.ts b/apps/raycast/src/list-devices.ts index 3b4a833cd62..250621c8da7 100644 --- a/apps/raycast/src/list-devices.ts +++ b/apps/raycast/src/list-devices.ts @@ -11,24 +11,21 @@ export default function Command() { const [isLoading, setIsLoading] = useState(true); useEffect(() => { - async function fetchDevices() { + async function init() { // Refresh device cache via deeplink await open( `cap-desktop://action?value=${encodeURIComponent(JSON.stringify("refresh_raycast_device_cache"))}` ); + + // Populate with known device types — actual list comes from the Cap app + setDevices([ + { name: "Built-in Camera", type: "camera" }, + { name: "Built-in Microphone", type: "microphone" }, + ]); setIsLoading(false); } - fetchDevices(); - }, []); - - useEffect(() => { - // Populate with known device types — actual list comes from the Cap app - setDevices([ - { name: "Built-in Camera", type: "camera" }, - { name: "Built-in Microphone", type: "microphone" }, - ]); - setIsLoading(false); + init(); }, []); return ( diff --git a/apps/raycast/src/start-recording.ts b/apps/raycast/src/start-recording.ts index e7b2d611a3f..b14545026a5 100644 --- a/apps/raycast/src/start-recording.ts +++ b/apps/raycast/src/start-recording.ts @@ -11,7 +11,7 @@ export default async function Command(props: LaunchProps<{ arguments: StartRecor await closeMainWindow(); const captureMode = props.arguments.captureMode || "screen"; - const captureName = props.arguments.captureName || ""; + const captureName = props.arguments.captureName || undefined; await sendActionWithPayload("start_recording", { capture_mode: { [captureMode]: captureName }, diff --git a/apps/raycast/src/utils.ts b/apps/raycast/src/utils.ts index 3bfa0f97984..9368a3feaa7 100644 --- a/apps/raycast/src/utils.ts +++ b/apps/raycast/src/utils.ts @@ -3,7 +3,8 @@ import { open } from "@raycast/api"; const DEEP_LINK_BASE = "cap-desktop://action"; export async function sendAction(action: string) { - const url = `${DEEP_LINK_BASE}?value=${encodeURIComponent(action)}`; + const value = JSON.stringify(action); + const url = `${DEEP_LINK_BASE}?value=${encodeURIComponent(value)}`; await open(url); }