From 8e6e4c95da0e42e52fbda792102bd6a1fb003921 Mon Sep 17 00:00:00 2001 From: buildingvibes Date: Sun, 8 Feb 2026 21:31:42 -0800 Subject: [PATCH 1/7] feat: add recording control deeplinks and Raycast extension (#1540) Extend the existing deeplink system with new actions for controlling recordings and device switching, then build a Raycast extension that uses those deeplinks for hands-free Cap control. New deeplink actions added to DeepLinkAction enum: - PauseRecording, ResumeRecording, TogglePauseRecording - SetCamera (switch or disable camera during/before recording) - SetMicrophone (switch or disable microphone during/before recording) - TakeScreenshot (capture a screenshot of a specified display/window) Also refactored the capture target resolution into a shared resolve_capture_target helper to reduce duplication. Raycast extension (raycast/) provides commands for: - Start/Stop/Pause/Resume/Toggle-pause recording - Switch microphone (lists system audio input devices) - Switch camera (lists system cameras) - Take screenshot - Open settings --- .../desktop/src-tauri/src/deeplink_actions.rs | 64 ++++++++--- raycast/.gitignore | 2 + raycast/assets/command-icon.png | Bin 0 -> 7543 bytes raycast/package.json | 93 ++++++++++++++++ raycast/src/open-settings.ts | 12 +++ raycast/src/pause-recording.ts | 5 + raycast/src/resume-recording.ts | 5 + raycast/src/start-recording.ts | 16 +++ raycast/src/stop-recording.ts | 5 + raycast/src/switch-camera.tsx | 79 ++++++++++++++ raycast/src/switch-microphone.tsx | 100 ++++++++++++++++++ raycast/src/take-screenshot.ts | 12 +++ raycast/src/toggle-pause-recording.ts | 5 + raycast/src/utils.ts | 17 +++ raycast/tsconfig.json | 17 +++ 15 files changed, 419 insertions(+), 13 deletions(-) create mode 100644 raycast/.gitignore create mode 100644 raycast/assets/command-icon.png create mode 100644 raycast/package.json create mode 100644 raycast/src/open-settings.ts create mode 100644 raycast/src/pause-recording.ts create mode 100644 raycast/src/resume-recording.ts create mode 100644 raycast/src/start-recording.ts create mode 100644 raycast/src/stop-recording.ts create mode 100644 raycast/src/switch-camera.tsx create mode 100644 raycast/src/switch-microphone.tsx create mode 100644 raycast/src/take-screenshot.ts create mode 100644 raycast/src/toggle-pause-recording.ts create mode 100644 raycast/src/utils.ts create mode 100644 raycast/tsconfig.json diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index fce75b4a84..35a4640dac 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, + SetCamera { + camera: Option, + }, + SetMicrophone { + mic_label: Option, + }, + TakeScreenshot { + capture_mode: CaptureMode, + }, OpenEditor { project_path: PathBuf, }, @@ -49,7 +61,6 @@ pub fn handle(app_handle: &AppHandle, urls: Vec) { ActionParseFromUrlError::Invalid => { eprintln!("Invalid deep link format \"{}\"", &url) } - // Likely login action, not handled here. ActionParseFromUrlError::NotAction => {} }) .ok() @@ -104,6 +115,21 @@ impl TryFrom<&Url> for DeepLinkAction { } } +fn resolve_capture_target(capture_mode: &CaptureMode) -> Result { + match capture_mode { + CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays() + .into_iter() + .find(|(s, _)| s.name == *name) + .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id }) + .ok_or(format!("No screen with name \"{}\"", name)), + CaptureMode::Window(name) => cap_recording::screen_capture::list_windows() + .into_iter() + .find(|(w, _)| w.name == *name) + .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) + .ok_or(format!("No window with name \"{}\"", name)), + } +} + impl DeepLinkAction { pub async fn execute(self, app: &AppHandle) -> Result<(), String> { match self { @@ -119,18 +145,7 @@ impl DeepLinkAction { crate::set_camera_input(app.clone(), state.clone(), camera, None).await?; crate::set_mic_input(state.clone(), mic_label).await?; - let capture_target: ScreenCaptureTarget = match capture_mode { - CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays() - .into_iter() - .find(|(s, _)| s.name == name) - .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id }) - .ok_or(format!("No screen with name \"{}\"", &name))?, - CaptureMode::Window(name) => cap_recording::screen_capture::list_windows() - .into_iter() - .find(|(w, _)| w.name == name) - .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) - .ok_or(format!("No window with name \"{}\"", &name))?, - }; + let capture_target = resolve_capture_target(&capture_mode)?; let inputs = StartRecordingInputs { mode, @@ -146,6 +161,29 @@ 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::SetCamera { camera } => { + let state = app.state::>(); + crate::set_camera_input(app.clone(), state, camera, None).await + } + DeepLinkAction::SetMicrophone { mic_label } => { + let state = app.state::>(); + crate::set_mic_input(state, mic_label).await + } + DeepLinkAction::TakeScreenshot { capture_mode } => { + let capture_target = resolve_capture_target(&capture_mode)?; + crate::recording::take_screenshot(app.clone(), capture_target) + .await + .map(|_| ()) + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } diff --git a/raycast/.gitignore b/raycast/.gitignore new file mode 100644 index 0000000000..5d80e402f0 --- /dev/null +++ b/raycast/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.raycast/ diff --git a/raycast/assets/command-icon.png b/raycast/assets/command-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..72dd4dcd0795ebde571a500c9a9a4aaef00403cf GIT binary patch literal 7543 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 { + const cameras: string[] = []; + const lines = stdout.split("\n"); + for (const line of lines) { + if (line.match(/^\s{4}\S/) && line.includes(":")) { + const name = line.trim().replace(/:$/, ""); + if (name.length > 0 && name !== "Camera") { + cameras.push(name); + } + } + } + if (cameras.length === 0) { + cameras.push("FaceTime HD Camera"); + } + return cameras; + }, + }); + + const cameras = data ?? []; + + return ( + + + + + } + /> + {cameras.map((cam) => ( + + switchCamera(cam)} /> + + } + /> + ))} + + ); +} diff --git a/raycast/src/switch-microphone.tsx b/raycast/src/switch-microphone.tsx new file mode 100644 index 0000000000..e8cdf35dea --- /dev/null +++ b/raycast/src/switch-microphone.tsx @@ -0,0 +1,100 @@ +import { Action, ActionPanel, List, showHUD, open } from "@raycast/api"; +import { useExec } from "@raycast/utils"; + +const DEEPLINK_SCHEME = "cap-desktop"; + +function parseMicrophones(output: string): string[] { + const devices: string[] = []; + const lines = output.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length > 0 && trimmed !== "None") { + devices.push(trimmed); + } + } + return devices; +} + +async function switchMicrophone(label: string) { + const action = { set_microphone: { mic_label: label } }; + const encodedValue = encodeURIComponent(JSON.stringify(action)); + const url = `${DEEPLINK_SCHEME}://action?value=${encodedValue}`; + + try { + await open(url); + await showHUD(`Switching microphone to "${label}" in Cap`); + } catch { + await showHUD("Failed to communicate with Cap. Is Cap running?"); + } +} + +async function disableMicrophone() { + const action = { set_microphone: { mic_label: null } }; + const encodedValue = encodeURIComponent(JSON.stringify(action)); + const url = `${DEEPLINK_SCHEME}://action?value=${encodedValue}`; + + try { + await open(url); + await showHUD("Disabling microphone in Cap"); + } catch { + await showHUD("Failed to communicate with Cap. Is Cap running?"); + } +} + +export default function SwitchMicrophone() { + const { data, isLoading } = useExec("system_profiler", ["SPAudioDataType", "-detailLevel", "mini"], { + parseOutput: ({ stdout }) => { + const devices: string[] = []; + const lines = stdout.split("\n"); + let inInput = false; + for (const line of lines) { + if (line.includes("Input:")) { + inInput = true; + continue; + } + if (line.includes("Output:")) { + inInput = false; + continue; + } + if (inInput && line.match(/^\s{8}\S/)) { + const name = line.trim().replace(/:$/, ""); + if (name.length > 0) { + devices.push(name); + } + } + } + if (devices.length === 0) { + devices.push("MacBook Pro Microphone"); + } + return devices; + }, + }); + + const microphones = data ?? []; + + return ( + + + + + } + /> + {microphones.map((mic) => ( + + switchMicrophone(mic)} /> + + } + /> + ))} + + ); +} diff --git a/raycast/src/take-screenshot.ts b/raycast/src/take-screenshot.ts new file mode 100644 index 0000000000..b13093acee --- /dev/null +++ b/raycast/src/take-screenshot.ts @@ -0,0 +1,12 @@ +import { executeDeepLink } from "./utils"; + +export default async function TakeScreenshot() { + await executeDeepLink( + { + take_screenshot: { + capture_mode: { screen: "Main Display" }, + }, + }, + "Taking screenshot with Cap", + ); +} diff --git a/raycast/src/toggle-pause-recording.ts b/raycast/src/toggle-pause-recording.ts new file mode 100644 index 0000000000..c902d2bb5f --- /dev/null +++ b/raycast/src/toggle-pause-recording.ts @@ -0,0 +1,5 @@ +import { executeDeepLink } from "./utils"; + +export default async function TogglePauseRecording() { + await executeDeepLink("toggle_pause_recording", "Toggling pause on recording in Cap"); +} diff --git a/raycast/src/utils.ts b/raycast/src/utils.ts new file mode 100644 index 0000000000..b7c808d764 --- /dev/null +++ b/raycast/src/utils.ts @@ -0,0 +1,17 @@ +import { open, showHUD } from "@raycast/api"; + +const DEEPLINK_SCHEME = "cap-desktop"; + +type DeepLinkAction = string | Record; + +export async function executeDeepLink(action: DeepLinkAction, hudMessage: string) { + const encodedValue = encodeURIComponent(JSON.stringify(action)); + const url = `${DEEPLINK_SCHEME}://action?value=${encodedValue}`; + + try { + await open(url); + await showHUD(hudMessage); + } catch { + await showHUD("Failed to communicate with Cap. Is Cap running?"); + } +} diff --git a/raycast/tsconfig.json b/raycast/tsconfig.json new file mode 100644 index 0000000000..348d2522ec --- /dev/null +++ b/raycast/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://www.raycast.com/schemas/tsconfig.json", + "compilerOptions": { + "lib": ["ES2023"], + "module": "ES2022", + "target": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*", "env.d.ts"] +} From 5ed09bb7c7416a43d48d106402a441611a674f52 Mon Sep 17 00:00:00 2001 From: buildingvibes Date: Sun, 8 Feb 2026 21:41:23 -0800 Subject: [PATCH 2/7] fix: address review feedback for Raycast extension Remove unused parseMicrophones function and dynamically detect the primary display name instead of hardcoding "Main Display" so the deeplinks work on any machine regardless of display naming. --- raycast/src/start-recording.ts | 21 ++++++++++++++++++++- raycast/src/switch-microphone.tsx | 12 ------------ raycast/src/take-screenshot.ts | 21 ++++++++++++++++++++- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/raycast/src/start-recording.ts b/raycast/src/start-recording.ts index 697fbc8e90..63f7bee450 100644 --- a/raycast/src/start-recording.ts +++ b/raycast/src/start-recording.ts @@ -1,10 +1,29 @@ +import { execSync } from "child_process"; import { executeDeepLink } from "./utils"; +function getMainDisplayName(): string { + try { + const output = execSync("system_profiler SPDisplaysDataType -detailLevel mini", { + encoding: "utf-8", + }); + const lines = output.split("\n"); + for (const line of lines) { + const match = line.match(/^\s{8}(\S.*):$/); + if (match && match[1]) { + return match[1]; + } + } + } catch { + } + return "Main Display"; +} + export default async function StartRecording() { + const displayName = getMainDisplayName(); await executeDeepLink( { start_recording: { - capture_mode: { screen: "Main Display" }, + capture_mode: { screen: displayName }, camera: null, mic_label: null, capture_system_audio: false, diff --git a/raycast/src/switch-microphone.tsx b/raycast/src/switch-microphone.tsx index e8cdf35dea..1e59ab66d2 100644 --- a/raycast/src/switch-microphone.tsx +++ b/raycast/src/switch-microphone.tsx @@ -3,18 +3,6 @@ import { useExec } from "@raycast/utils"; const DEEPLINK_SCHEME = "cap-desktop"; -function parseMicrophones(output: string): string[] { - const devices: string[] = []; - const lines = output.split("\n"); - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.length > 0 && trimmed !== "None") { - devices.push(trimmed); - } - } - return devices; -} - async function switchMicrophone(label: string) { const action = { set_microphone: { mic_label: label } }; const encodedValue = encodeURIComponent(JSON.stringify(action)); diff --git a/raycast/src/take-screenshot.ts b/raycast/src/take-screenshot.ts index b13093acee..82b98ab272 100644 --- a/raycast/src/take-screenshot.ts +++ b/raycast/src/take-screenshot.ts @@ -1,10 +1,29 @@ +import { execSync } from "child_process"; import { executeDeepLink } from "./utils"; +function getMainDisplayName(): string { + try { + const output = execSync("system_profiler SPDisplaysDataType -detailLevel mini", { + encoding: "utf-8", + }); + const lines = output.split("\n"); + for (const line of lines) { + const match = line.match(/^\s{8}(\S.*):$/); + if (match && match[1]) { + return match[1]; + } + } + } catch { + } + return "Main Display"; +} + export default async function TakeScreenshot() { + const displayName = getMainDisplayName(); await executeDeepLink( { take_screenshot: { - capture_mode: { screen: "Main Display" }, + capture_mode: { screen: displayName }, }, }, "Taking screenshot with Cap", From 10d4a0165ce37d65b197098bb5eae93d6a477fe7 Mon Sep 17 00:00:00 2001 From: buildingvibes Date: Sun, 8 Feb 2026 21:48:06 -0800 Subject: [PATCH 3/7] fix: address PR review comments for deeplinks and Raycast extension - Use name.as_str() instead of *name deref in resolve_capture_target - Use ok_or_else for lazy error string allocation - Convert start-recording and take-screenshot to List views that enumerate displays via system_profiler instead of hardcoding - Update package.json command modes to "view" for display selection --- .../desktop/src-tauri/src/deeplink_actions.rs | 8 +-- raycast/package.json | 4 +- raycast/src/start-recording.ts | 35 ------------ raycast/src/start-recording.tsx | 57 +++++++++++++++++++ raycast/src/take-screenshot.ts | 31 ---------- raycast/src/take-screenshot.tsx | 53 +++++++++++++++++ 6 files changed, 116 insertions(+), 72 deletions(-) delete mode 100644 raycast/src/start-recording.ts create mode 100644 raycast/src/start-recording.tsx delete mode 100644 raycast/src/take-screenshot.ts create mode 100644 raycast/src/take-screenshot.tsx diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index 35a4640dac..f71fb5b4b0 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -119,14 +119,14 @@ fn resolve_capture_target(capture_mode: &CaptureMode) -> Result cap_recording::screen_capture::list_displays() .into_iter() - .find(|(s, _)| s.name == *name) + .find(|(s, _)| s.name == name.as_str()) .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id }) - .ok_or(format!("No screen with name \"{}\"", name)), + .ok_or_else(|| format!("No screen with name \"{}\"", name)), CaptureMode::Window(name) => cap_recording::screen_capture::list_windows() .into_iter() - .find(|(w, _)| w.name == *name) + .find(|(w, _)| w.name == name.as_str()) .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) - .ok_or(format!("No window with name \"{}\"", name)), + .ok_or_else(|| format!("No window with name \"{}\"", name)), } } diff --git a/raycast/package.json b/raycast/package.json index 93b75f3ac5..3202de3633 100644 --- a/raycast/package.json +++ b/raycast/package.json @@ -13,7 +13,7 @@ "title": "Start Recording", "subtitle": "Cap", "description": "Start a new screen recording in Cap", - "mode": "no-view" + "mode": "view" }, { "name": "stop-recording", @@ -62,7 +62,7 @@ "title": "Take Screenshot", "subtitle": "Cap", "description": "Take a screenshot with Cap", - "mode": "no-view" + "mode": "view" }, { "name": "open-settings", diff --git a/raycast/src/start-recording.ts b/raycast/src/start-recording.ts deleted file mode 100644 index 63f7bee450..0000000000 --- a/raycast/src/start-recording.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { execSync } from "child_process"; -import { executeDeepLink } from "./utils"; - -function getMainDisplayName(): string { - try { - const output = execSync("system_profiler SPDisplaysDataType -detailLevel mini", { - encoding: "utf-8", - }); - const lines = output.split("\n"); - for (const line of lines) { - const match = line.match(/^\s{8}(\S.*):$/); - if (match && match[1]) { - return match[1]; - } - } - } catch { - } - return "Main Display"; -} - -export default async function StartRecording() { - const displayName = getMainDisplayName(); - await executeDeepLink( - { - start_recording: { - capture_mode: { screen: displayName }, - camera: null, - mic_label: null, - capture_system_audio: false, - mode: "studio", - }, - }, - "Starting recording in Cap", - ); -} diff --git a/raycast/src/start-recording.tsx b/raycast/src/start-recording.tsx new file mode 100644 index 0000000000..94cb48c2bb --- /dev/null +++ b/raycast/src/start-recording.tsx @@ -0,0 +1,57 @@ +import { ActionPanel, Action, List } from "@raycast/api"; +import { useExec } from "@raycast/utils"; +import { executeDeepLink } from "./utils"; + +function parseDisplays(stdout: string): string[] { + const displays: string[] = []; + const lines = stdout.split("\n"); + for (const line of lines) { + const match = line.match(/^\s{8}(\S.*):$/); + if (match && match[1]) { + displays.push(match[1]); + } + } + if (displays.length === 0) { + displays.push("Main Display"); + } + return displays; +} + +async function startRecordingOnDisplay(displayName: string) { + await executeDeepLink( + { + start_recording: { + capture_mode: { screen: displayName }, + camera: null, + mic_label: null, + capture_system_audio: false, + mode: "studio", + }, + }, + `Starting recording on "${displayName}" in Cap`, + ); +} + +export default function StartRecording() { + const { data, isLoading } = useExec("system_profiler", ["SPDisplaysDataType", "-detailLevel", "mini"], { + parseOutput: ({ stdout }) => parseDisplays(stdout), + }); + + const displays = data ?? []; + + return ( + + {displays.map((display) => ( + + startRecordingOnDisplay(display)} /> + + } + /> + ))} + + ); +} diff --git a/raycast/src/take-screenshot.ts b/raycast/src/take-screenshot.ts deleted file mode 100644 index 82b98ab272..0000000000 --- a/raycast/src/take-screenshot.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { execSync } from "child_process"; -import { executeDeepLink } from "./utils"; - -function getMainDisplayName(): string { - try { - const output = execSync("system_profiler SPDisplaysDataType -detailLevel mini", { - encoding: "utf-8", - }); - const lines = output.split("\n"); - for (const line of lines) { - const match = line.match(/^\s{8}(\S.*):$/); - if (match && match[1]) { - return match[1]; - } - } - } catch { - } - return "Main Display"; -} - -export default async function TakeScreenshot() { - const displayName = getMainDisplayName(); - await executeDeepLink( - { - take_screenshot: { - capture_mode: { screen: displayName }, - }, - }, - "Taking screenshot with Cap", - ); -} diff --git a/raycast/src/take-screenshot.tsx b/raycast/src/take-screenshot.tsx new file mode 100644 index 0000000000..a68b1bc20b --- /dev/null +++ b/raycast/src/take-screenshot.tsx @@ -0,0 +1,53 @@ +import { ActionPanel, Action, List } from "@raycast/api"; +import { useExec } from "@raycast/utils"; +import { executeDeepLink } from "./utils"; + +function parseDisplays(stdout: string): string[] { + const displays: string[] = []; + const lines = stdout.split("\n"); + for (const line of lines) { + const match = line.match(/^\s{8}(\S.*):$/); + if (match && match[1]) { + displays.push(match[1]); + } + } + if (displays.length === 0) { + displays.push("Main Display"); + } + return displays; +} + +async function takeScreenshotOnDisplay(displayName: string) { + await executeDeepLink( + { + take_screenshot: { + capture_mode: { screen: displayName }, + }, + }, + `Taking screenshot of "${displayName}" with Cap`, + ); +} + +export default function TakeScreenshot() { + const { data, isLoading } = useExec("system_profiler", ["SPDisplaysDataType", "-detailLevel", "mini"], { + parseOutput: ({ stdout }) => parseDisplays(stdout), + }); + + const displays = data ?? []; + + return ( + + {displays.map((display) => ( + + takeScreenshotOnDisplay(display)} /> + + } + /> + ))} + + ); +} From 9155c0060d8440470ca2398e490e0c00b16625c2 Mon Sep 17 00:00:00 2001 From: buildingvibes Date: Sun, 8 Feb 2026 21:56:22 -0800 Subject: [PATCH 4/7] fix: address round 2 review feedback for Raycast extension --- raycast/src/switch-camera.tsx | 70 ++++++++++++++++++------------- raycast/src/switch-microphone.tsx | 33 +++++---------- 2 files changed, 51 insertions(+), 52 deletions(-) diff --git a/raycast/src/switch-camera.tsx b/raycast/src/switch-camera.tsx index 793a0b1449..ea2dc3f4fb 100644 --- a/raycast/src/switch-camera.tsx +++ b/raycast/src/switch-camera.tsx @@ -1,49 +1,61 @@ -import { Action, ActionPanel, List, showHUD, open } from "@raycast/api"; +import { Action, ActionPanel, List } from "@raycast/api"; import { useExec } from "@raycast/utils"; +import { executeDeepLink } from "./utils"; -const DEEPLINK_SCHEME = "cap-desktop"; - -async function switchCamera(name: string) { - const action = { set_camera: { camera: { DeviceID: name } } }; - const encodedValue = encodeURIComponent(JSON.stringify(action)); - const url = `${DEEPLINK_SCHEME}://action?value=${encodedValue}`; +interface Camera { + name: string; + uniqueId: string; +} - try { - await open(url); - await showHUD(`Switching camera to "${name}" in Cap`); - } catch { - await showHUD("Failed to communicate with Cap. Is Cap running?"); - } +async function switchCamera(camera: Camera) { + await executeDeepLink( + { set_camera: { camera: { DeviceID: camera.uniqueId } } }, + `Switching camera to "${camera.name}" in Cap`, + ); } async function disableCamera() { - const action = { set_camera: { camera: null } }; - const encodedValue = encodeURIComponent(JSON.stringify(action)); - const url = `${DEEPLINK_SCHEME}://action?value=${encodedValue}`; - - try { - await open(url); - await showHUD("Disabling camera in Cap"); - } catch { - await showHUD("Failed to communicate with Cap. Is Cap running?"); - } + await executeDeepLink( + { set_camera: { camera: null } }, + "Disabling camera in Cap", + ); } export default function SwitchCamera() { const { data, isLoading } = useExec("system_profiler", ["SPCameraDataType", "-detailLevel", "mini"], { parseOutput: ({ stdout }) => { - const cameras: string[] = []; + const cameras: Camera[] = []; const lines = stdout.split("\n"); + let currentName: string | null = null; + let currentUniqueId: string | null = null; + for (const line of lines) { if (line.match(/^\s{4}\S/) && line.includes(":")) { + if (currentName && currentUniqueId) { + cameras.push({ name: currentName, uniqueId: currentUniqueId }); + } const name = line.trim().replace(/:$/, ""); if (name.length > 0 && name !== "Camera") { - cameras.push(name); + currentName = name; + currentUniqueId = null; + } else { + currentName = null; + currentUniqueId = null; } } + + const uniqueIdMatch = line.match(/^\s+Unique ID:\s*(.+)/); + if (uniqueIdMatch && currentName) { + currentUniqueId = uniqueIdMatch[1].trim(); + } } + + if (currentName && currentUniqueId) { + cameras.push({ name: currentName, uniqueId: currentUniqueId }); + } + if (cameras.length === 0) { - cameras.push("FaceTime HD Camera"); + cameras.push({ name: "FaceTime HD Camera", uniqueId: "FaceTime HD Camera" }); } return cameras; }, @@ -65,11 +77,11 @@ export default function SwitchCamera() { /> {cameras.map((cam) => ( - switchCamera(cam)} /> + switchCamera(cam)} /> } /> diff --git a/raycast/src/switch-microphone.tsx b/raycast/src/switch-microphone.tsx index 1e59ab66d2..cb4196c96d 100644 --- a/raycast/src/switch-microphone.tsx +++ b/raycast/src/switch-microphone.tsx @@ -1,32 +1,19 @@ -import { Action, ActionPanel, List, showHUD, open } from "@raycast/api"; +import { Action, ActionPanel, List } from "@raycast/api"; import { useExec } from "@raycast/utils"; - -const DEEPLINK_SCHEME = "cap-desktop"; +import { executeDeepLink } from "./utils"; async function switchMicrophone(label: string) { - const action = { set_microphone: { mic_label: label } }; - const encodedValue = encodeURIComponent(JSON.stringify(action)); - const url = `${DEEPLINK_SCHEME}://action?value=${encodedValue}`; - - try { - await open(url); - await showHUD(`Switching microphone to "${label}" in Cap`); - } catch { - await showHUD("Failed to communicate with Cap. Is Cap running?"); - } + await executeDeepLink( + { set_microphone: { mic_label: label } }, + `Switching microphone to "${label}" in Cap`, + ); } async function disableMicrophone() { - const action = { set_microphone: { mic_label: null } }; - const encodedValue = encodeURIComponent(JSON.stringify(action)); - const url = `${DEEPLINK_SCHEME}://action?value=${encodedValue}`; - - try { - await open(url); - await showHUD("Disabling microphone in Cap"); - } catch { - await showHUD("Failed to communicate with Cap. Is Cap running?"); - } + await executeDeepLink( + { set_microphone: { mic_label: null } }, + "Disabling microphone in Cap", + ); } export default function SwitchMicrophone() { From 3d8de3ea87ab403af5f3e03ff7d15a753af7e02e Mon Sep 17 00:00:00 2001 From: buildingvibes Date: Sun, 8 Feb 2026 22:06:08 -0800 Subject: [PATCH 5/7] fix: remove misleading fallback values in Raycast display/camera lists Remove hardcoded "Main Display" fallback in start-recording and take-screenshot parseDisplays, and remove bogus "FaceTime HD Camera" fallback in switch-camera. When system_profiler parsing yields no results, return an empty list instead of guessing names that will fail on the desktop deeplink handler side. --- raycast/src/start-recording.tsx | 3 --- raycast/src/switch-camera.tsx | 3 --- raycast/src/take-screenshot.tsx | 3 --- 3 files changed, 9 deletions(-) diff --git a/raycast/src/start-recording.tsx b/raycast/src/start-recording.tsx index 94cb48c2bb..5bedcd65eb 100644 --- a/raycast/src/start-recording.tsx +++ b/raycast/src/start-recording.tsx @@ -11,9 +11,6 @@ function parseDisplays(stdout: string): string[] { displays.push(match[1]); } } - if (displays.length === 0) { - displays.push("Main Display"); - } return displays; } diff --git a/raycast/src/switch-camera.tsx b/raycast/src/switch-camera.tsx index ea2dc3f4fb..3b1681dce1 100644 --- a/raycast/src/switch-camera.tsx +++ b/raycast/src/switch-camera.tsx @@ -54,9 +54,6 @@ export default function SwitchCamera() { cameras.push({ name: currentName, uniqueId: currentUniqueId }); } - if (cameras.length === 0) { - cameras.push({ name: "FaceTime HD Camera", uniqueId: "FaceTime HD Camera" }); - } return cameras; }, }); diff --git a/raycast/src/take-screenshot.tsx b/raycast/src/take-screenshot.tsx index a68b1bc20b..e8f011b7fa 100644 --- a/raycast/src/take-screenshot.tsx +++ b/raycast/src/take-screenshot.tsx @@ -11,9 +11,6 @@ function parseDisplays(stdout: string): string[] { displays.push(match[1]); } } - if (displays.length === 0) { - displays.push("Main Display"); - } return displays; } From 80eb49ac4b080807fda6b21cd22d8878547b4800 Mon Sep 17 00:00:00 2001 From: buildingvibes Date: Sun, 8 Feb 2026 23:00:18 -0800 Subject: [PATCH 6/7] fix: remove hardcoded mic fallback in Raycast extension --- raycast/src/switch-microphone.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/raycast/src/switch-microphone.tsx b/raycast/src/switch-microphone.tsx index cb4196c96d..d7f4829657 100644 --- a/raycast/src/switch-microphone.tsx +++ b/raycast/src/switch-microphone.tsx @@ -38,9 +38,6 @@ export default function SwitchMicrophone() { } } } - if (devices.length === 0) { - devices.push("MacBook Pro Microphone"); - } return devices; }, }); From 5bcb8823e2930ed5c37363c66e74de10875ca3e5 Mon Sep 17 00:00:00 2001 From: buildingvibes Date: Mon, 9 Feb 2026 07:08:29 -0800 Subject: [PATCH 7/7] fix: address remaining review feedback - Tighten DeepLinkAction type to string union for unit actions - Add exact-match hint to error messages in resolve_capture_target - Fix license field to match repo (AGPL-3.0-or-later) --- apps/desktop/src-tauri/src/deeplink_actions.rs | 4 ++-- raycast/package.json | 2 +- raycast/src/utils.ts | 8 +++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index f71fb5b4b0..394158dd87 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -121,12 +121,12 @@ fn resolve_capture_target(capture_mode: &CaptureMode) -> Result cap_recording::screen_capture::list_windows() .into_iter() .find(|(w, _)| w.name == name.as_str()) .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) - .ok_or_else(|| format!("No window with name \"{}\"", name)), + .ok_or_else(|| format!("No window with name \"{}\" (must match exactly)", name)), } } diff --git a/raycast/package.json b/raycast/package.json index 3202de3633..8fa1bbc2a9 100644 --- a/raycast/package.json +++ b/raycast/package.json @@ -6,7 +6,7 @@ "icon": "command-icon.png", "author": "cap", "categories": ["Productivity", "Applications"], - "license": "MIT", + "license": "AGPL-3.0-or-later", "commands": [ { "name": "start-recording", diff --git a/raycast/src/utils.ts b/raycast/src/utils.ts index b7c808d764..94f7a61d89 100644 --- a/raycast/src/utils.ts +++ b/raycast/src/utils.ts @@ -2,7 +2,13 @@ import { open, showHUD } from "@raycast/api"; const DEEPLINK_SCHEME = "cap-desktop"; -type DeepLinkAction = string | Record; +type UnitDeepLinkAction = + | "stop_recording" + | "pause_recording" + | "resume_recording" + | "toggle_pause_recording"; + +type DeepLinkAction = UnitDeepLinkAction | Record; export async function executeDeepLink(action: DeepLinkAction, hudMessage: string) { const encodedValue = encodeURIComponent(JSON.stringify(action));