diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index fce75b4a84..394158dd87 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.as_str()) + .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id }) + .ok_or_else(|| format!("No screen with name \"{}\" (must match exactly)", name)), + CaptureMode::Window(name) => 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 \"{}\" (must match exactly)", 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 0000000000..72dd4dcd07 Binary files /dev/null and b/raycast/assets/command-icon.png differ diff --git a/raycast/package.json b/raycast/package.json new file mode 100644 index 0000000000..8fa1bbc2a9 --- /dev/null +++ b/raycast/package.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap screen recorder via deeplinks - start, stop, pause, resume recording, switch camera/microphone, and take screenshots.", + "icon": "command-icon.png", + "author": "cap", + "categories": ["Productivity", "Applications"], + "license": "AGPL-3.0-or-later", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "subtitle": "Cap", + "description": "Start a new screen recording in Cap", + "mode": "view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "subtitle": "Cap", + "description": "Stop the current recording in Cap", + "mode": "no-view" + }, + { + "name": "pause-recording", + "title": "Pause Recording", + "subtitle": "Cap", + "description": "Pause the current recording in Cap", + "mode": "no-view" + }, + { + "name": "resume-recording", + "title": "Resume Recording", + "subtitle": "Cap", + "description": "Resume a paused recording in Cap", + "mode": "no-view" + }, + { + "name": "toggle-pause-recording", + "title": "Toggle Pause Recording", + "subtitle": "Cap", + "description": "Toggle pause/resume on the current recording in Cap", + "mode": "no-view" + }, + { + "name": "switch-microphone", + "title": "Switch Microphone", + "subtitle": "Cap", + "description": "Switch the active microphone in Cap", + "mode": "view" + }, + { + "name": "switch-camera", + "title": "Switch Camera", + "subtitle": "Cap", + "description": "Switch the active camera in Cap", + "mode": "view" + }, + { + "name": "take-screenshot", + "title": "Take Screenshot", + "subtitle": "Cap", + "description": "Take a screenshot with Cap", + "mode": "view" + }, + { + "name": "open-settings", + "title": "Open Settings", + "subtitle": "Cap", + "description": "Open Cap settings", + "mode": "no-view" + } + ], + "dependencies": { + "@raycast/api": "^1.93.2", + "@raycast/utils": "^1.19.1" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "22.13.1", + "@types/react": "19.0.8", + "eslint": "^9.18.0", + "prettier": "^3.4.2", + "typescript": "^5.7.3" + }, + "scripts": { + "build": "ray build", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint" + } +} diff --git a/raycast/src/open-settings.ts b/raycast/src/open-settings.ts new file mode 100644 index 0000000000..445fce83db --- /dev/null +++ b/raycast/src/open-settings.ts @@ -0,0 +1,12 @@ +import { executeDeepLink } from "./utils"; + +export default async function OpenSettings() { + await executeDeepLink( + { + open_settings: { + page: null, + }, + }, + "Opening Cap settings", + ); +} diff --git a/raycast/src/pause-recording.ts b/raycast/src/pause-recording.ts new file mode 100644 index 0000000000..76367ecaf9 --- /dev/null +++ b/raycast/src/pause-recording.ts @@ -0,0 +1,5 @@ +import { executeDeepLink } from "./utils"; + +export default async function PauseRecording() { + await executeDeepLink("pause_recording", "Pausing recording in Cap"); +} diff --git a/raycast/src/resume-recording.ts b/raycast/src/resume-recording.ts new file mode 100644 index 0000000000..990550c558 --- /dev/null +++ b/raycast/src/resume-recording.ts @@ -0,0 +1,5 @@ +import { executeDeepLink } from "./utils"; + +export default async function ResumeRecording() { + await executeDeepLink("resume_recording", "Resuming recording in Cap"); +} diff --git a/raycast/src/start-recording.tsx b/raycast/src/start-recording.tsx new file mode 100644 index 0000000000..5bedcd65eb --- /dev/null +++ b/raycast/src/start-recording.tsx @@ -0,0 +1,54 @@ +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]); + } + } + 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/stop-recording.ts b/raycast/src/stop-recording.ts new file mode 100644 index 0000000000..a03b0bfaed --- /dev/null +++ b/raycast/src/stop-recording.ts @@ -0,0 +1,5 @@ +import { executeDeepLink } from "./utils"; + +export default async function StopRecording() { + await executeDeepLink("stop_recording", "Stopping recording in Cap"); +} diff --git a/raycast/src/switch-camera.tsx b/raycast/src/switch-camera.tsx new file mode 100644 index 0000000000..3b1681dce1 --- /dev/null +++ b/raycast/src/switch-camera.tsx @@ -0,0 +1,88 @@ +import { Action, ActionPanel, List } from "@raycast/api"; +import { useExec } from "@raycast/utils"; +import { executeDeepLink } from "./utils"; + +interface Camera { + name: string; + uniqueId: string; +} + +async function switchCamera(camera: Camera) { + await executeDeepLink( + { set_camera: { camera: { DeviceID: camera.uniqueId } } }, + `Switching camera to "${camera.name}" in Cap`, + ); +} + +async function disableCamera() { + 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: 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") { + 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 }); + } + + 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..d7f4829657 --- /dev/null +++ b/raycast/src/switch-microphone.tsx @@ -0,0 +1,72 @@ +import { Action, ActionPanel, List } from "@raycast/api"; +import { useExec } from "@raycast/utils"; +import { executeDeepLink } from "./utils"; + +async function switchMicrophone(label: string) { + await executeDeepLink( + { set_microphone: { mic_label: label } }, + `Switching microphone to "${label}" in Cap`, + ); +} + +async function disableMicrophone() { + await executeDeepLink( + { set_microphone: { mic_label: null } }, + "Disabling microphone in Cap", + ); +} + +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); + } + } + } + return devices; + }, + }); + + const microphones = data ?? []; + + return ( + + + + + } + /> + {microphones.map((mic) => ( + + switchMicrophone(mic)} /> + + } + /> + ))} + + ); +} diff --git a/raycast/src/take-screenshot.tsx b/raycast/src/take-screenshot.tsx new file mode 100644 index 0000000000..e8f011b7fa --- /dev/null +++ b/raycast/src/take-screenshot.tsx @@ -0,0 +1,50 @@ +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]); + } + } + 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)} /> + + } + /> + ))} + + ); +} 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..94f7a61d89 --- /dev/null +++ b/raycast/src/utils.ts @@ -0,0 +1,23 @@ +import { open, showHUD } from "@raycast/api"; + +const DEEPLINK_SCHEME = "cap-desktop"; + +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)); + 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"] +}