diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index fce75b4a84..2e36afa157 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -26,6 +26,16 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, + PauseRecording, + ResumeRecording, + TogglePauseRecording, + RestartRecording, + SetMicrophone { + label: Option, + }, + SetCamera { + id: Option, + }, OpenEditor { project_path: PathBuf, }, @@ -146,6 +156,28 @@ 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::SetMicrophone { label } => { + let state = app.state::>(); + crate::set_mic_input(state, label).await + } + DeepLinkAction::SetCamera { id } => { + let state = app.state::>(); + crate::set_camera_input(app.clone(), state, id, None).await + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 7497f352dc..b7b0e9ed27 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -470,7 +470,7 @@ impl App { #[tauri::command] #[specta::specta] #[instrument(skip(state))] -async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> Result<(), String> { +pub(crate) async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> Result<(), String> { let desired_label = label; let (mic_feed, studio_handle, previous_label) = { @@ -573,7 +573,7 @@ fn get_system_diagnostics() -> cap_recording::diagnostics::SystemDiagnostics { #[specta::specta] #[instrument(skip(app_handle, state))] #[allow(unused_mut)] -async fn set_camera_input( +pub(crate) async fn set_camera_input( app_handle: AppHandle, state: MutableState<'_, App>, id: Option, diff --git a/extensions/raycast/DEEPLINKS.md b/extensions/raycast/DEEPLINKS.md new file mode 100644 index 0000000000..e24c5e6f38 --- /dev/null +++ b/extensions/raycast/DEEPLINKS.md @@ -0,0 +1,191 @@ +# Cap Deeplinks + +Cap supports deeplinks for controlling recordings and other app functionality. This enables integration with tools like Raycast, Alfred, Shortcuts, and custom scripts. + +## URL Scheme + +Cap uses the `cap-desktop://` URL scheme on macOS and Windows. + +## Action Format + +Actions are sent as JSON in the `value` query parameter: + +``` +cap-desktop://action?value= +``` + +**Important:** The JSON value MUST be URL-encoded! + +## Available Actions + +### Recording Controls + +Unit variants are serialized as JSON strings (not objects): + +#### Stop Recording +```json +"stop_recording" +``` + +#### Pause Recording +```json +"pause_recording" +``` + +#### Resume Recording +```json +"resume_recording" +``` + +#### Toggle Pause/Resume +```json +"toggle_pause_recording" +``` + +#### Restart Recording +Stops and immediately restarts with the same settings: +```json +"restart_recording" +``` + +#### Start Recording +Struct variant (serialized as object): +```json +{ + "start_recording": { + "capture_mode": {"screen": "Main Display"}, + "camera": null, + "mic_label": null, + "capture_system_audio": false, + "mode": "instant" + } +} +``` + +**capture_mode options:** +- `{"screen": "Display Name"}` - Record a specific display +- `{"window": "Window Name"}` - Record a specific window + +**mode options:** +- `"instant"` - Quick recording with immediate upload +- `"studio"` - Full editing capabilities + +### Input Controls + +#### Set Microphone +```json +{"set_microphone": {"label": "MacBook Pro Microphone"}} +``` + +Set to `null` to disable: +```json +{"set_microphone": {"label": null}} +``` + +#### Set Camera +```json +{"set_camera": {"id": "camera-device-id"}} +``` + +Set to `null` to disable: +```json +{"set_camera": {"id": null}} +``` + +### App Controls + +#### Open Settings +```json +{"open_settings": {"page": null}} +``` + +Open a specific settings page: +```json +{"open_settings": {"page": "recordings"}} +``` + +#### Open Editor +```json +{"open_editor": {"project_path": "/path/to/project.cap"}} +``` + +## Examples + +### Shell Script (macOS) + +```bash +#!/bin/bash + +# Stop recording (note: unit variant is a string, not object) +open "cap-desktop://action?value=%22stop_recording%22" + +# Toggle pause +open "cap-desktop://action?value=%22toggle_pause_recording%22" + +# Set microphone (struct variant) +open "cap-desktop://action?value=%7B%22set_microphone%22%3A%7B%22label%22%3A%22MacBook%20Pro%20Microphone%22%7D%7D" +``` + +### AppleScript + +```applescript +tell application "System Events" + open location "cap-desktop://action?value=%22stop_recording%22" +end tell +``` + +### JavaScript/Node.js + +```javascript +const { exec } = require('child_process'); + +function capAction(action) { + const json = JSON.stringify(action); + const encoded = encodeURIComponent(json); + const url = `cap-desktop://action?value=${encoded}`; + exec(`open "${url}"`); +} + +// Stop recording (unit variant = string) +capAction("stop_recording"); + +// Toggle pause (unit variant = string) +capAction("toggle_pause_recording"); + +// Set microphone (struct variant = object) +capAction({ set_microphone: { label: "MacBook Pro Microphone" } }); +``` + +### Python + +```python +import subprocess +import json +import urllib.parse + +def cap_action(action): + json_str = json.dumps(action) + encoded = urllib.parse.quote(json_str) + url = f"cap-desktop://action?value={encoded}" + subprocess.run(["open", url]) + +# Stop recording (unit variant = string) +cap_action("stop_recording") + +# Toggle pause (unit variant = string) +cap_action("toggle_pause_recording") + +# Set microphone (struct variant = dict) +cap_action({"set_microphone": {"label": "MacBook Pro Microphone"}}) +``` + +## Raycast Extension + +A full Raycast extension is included in `extensions/raycast/`. See its README for installation instructions. + +## Troubleshooting + +1. **Cap must be running** - Deeplinks only work when Cap is open +2. **URL encoding** - Make sure the JSON is properly URL-encoded +3. **Unit vs Struct variants** - Unit actions (stop, pause, etc.) are JSON strings like `"stop_recording"`, not objects like `{"stop_recording": {}}` +4. **Permissions** - Some actions require an active recording session diff --git a/extensions/raycast/README.md b/extensions/raycast/README.md new file mode 100644 index 0000000000..0022d4635b --- /dev/null +++ b/extensions/raycast/README.md @@ -0,0 +1,74 @@ +# Cap Raycast Extension + +Control [Cap](https://cap.so) screen recording from Raycast. + +## Features + +- **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** - Toggle pause/resume on current recording +- **Restart Recording** - Restart the current recording +- **Open Settings** - Open Cap settings + +## Requirements + +- [Cap](https://cap.so) must be installed and running +- macOS and Windows supported (Cap deeplinks use the `cap-desktop://` scheme) + +## How It Works + +This extension uses Cap's deeplink API to control recordings. Each command sends a URL like: + +``` +cap-desktop://action?value=%22stop_recording%22 +``` + +Note: Unit actions (stop, pause, resume, etc.) are sent as JSON strings, while actions with parameters are sent as JSON objects. + +## Installation + +1. Clone this repository +2. Run `npm install` in the `extensions/raycast` directory +3. Run `npm run dev` to start development +4. Or `npm run build` to build for production + +## Configuration + +The extension supports the following preferences (configurable in Raycast): + +- **Display Name** - Name of the display to record (leave empty for primary display) +- **Recording Mode** - Choose between "instant" or "studio" mode +- **Capture System Audio** - Whether to capture system audio by default + +## Available Deeplinks + +| Action | Deeplink Value (URL-encoded) | +|--------|------------------------------| +| Stop Recording | `%22stop_recording%22` | +| Pause Recording | `%22pause_recording%22` | +| Resume Recording | `%22resume_recording%22` | +| Toggle Pause | `%22toggle_pause_recording%22` | +| Restart Recording | `%22restart_recording%22` | +| Set Microphone | `%7B%22set_microphone%22%3A%7B%22label%22%3A%22Microphone%20Name%22%7D%7D` | +| Set Camera | `%7B%22set_camera%22%3A%7B%22id%22%3A%22camera-id%22%7D%7D` | +| Open Settings | `%7B%22open_settings%22%3A%7B%22page%22%3Anull%7D%7D` | + +### Raw JSON Values (before URL encoding) + +Unit actions (no parameters): +- `"stop_recording"` +- `"pause_recording"` +- `"resume_recording"` +- `"toggle_pause_recording"` +- `"restart_recording"` + +Struct actions (with parameters): +- `{"set_microphone":{"label":"Microphone Name"}}` +- `{"set_camera":{"id":"camera-id"}}` +- `{"open_settings":{"page":null}}` + +## License + +MIT diff --git a/extensions/raycast/assets/cap-icon.png b/extensions/raycast/assets/cap-icon.png new file mode 100644 index 0000000000..72dd4dcd07 Binary files /dev/null and b/extensions/raycast/assets/cap-icon.png differ diff --git a/extensions/raycast/package.json b/extensions/raycast/package.json new file mode 100644 index 0000000000..a423ff4c63 --- /dev/null +++ b/extensions/raycast/package.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap screen recording from Raycast", + "icon": "cap-icon.png", + "author": "cap", + "categories": ["Productivity", "Media"], + "license": "MIT", + "preferences": [ + { + "name": "displayName", + "title": "Display Name", + "description": "Name of the display to record (defaults to 'Main Display')", + "type": "textfield", + "required": false, + "placeholder": "Main Display" + }, + { + "name": "recordingMode", + "title": "Recording Mode", + "description": "Default recording mode", + "type": "dropdown", + "required": false, + "default": "instant", + "data": [ + { "title": "Instant", "value": "instant" }, + { "title": "Studio", "value": "studio" } + ] + }, + { + "name": "captureSystemAudio", + "title": "Capture System Audio", + "description": "Whether to capture system audio by default", + "type": "checkbox", + "required": false, + "default": false, + "label": "Capture system audio" + } + ], + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "subtitle": "Cap", + "description": "Start a new screen recording", + "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", + "title": "Toggle Pause", + "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": "open-settings", + "title": "Open Settings", + "subtitle": "Cap", + "description": "Open Cap settings", + "mode": "no-view" + } + ], + "dependencies": { + "@raycast/api": "^1.93.1" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "22.13.5", + "@types/react": "19.0.10", + "eslint": "^9.21.0", + "prettier": "^3.5.2", + "typescript": "^5.7.3" + }, + "scripts": { + "build": "ray build", + "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/extensions/raycast/src/open-settings.ts b/extensions/raycast/src/open-settings.ts new file mode 100644 index 0000000000..8a1bb03c85 --- /dev/null +++ b/extensions/raycast/src/open-settings.ts @@ -0,0 +1,5 @@ +import { openSettings } from "./utils/deeplink"; + +export default async function Command() { + await openSettings(); +} diff --git a/extensions/raycast/src/pause-recording.ts b/extensions/raycast/src/pause-recording.ts new file mode 100644 index 0000000000..e0cff797dd --- /dev/null +++ b/extensions/raycast/src/pause-recording.ts @@ -0,0 +1,5 @@ +import { pauseRecording } from "./utils/deeplink"; + +export default async function Command() { + await pauseRecording(); +} diff --git a/extensions/raycast/src/restart-recording.ts b/extensions/raycast/src/restart-recording.ts new file mode 100644 index 0000000000..0cfc5a3aa9 --- /dev/null +++ b/extensions/raycast/src/restart-recording.ts @@ -0,0 +1,5 @@ +import { restartRecording } from "./utils/deeplink"; + +export default async function Command() { + await restartRecording(); +} diff --git a/extensions/raycast/src/resume-recording.ts b/extensions/raycast/src/resume-recording.ts new file mode 100644 index 0000000000..2d71910375 --- /dev/null +++ b/extensions/raycast/src/resume-recording.ts @@ -0,0 +1,5 @@ +import { resumeRecording } from "./utils/deeplink"; + +export default async function Command() { + await resumeRecording(); +} diff --git a/extensions/raycast/src/start-recording.ts b/extensions/raycast/src/start-recording.ts new file mode 100644 index 0000000000..6d4f7c2790 --- /dev/null +++ b/extensions/raycast/src/start-recording.ts @@ -0,0 +1,50 @@ +import { showHUD, open, getPreferenceValues } from "@raycast/api"; + +const DEEPLINK_SCHEME = "cap-desktop://action"; + +interface Preferences { + displayName?: string; + recordingMode?: "instant" | "studio"; + captureSystemAudio?: boolean; +} + +interface StartRecordingAction { + start_recording: { + capture_mode: { screen: string } | { window: string }; + camera: null; + mic_label: null; + capture_system_audio: boolean; + mode: "instant" | "studio"; + }; +} + +export default async function Command() { + const preferences = getPreferenceValues(); + + // Use configured display name or fall back to empty string + // Empty string will let Cap use the primary/default display + const displayName = preferences.displayName?.trim() || "Main Display"; + const recordingMode = preferences.recordingMode || "instant"; + const captureSystemAudio = preferences.captureSystemAudio || false; + + const action: StartRecordingAction = { + start_recording: { + capture_mode: { screen: displayName }, + camera: null, + mic_label: null, + capture_system_audio: captureSystemAudio, + mode: recordingMode, + }, + }; + + const jsonValue = JSON.stringify(action); + const encodedValue = encodeURIComponent(jsonValue); + const deeplink = `${DEEPLINK_SCHEME}?value=${encodedValue}`; + + try { + await open(deeplink); + await showHUD("🔴 Recording started"); + } catch { + await showHUD("Failed to communicate with Cap. Is it running?"); + } +} diff --git a/extensions/raycast/src/stop-recording.ts b/extensions/raycast/src/stop-recording.ts new file mode 100644 index 0000000000..5acfaa5127 --- /dev/null +++ b/extensions/raycast/src/stop-recording.ts @@ -0,0 +1,5 @@ +import { stopRecording } from "./utils/deeplink"; + +export default async function Command() { + await stopRecording(); +} diff --git a/extensions/raycast/src/toggle-pause.ts b/extensions/raycast/src/toggle-pause.ts new file mode 100644 index 0000000000..657753b8d2 --- /dev/null +++ b/extensions/raycast/src/toggle-pause.ts @@ -0,0 +1,5 @@ +import { togglePauseRecording } from "./utils/deeplink"; + +export default async function Command() { + await togglePauseRecording(); +} diff --git a/extensions/raycast/src/utils/deeplink.ts b/extensions/raycast/src/utils/deeplink.ts new file mode 100644 index 0000000000..96a32b727d --- /dev/null +++ b/extensions/raycast/src/utils/deeplink.ts @@ -0,0 +1,72 @@ +import { open, showHUD } from "@raycast/api"; + +const DEEPLINK_SCHEME = "cap-desktop://action"; + +type UnitAction = + | "stop_recording" + | "pause_recording" + | "resume_recording" + | "toggle_pause_recording" + | "restart_recording"; + +type StructAction = + | { + start_recording: { + capture_mode: { screen: string } | { window: string }; + camera: string | null; + mic_label: string | null; + capture_system_audio: boolean; + mode: "instant" | "studio"; + }; + } + | { set_microphone: { label: string | null } } + | { set_camera: { id: string | null } } + | { open_settings: { page: string | null } } + | { open_editor: { project_path: string } }; + +type DeepLinkAction = UnitAction | StructAction; + +export async function executeDeepLink(action: DeepLinkAction, successMessage: string): Promise { + const jsonValue = JSON.stringify(action); + const encodedValue = encodeURIComponent(jsonValue); + const deeplink = `${DEEPLINK_SCHEME}?value=${encodedValue}`; + + try { + await open(deeplink); + await showHUD(successMessage); + } catch { + await showHUD("Failed to communicate with Cap. Is it running?"); + } +} + +export async function stopRecording(): Promise { + await executeDeepLink("stop_recording", "⏹ Recording stopped"); +} + +export async function pauseRecording(): Promise { + await executeDeepLink("pause_recording", "⏸ Recording paused"); +} + +export async function resumeRecording(): Promise { + await executeDeepLink("resume_recording", "▶️ Recording resumed"); +} + +export async function togglePauseRecording(): Promise { + await executeDeepLink("toggle_pause_recording", "⏯ Toggled pause"); +} + +export async function restartRecording(): Promise { + await executeDeepLink("restart_recording", "🔄 Recording restarted"); +} + +export async function setMicrophone(label: string | null): Promise { + await executeDeepLink({ set_microphone: { label } }, label ? `🎤 Switched to ${label}` : "🎤 Microphone disabled"); +} + +export async function setCamera(id: string | null): Promise { + await executeDeepLink({ set_camera: { id } }, id ? `📷 Camera switched` : "📷 Camera disabled"); +} + +export async function openSettings(page?: string): Promise { + await executeDeepLink({ open_settings: { page: page ?? null } }, "⚙️ Opening settings"); +} diff --git a/extensions/raycast/tsconfig.json b/extensions/raycast/tsconfig.json new file mode 100644 index 0000000000..fde8b72149 --- /dev/null +++ b/extensions/raycast/tsconfig.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "strict": true, + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2023", + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"] +}