diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index fce75b4a84..0077c32040 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,77 @@ 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 = 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 } => { + 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, 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, 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 0000000000..72dd4dcd07 Binary files /dev/null and b/apps/raycast-extension/assets/extension-icon.png differ diff --git a/apps/raycast-extension/package.json b/apps/raycast-extension/package.json new file mode 100644 index 0000000000..c900be2abc --- /dev/null +++ b/apps/raycast-extension/package.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap screen recorder - start, stop, pause recordings and switch devices", + "icon": "extension-icon.png", + "author": "cap", + "categories": ["Media", "Productivity"], + "license": "MIT", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "subtitle": "Cap", + "description": "Start a new screen recording with Cap", + "mode": "view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "subtitle": "Cap", + "description": "Stop the current recording", + "mode": "no-view" + }, + { + "name": "toggle-pause", + "title": "Toggle Pause", + "subtitle": "Cap", + "description": "Pause or resume the current recording", + "mode": "no-view" + }, + { + "name": "switch-camera", + "title": "Switch Camera", + "subtitle": "Cap", + "description": "Switch to a different camera", + "mode": "view" + }, + { + "name": "switch-microphone", + "title": "Switch Microphone", + "subtitle": "Cap", + "description": "Switch to a different microphone", + "mode": "view" + }, + { + "name": "open-cap", + "title": "Open Cap", + "subtitle": "Cap", + "description": "Open Cap application", + "mode": "no-view" + } + ], + "dependencies": { + "@raycast/api": "^1.87.3", + "@raycast/utils": "^1.19.1" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "22.10.6", + "@types/react": "18.3.18", + "eslint": "^8.57.0", + "prettier": "^3.4.2", + "typescript": "^5.7.3" + }, + "scripts": { + "build": "ray build --skip-types -e dist -o dist", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint", + "prepublishOnly": "echo \"\\n\\nIt seems like you are trying to publish the Raycast extension to npm.\\n\\nIf you did intend to publish it to npm, remove the \\`prepublishOnly\\` script and rerun \\`npm publish\\` again.\\nIf you wanted to publish it to the Raycast Store instead, use \\`npm run publish\\` instead.\\n\\n\" && exit 1", + "publish": "npx @raycast/api@latest publish" + } +} diff --git a/apps/raycast-extension/src/open-cap.tsx b/apps/raycast-extension/src/open-cap.tsx new file mode 100644 index 0000000000..aed36ba6d1 --- /dev/null +++ b/apps/raycast-extension/src/open-cap.tsx @@ -0,0 +1,15 @@ +import { showHUD, showToast, Toast } from "@raycast/api"; +import { openCap } from "./utils/cap"; + +export default async function OpenCap() { + try { + await openCap(); + await showHUD("Opening Cap..."); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to open Cap", + message: String(error), + }); + } +} diff --git a/apps/raycast-extension/src/start-recording.tsx b/apps/raycast-extension/src/start-recording.tsx new file mode 100644 index 0000000000..37285b03b4 --- /dev/null +++ b/apps/raycast-extension/src/start-recording.tsx @@ -0,0 +1,108 @@ +import { Action, ActionPanel, Icon, List, showHUD, showToast, Toast } from "@raycast/api"; +import { useEffect, useState } from "react"; +import { Display, Window, isCapRunning, listDisplays, listWindows, openCap, startRecording } from "./utils/cap"; + +type CaptureTarget = { type: "screen"; display: Display } | { type: "window"; window: Window }; + +export default function StartRecording() { + const [displays, setDisplays] = useState([]); + 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..7949c0e3b5 --- /dev/null +++ b/apps/raycast-extension/src/utils/cap.ts @@ -0,0 +1,197 @@ +import { exec } from "child_process"; +import { promisify } from "util"; +import { open } from "@raycast/api"; + +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; +} + +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: DeeplinkAction): Promise { + const url = buildDeeplinkUrl(action); + 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 }; + + 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 + set AppleScript's text item delimiters to linefeed + return windowList as text + end tell + `; + const { stdout } = await execAsync(`osascript -e '${script.replace(/'/g, "'\\''")}'`); + const windows = stdout.trim().split("\n").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 { 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) => { + const displayName = name.replace(/:$/, "").trim(); + return { + deviceId: displayName, + displayName, + }; + }); + } catch { + return [{ deviceId: "FaceTime HD Camera", displayName: "FaceTime HD Camera" }]; + } +} + +export async function listMicrophones(): Promise { + try { + 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 open("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/**/*"] +}