diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a117028487..f2a064c518 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 0000000000..746087d378 --- /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 0000000000..0448f33140 --- /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 0000000000..2b5f90b322 --- /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 0000000000..250621c8da --- /dev/null +++ b/apps/raycast/src/list-devices.ts @@ -0,0 +1,95 @@ +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 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); + } + + init(); + }, []); + + 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 0000000000..4bde33bd20 --- /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 0000000000..c44730c8c1 --- /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 0000000000..7505383864 --- /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 0000000000..b14545026a --- /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 || undefined; + + 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 0000000000..2b55f9aa05 --- /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 0000000000..5255fce58d --- /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 0000000000..c50a2643ac --- /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 0000000000..d260e559d8 --- /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 0000000000..471c2b5716 --- /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 0000000000..9368a3feaa --- /dev/null +++ b/apps/raycast/src/utils.ts @@ -0,0 +1,15 @@ +import { open } from "@raycast/api"; + +const DEEP_LINK_BASE = "cap-desktop://action"; + +export async function sendAction(action: string) { + const value = JSON.stringify(action); + const url = `${DEEP_LINK_BASE}?value=${encodeURIComponent(value)}`; + 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 0000000000..46c787582b --- /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"] +}