From 07c9436b023e82f009ba5bf8a4a469651c2a5172 Mon Sep 17 00:00:00 2001 From: Thomas Davies Date: Sun, 1 Feb 2026 00:32:36 +0000 Subject: [PATCH 1/5] feat: add deeplink actions for pause/resume/switch and Raycast extension This PR adds: ## Deeplink Actions (Rust) - PauseRecording: Pause the current recording - ResumeRecording: Resume a paused recording - TogglePauseRecording: Toggle pause/resume state - SwitchCamera: Switch to a different camera by device ID - SwitchMicrophone: Switch to a different microphone by label - ListCameras: List available cameras (for external tooling) - ListMicrophones: List available microphones (for external tooling) - GetRecordingStatus: Get current recording state ## Raycast Extension A full Raycast extension with commands: - Start Recording: Pick screen or window, choose instant/studio mode - Stop Recording: Stop the current recording - Toggle Pause: Pause or resume recording - Switch Camera: Pick from available cameras or disable - Switch Microphone: Pick from available mics or disable - Open Cap: Launch the Cap application Closes #1540 --- .../desktop/src-tauri/src/deeplink_actions.rs | 84 ++++++++ apps/raycast-extension/README.md | 84 ++++++++ .../assets/extension-icon.png | Bin 0 -> 7543 bytes apps/raycast-extension/package.json | 74 +++++++ apps/raycast-extension/src/open-cap.tsx | 15 ++ .../raycast-extension/src/start-recording.tsx | 108 ++++++++++ apps/raycast-extension/src/stop-recording.tsx | 25 +++ apps/raycast-extension/src/switch-camera.tsx | 69 ++++++ .../src/switch-microphone.tsx | 72 +++++++ apps/raycast-extension/src/toggle-pause.tsx | 25 +++ apps/raycast-extension/src/utils/cap.ts | 197 ++++++++++++++++++ apps/raycast-extension/tsconfig.json | 19 ++ 12 files changed, 772 insertions(+) create mode 100644 apps/raycast-extension/README.md create mode 100644 apps/raycast-extension/assets/extension-icon.png create mode 100644 apps/raycast-extension/package.json create mode 100644 apps/raycast-extension/src/open-cap.tsx create mode 100644 apps/raycast-extension/src/start-recording.tsx create mode 100644 apps/raycast-extension/src/stop-recording.tsx create mode 100644 apps/raycast-extension/src/switch-camera.tsx create mode 100644 apps/raycast-extension/src/switch-microphone.tsx create mode 100644 apps/raycast-extension/src/toggle-pause.tsx create mode 100644 apps/raycast-extension/src/utils/cap.ts create mode 100644 apps/raycast-extension/tsconfig.json diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index fce75b4a84..e15d05035a 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -15,6 +15,20 @@ pub enum CaptureMode { Window(String), } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CameraInfo { + pub device_id: String, + pub display_name: String, + pub model_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MicrophoneInfo { + pub label: String, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DeepLinkAction { @@ -26,6 +40,18 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, + PauseRecording, + ResumeRecording, + TogglePauseRecording, + SwitchCamera { + device_id: Option, + }, + SwitchMicrophone { + device_label: Option, + }, + ListCameras, + ListMicrophones, + GetRecordingStatus, OpenEditor { project_path: PathBuf, }, @@ -146,6 +172,64 @@ 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::SwitchCamera { device_id } => { + let state = app.state::>(); + let camera_id = device_id.map(DeviceOrModelID::DeviceID); + crate::set_camera_input(app.clone(), state, camera_id, Some(true)).await + } + DeepLinkAction::SwitchMicrophone { device_label } => { + let state = app.state::>(); + crate::set_mic_input(state, device_label).await + } + DeepLinkAction::ListCameras => { + let cameras: Vec = cap_camera::list_cameras() + .map(|c| CameraInfo { + device_id: c.device_id().to_string(), + display_name: c.display_name().to_string(), + model_id: c.model_id().map(|m| m.to_string()), + }) + .collect(); + let json = serde_json::to_string(&cameras).map_err(|e| e.to_string())?; + trace!("ListCameras response: {}", json); + Ok(()) + } + DeepLinkAction::ListMicrophones => { + let mics: Vec = + cap_recording::feeds::microphone::MicrophoneFeed::list() + .keys() + .map(|label| MicrophoneInfo { + label: label.clone(), + }) + .collect(); + let json = serde_json::to_string(&mics).map_err(|e| e.to_string())?; + trace!("ListMicrophones response: {}", json); + Ok(()) + } + DeepLinkAction::GetRecordingStatus => { + let state = app.state::>(); + let app_state = state.read().await; + let is_recording = app_state.current_recording().is_some(); + let is_paused = if let Some(recording) = app_state.current_recording() { + recording.is_paused().await.unwrap_or(false) + } else { + false + }; + trace!( + "GetRecordingStatus: is_recording={}, is_paused={}", + is_recording, + is_paused + ); + Ok(()) + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } diff --git a/apps/raycast-extension/README.md b/apps/raycast-extension/README.md new file mode 100644 index 0000000000..b9e771b928 --- /dev/null +++ b/apps/raycast-extension/README.md @@ -0,0 +1,84 @@ +# Cap Raycast Extension + +Control [Cap](https://cap.so) screen recorder directly from Raycast. + +## Features + +- **Start Recording** - Pick a screen or window to start recording +- **Stop Recording** - Stop the current recording +- **Toggle Pause** - Pause or resume the current recording +- **Switch Camera** - Switch to a different camera or disable camera +- **Switch Microphone** - Switch to a different microphone or disable microphone +- **Open Cap** - Launch the Cap application + +## Requirements + +- [Cap](https://cap.so) must be installed on your Mac +- macOS 13.0 or later + +## How It Works + +This extension uses Cap's deeplink protocol to control the application: + +``` +cap://action?value={"action_type": {...}} +``` + +### Available Deeplink Actions + +| Action | Description | +|--------|-------------| +| `start_recording` | Start a new recording with specified capture mode | +| `stop_recording` | Stop the current recording | +| `pause_recording` | Pause the current recording | +| `resume_recording` | Resume a paused recording | +| `toggle_pause_recording` | Toggle pause/resume state | +| `switch_camera` | Switch to a different camera | +| `switch_microphone` | Switch to a different microphone | + +### Example Deeplinks + +**Start recording a screen:** +```bash +open "cap://action?value=%7B%22start_recording%22%3A%7B%22capture_mode%22%3A%7B%22screen%22%3A%22Built-in%20Retina%20Display%22%7D%2C%22capture_system_audio%22%3Afalse%2C%22mode%22%3A%22instant%22%7D%7D" +``` + +**Stop recording:** +```bash +open "cap://action?value=%22stop_recording%22" +``` + +**Toggle pause:** +```bash +open "cap://action?value=%22toggle_pause_recording%22" +``` + +**Switch camera:** +```bash +open "cap://action?value=%7B%22switch_camera%22%3A%7B%22device_id%22%3A%22FaceTime%20HD%20Camera%22%7D%7D" +``` + +**Disable camera:** +```bash +open "cap://action?value=%7B%22switch_camera%22%3A%7B%22device_id%22%3Anull%7D%7D" +``` + +## Development + +```bash +# Install dependencies +npm install + +# Start development +npm run dev + +# Build +npm run build + +# Lint +npm run lint +``` + +## License + +MIT diff --git a/apps/raycast-extension/assets/extension-icon.png b/apps/raycast-extension/assets/extension-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..72dd4dcd0795ebde571a500c9a9a4aaef00403cf GIT binary patch literal 7543 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[]); + const [windows, setWindows] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [selectedMode, setSelectedMode] = useState<"instant" | "studio">("instant"); + + useEffect(() => { + async function fetchData() { + const [displayList, windowList] = await Promise.all([listDisplays(), listWindows()]); + setDisplays(displayList); + setWindows(windowList); + setIsLoading(false); + } + fetchData(); + }, []); + + async function handleStartRecording(target: CaptureTarget) { + try { + const isRunning = await isCapRunning(); + if (!isRunning) { + await showToast({ style: Toast.Style.Animated, title: "Starting Cap..." }); + await openCap(); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + const captureMode = + target.type === "screen" ? { screen: target.display.name } : { window: target.window.name }; + + await startRecording({ + captureMode, + mode: selectedMode, + }); + + await showHUD(`Recording started (${selectedMode} mode)`); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to start recording", + message: String(error), + }); + } + } + + return ( + setSelectedMode(value as "instant" | "studio")} + > + + + + } + > + + {displays.map((display) => ( + + handleStartRecording({ type: "screen", display })} + /> + + } + /> + ))} + + + + {windows.map((window) => ( + + handleStartRecording({ type: "window", window })} + /> + + } + /> + ))} + + + ); +} diff --git a/apps/raycast-extension/src/stop-recording.tsx b/apps/raycast-extension/src/stop-recording.tsx new file mode 100644 index 0000000000..8125ca7b54 --- /dev/null +++ b/apps/raycast-extension/src/stop-recording.tsx @@ -0,0 +1,25 @@ +import { showHUD, showToast, Toast } from "@raycast/api"; +import { isCapRunning, stopRecording } from "./utils/cap"; + +export default async function StopRecording() { + try { + const isRunning = await isCapRunning(); + if (!isRunning) { + await showToast({ + style: Toast.Style.Failure, + title: "Cap is not running", + message: "Please start Cap first", + }); + return; + } + + await stopRecording(); + await showHUD("Recording stopped"); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to stop recording", + message: String(error), + }); + } +} diff --git a/apps/raycast-extension/src/switch-camera.tsx b/apps/raycast-extension/src/switch-camera.tsx new file mode 100644 index 0000000000..9051a631ad --- /dev/null +++ b/apps/raycast-extension/src/switch-camera.tsx @@ -0,0 +1,69 @@ +import { Action, ActionPanel, Icon, List, showHUD, showToast, Toast } from "@raycast/api"; +import { useEffect, useState } from "react"; +import { Camera, isCapRunning, listCameras, openCap, switchCamera } from "./utils/cap"; + +export default function SwitchCamera() { + const [cameras, setCameras] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + async function fetchCameras() { + const cameraList = await listCameras(); + setCameras(cameraList); + setIsLoading(false); + } + fetchCameras(); + }, []); + + async function handleSwitchCamera(camera: Camera | null) { + try { + const isRunning = await isCapRunning(); + if (!isRunning) { + await showToast({ style: Toast.Style.Animated, title: "Starting Cap..." }); + await openCap(); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + await switchCamera(camera?.deviceId ?? null); + await showHUD(camera ? `Switched to ${camera.displayName}` : "Camera disabled"); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to switch camera", + message: String(error), + }); + } + } + + return ( + + + handleSwitchCamera(null)} /> + + } + /> + {cameras.map((camera) => ( + + handleSwitchCamera(camera)} + /> + + } + /> + ))} + + ); +} diff --git a/apps/raycast-extension/src/switch-microphone.tsx b/apps/raycast-extension/src/switch-microphone.tsx new file mode 100644 index 0000000000..42b2cac9d9 --- /dev/null +++ b/apps/raycast-extension/src/switch-microphone.tsx @@ -0,0 +1,72 @@ +import { Action, ActionPanel, Icon, List, showHUD, showToast, Toast } from "@raycast/api"; +import { useEffect, useState } from "react"; +import { Microphone, isCapRunning, listMicrophones, openCap, switchMicrophone } from "./utils/cap"; + +export default function SwitchMicrophone() { + const [microphones, setMicrophones] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + async function fetchMicrophones() { + const micList = await listMicrophones(); + setMicrophones(micList); + setIsLoading(false); + } + fetchMicrophones(); + }, []); + + async function handleSwitchMicrophone(mic: Microphone | null) { + try { + const isRunning = await isCapRunning(); + if (!isRunning) { + await showToast({ style: Toast.Style.Animated, title: "Starting Cap..." }); + await openCap(); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + await switchMicrophone(mic?.label ?? null); + await showHUD(mic ? `Switched to ${mic.label}` : "Microphone disabled"); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to switch microphone", + message: String(error), + }); + } + } + + return ( + + + handleSwitchMicrophone(null)} + /> + + } + /> + {microphones.map((mic) => ( + + handleSwitchMicrophone(mic)} + /> + + } + /> + ))} + + ); +} diff --git a/apps/raycast-extension/src/toggle-pause.tsx b/apps/raycast-extension/src/toggle-pause.tsx new file mode 100644 index 0000000000..6190e93eec --- /dev/null +++ b/apps/raycast-extension/src/toggle-pause.tsx @@ -0,0 +1,25 @@ +import { showHUD, showToast, Toast } from "@raycast/api"; +import { isCapRunning, togglePauseRecording } from "./utils/cap"; + +export default async function TogglePause() { + try { + const isRunning = await isCapRunning(); + if (!isRunning) { + await showToast({ + style: Toast.Style.Failure, + title: "Cap is not running", + message: "Please start Cap first", + }); + return; + } + + await togglePauseRecording(); + await showHUD("Recording pause toggled"); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to toggle pause", + message: String(error), + }); + } +} diff --git a/apps/raycast-extension/src/utils/cap.ts b/apps/raycast-extension/src/utils/cap.ts new file mode 100644 index 0000000000..b9e0f672a9 --- /dev/null +++ b/apps/raycast-extension/src/utils/cap.ts @@ -0,0 +1,197 @@ +import { exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); + +const CAP_SCHEME = "cap://action"; + +export interface CaptureMode { + screen?: string; + window?: string; +} + +export interface StartRecordingOptions { + captureMode: CaptureMode; + camera?: string; + micLabel?: string; + captureSystemAudio?: boolean; + mode?: "studio" | "instant"; +} + +export interface Display { + id: number; + name: string; +} + +export interface Window { + id: number; + name: string; + owner: string; +} + +export interface Camera { + deviceId: string; + displayName: string; + modelId?: string; +} + +export interface Microphone { + label: string; +} + +function buildDeeplinkUrl(action: object): string { + const encodedValue = encodeURIComponent(JSON.stringify(action)); + return `${CAP_SCHEME}?value=${encodedValue}`; +} + +export async function openDeeplink(action: object): Promise { + const url = buildDeeplinkUrl(action); + await execAsync(`open "${url}"`); +} + +export async function startRecording(options: StartRecordingOptions): Promise { + const captureMode = options.captureMode.screen + ? { screen: options.captureMode.screen } + : { window: options.captureMode.window }; + + await openDeeplink({ + start_recording: { + capture_mode: captureMode, + camera: options.camera ? { device_id: options.camera } : null, + mic_label: options.micLabel ?? null, + capture_system_audio: options.captureSystemAudio ?? false, + mode: options.mode ?? "instant", + }, + }); +} + +export async function stopRecording(): Promise { + await openDeeplink("stop_recording"); +} + +export async function pauseRecording(): Promise { + await openDeeplink("pause_recording"); +} + +export async function resumeRecording(): Promise { + await openDeeplink("resume_recording"); +} + +export async function togglePauseRecording(): Promise { + await openDeeplink("toggle_pause_recording"); +} + +export async function switchCamera(deviceId: string | null): Promise { + await openDeeplink({ + switch_camera: { + device_id: deviceId, + }, + }); +} + +export async function switchMicrophone(deviceLabel: string | null): Promise { + await openDeeplink({ + switch_microphone: { + device_label: deviceLabel, + }, + }); +} + +export async function listDisplays(): Promise { + try { + const { stdout } = await execAsync( + `system_profiler SPDisplaysDataType -json 2>/dev/null | grep -o '"_name" : "[^"]*"' | cut -d'"' -f4` + ); + const names = stdout.trim().split("\n").filter(Boolean); + return names.map((name, index) => ({ + id: index + 1, + name: name || `Display ${index + 1}`, + })); + } catch { + return [{ id: 1, name: "Main Display" }]; + } +} + +export async function listWindows(): Promise { + try { + const script = ` + tell application "System Events" + set windowList to {} + repeat with proc in (every process whose visible is true) + try + repeat with win in (every window of proc) + set windowName to name of win + set appName to name of proc + set end of windowList to appName & ": " & windowName + end repeat + end try + end repeat + return windowList + end tell + `; + const { stdout } = await execAsync(`osascript -e '${script.replace(/'/g, "'\\''")}'`); + const windows = stdout.trim().split(", ").filter(Boolean); + return windows.map((win, index) => { + const parts = win.split(": "); + return { + id: index + 1, + owner: parts[0] || "Unknown", + name: parts.slice(1).join(": ") || "Untitled", + }; + }); + } catch { + return []; + } +} + +export async function listCameras(): Promise { + try { + const script = ` + set cameraList to {} + try + do shell script "system_profiler SPCameraDataType 2>/dev/null | grep -E '^\\s+[A-Za-z]' | sed 's/^[[:space:]]*//' | head -10" + on error + return "" + end try + `; + const { stdout } = await execAsync(`system_profiler SPCameraDataType 2>/dev/null | grep -E "^\\s+[A-Za-z]" | sed 's/^[[:space:]]*//' | head -10`); + const cameras = stdout.trim().split("\n").filter(Boolean); + return cameras.map((name, index) => ({ + deviceId: `camera-${index}`, + displayName: name.replace(/:$/, "").trim(), + })); + } catch { + return [{ deviceId: "default", displayName: "Default Camera" }]; + } +} + +export async function listMicrophones(): Promise { + try { + const script = ` + do shell script "system_profiler SPAudioDataType 2>/dev/null | grep -A 20 'Input Sources:' | grep -E '^\\s+[A-Za-z]' | sed 's/^[[:space:]]*//' | head -10" + `; + const { stdout } = await execAsync(`system_profiler SPAudioDataType 2>/dev/null | grep -A 50 'Input Sources:' | grep -E "Default Input Device: Yes" -B 10 | grep -E "^\\s+[A-Za-z].*:" | head -5 | sed 's/^[[:space:]]*//' | cut -d: -f1`); + const mics = stdout.trim().split("\n").filter(Boolean); + if (mics.length === 0) { + const { stdout: altOutput } = await execAsync(`system_profiler SPAudioDataType 2>/dev/null | grep -E "^\\s{8}[A-Za-z].*:" | head -10 | sed 's/^[[:space:]]*//' | cut -d: -f1`); + const altMics = altOutput.trim().split("\n").filter(Boolean); + return altMics.map((label) => ({ label: label.trim() })); + } + return mics.map((label) => ({ label: label.trim() })); + } catch { + return [{ label: "Default Microphone" }]; + } +} + +export async function isCapRunning(): Promise { + try { + const { stdout } = await execAsync("pgrep -x 'Cap' || pgrep -f 'Cap.app'"); + return stdout.trim().length > 0; + } catch { + return false; + } +} + +export async function openCap(): Promise { + await execAsync("open -a Cap"); +} diff --git a/apps/raycast-extension/tsconfig.json b/apps/raycast-extension/tsconfig.json new file mode 100644 index 0000000000..99185b74b5 --- /dev/null +++ b/apps/raycast-extension/tsconfig.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Cap Raycast Extension", + "compilerOptions": { + "lib": ["ES2023"], + "module": "Node16", + "target": "ES2022", + "moduleResolution": "Node16", + "strict": true, + "declaration": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"] +} From 818c0377553a38cdad90a0ebc3a3ffa60f2083ba Mon Sep 17 00:00:00 2001 From: Thomas Davies Date: Sun, 1 Feb 2026 00:37:17 +0000 Subject: [PATCH 2/5] fix: Address review comments - Fix TypeScript type: openDeeplink now accepts string | object - Fix command injection: Use Raycast's open API instead of shell exec - Fix camera IDs: Use camera name as deviceId (Cap matches by name) --- apps/raycast-extension/src/utils/cap.ts | 35 +++++++++++++------------ 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/apps/raycast-extension/src/utils/cap.ts b/apps/raycast-extension/src/utils/cap.ts index b9e0f672a9..b6ecd882fc 100644 --- a/apps/raycast-extension/src/utils/cap.ts +++ b/apps/raycast-extension/src/utils/cap.ts @@ -1,5 +1,6 @@ import { exec } from "child_process"; import { promisify } from "util"; +import { open } from "@raycast/api"; const execAsync = promisify(exec); @@ -39,14 +40,17 @@ export interface Microphone { label: string; } -function buildDeeplinkUrl(action: object): string { +type DeeplinkAction = string | object; + +function buildDeeplinkUrl(action: DeeplinkAction): string { const encodedValue = encodeURIComponent(JSON.stringify(action)); return `${CAP_SCHEME}?value=${encodedValue}`; } -export async function openDeeplink(action: object): Promise { +export async function openDeeplink(action: DeeplinkAction): Promise { const url = buildDeeplinkUrl(action); - await execAsync(`open "${url}"`); + // Use Raycast's open API to avoid command injection risks + await open(url); } export async function startRecording(options: StartRecordingOptions): Promise { @@ -146,22 +150,18 @@ export async function listWindows(): Promise { export async function listCameras(): Promise { try { - const script = ` - set cameraList to {} - try - do shell script "system_profiler SPCameraDataType 2>/dev/null | grep -E '^\\s+[A-Za-z]' | sed 's/^[[:space:]]*//' | head -10" - on error - return "" - end try - `; const { stdout } = await execAsync(`system_profiler SPCameraDataType 2>/dev/null | grep -E "^\\s+[A-Za-z]" | sed 's/^[[:space:]]*//' | head -10`); const cameras = stdout.trim().split("\n").filter(Boolean); - return cameras.map((name, index) => ({ - deviceId: `camera-${index}`, - displayName: name.replace(/:$/, "").trim(), - })); + return cameras.map((name) => { + const displayName = name.replace(/:$/, "").trim(); + // Use camera name as deviceId - Cap accepts camera names for matching + return { + deviceId: displayName, + displayName, + }; + }); } catch { - return [{ deviceId: "default", displayName: "Default Camera" }]; + return [{ deviceId: "FaceTime HD Camera", displayName: "FaceTime HD Camera" }]; } } @@ -193,5 +193,6 @@ export async function isCapRunning(): Promise { } export async function openCap(): Promise { - await execAsync("open -a Cap"); + // Use Raycast's open API for launching applications + await open("cap://"); } From eb8c9fc65f0fa228309c7131a9ea46712ec89151 Mon Sep 17 00:00:00 2001 From: Tom Davies Date: Sun, 1 Feb 2026 01:10:07 +0000 Subject: [PATCH 3/5] fix: polish code per repo conventions - Remove all inline comments (repo disallows comments) - Guard startRecording against empty captureMode - Fix window title parsing to use newlines instead of comma splitting - Remove unused script variable in listMicrophones - SwitchCamera now accepts device ID or display name - GetRecordingStatus avoids redundant current_recording() call and propagates errors --- .../desktop/src-tauri/src/deeplink_actions.rs | 26 ++++++++++++++----- apps/raycast-extension/src/utils/cap.ts | 16 +++++------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index e15d05035a..a60ea2eeee 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -183,7 +183,18 @@ impl DeepLinkAction { } DeepLinkAction::SwitchCamera { device_id } => { let state = app.state::>(); - let camera_id = device_id.map(DeviceOrModelID::DeviceID); + + let camera_id = match device_id { + None => None, + Some(id) => { + let matched = cap_camera::list_cameras() + .find(|c| c.device_id() == id || c.display_name() == id) + .map(|c| c.device_id().to_string()) + .ok_or_else(|| format!("No camera with id or name \"{}\"", id))?; + Some(DeviceOrModelID::DeviceID(matched)) + } + }; + crate::set_camera_input(app.clone(), state, camera_id, Some(true)).await } DeepLinkAction::SwitchMicrophone { device_label } => { @@ -217,12 +228,15 @@ impl DeepLinkAction { DeepLinkAction::GetRecordingStatus => { let state = app.state::>(); let app_state = state.read().await; - let is_recording = app_state.current_recording().is_some(); - let is_paused = if let Some(recording) = app_state.current_recording() { - recording.is_paused().await.unwrap_or(false) - } else { - false + + let (is_recording, is_paused) = match app_state.current_recording() { + Some(recording) => { + let paused = recording.is_paused().await.map_err(|e| e.to_string())?; + (true, paused) + } + None => (false, false), }; + trace!( "GetRecordingStatus: is_recording={}, is_paused={}", is_recording, diff --git a/apps/raycast-extension/src/utils/cap.ts b/apps/raycast-extension/src/utils/cap.ts index b6ecd882fc..e1de0bf83c 100644 --- a/apps/raycast-extension/src/utils/cap.ts +++ b/apps/raycast-extension/src/utils/cap.ts @@ -49,11 +49,14 @@ function buildDeeplinkUrl(action: DeeplinkAction): string { export async function openDeeplink(action: DeeplinkAction): Promise { const url = buildDeeplinkUrl(action); - // Use Raycast's open API to avoid command injection risks await open(url); } export async function startRecording(options: StartRecordingOptions): Promise { + if (!options.captureMode.screen && !options.captureMode.window) { + throw new Error("captureMode must include screen or window"); + } + const captureMode = options.captureMode.screen ? { screen: options.captureMode.screen } : { window: options.captureMode.window }; @@ -126,15 +129,15 @@ export async function listWindows(): Promise { repeat with win in (every window of proc) set windowName to name of win set appName to name of proc - set end of windowList to appName & ": " & windowName + set end of windowList to appName & ": " & windowName & "\n" end repeat end try end repeat - return windowList + return windowList as text end tell `; const { stdout } = await execAsync(`osascript -e '${script.replace(/'/g, "'\\''")}'`); - const windows = stdout.trim().split(", ").filter(Boolean); + const windows = stdout.trim().split("\n").filter(Boolean); return windows.map((win, index) => { const parts = win.split(": "); return { @@ -154,7 +157,6 @@ export async function listCameras(): Promise { const cameras = stdout.trim().split("\n").filter(Boolean); return cameras.map((name) => { const displayName = name.replace(/:$/, "").trim(); - // Use camera name as deviceId - Cap accepts camera names for matching return { deviceId: displayName, displayName, @@ -167,9 +169,6 @@ export async function listCameras(): Promise { export async function listMicrophones(): Promise { try { - const script = ` - do shell script "system_profiler SPAudioDataType 2>/dev/null | grep -A 20 'Input Sources:' | grep -E '^\\s+[A-Za-z]' | sed 's/^[[:space:]]*//' | head -10" - `; const { stdout } = await execAsync(`system_profiler SPAudioDataType 2>/dev/null | grep -A 50 'Input Sources:' | grep -E "Default Input Device: Yes" -B 10 | grep -E "^\\s+[A-Za-z].*:" | head -5 | sed 's/^[[:space:]]*//' | cut -d: -f1`); const mics = stdout.trim().split("\n").filter(Boolean); if (mics.length === 0) { @@ -193,6 +192,5 @@ export async function isCapRunning(): Promise { } export async function openCap(): Promise { - // Use Raycast's open API for launching applications await open("cap://"); } From 8b8cb67e5a30fbba0191bd00f9109cb858dfa214 Mon Sep 17 00:00:00 2001 From: Tom Davies Date: Sun, 1 Feb 2026 01:15:40 +0000 Subject: [PATCH 4/5] style: apply cargo fmt --- apps/desktop/src-tauri/src/deeplink_actions.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a60ea2eeee..0077c32040 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -239,8 +239,7 @@ impl DeepLinkAction { trace!( "GetRecordingStatus: is_recording={}, is_paused={}", - is_recording, - is_paused + is_recording, is_paused ); Ok(()) } From 4d62dcaedeaa60364f0b587fb02bacb8c77ae1b2 Mon Sep 17 00:00:00 2001 From: Tom Davies Date: Sun, 1 Feb 2026 07:58:46 +0000 Subject: [PATCH 5/5] fix: use explicit text item delimiters for window list Set AppleScript's text item delimiters to linefeed before coercing windowList to text, following Raycast extension conventions. --- apps/raycast-extension/src/utils/cap.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/raycast-extension/src/utils/cap.ts b/apps/raycast-extension/src/utils/cap.ts index e1de0bf83c..7949c0e3b5 100644 --- a/apps/raycast-extension/src/utils/cap.ts +++ b/apps/raycast-extension/src/utils/cap.ts @@ -129,10 +129,11 @@ export async function listWindows(): Promise { repeat with win in (every window of proc) set windowName to name of win set appName to name of proc - set end of windowList to appName & ": " & windowName & "\n" + set end of windowList to appName & ": " & windowName end repeat end try end repeat + set AppleScript's text item delimiters to linefeed return windowList as text end tell `;