diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a117028487..6f7ee2a2f2 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -26,6 +26,15 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, + PauseRecording, + ResumeRecording, + TogglePauseRecording, + SetCamera { + camera: Option, + }, + SetMicrophone { + mic_label: Option, + }, OpenEditor { project_path: PathBuf, }, @@ -147,6 +156,22 @@ 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 } => { + crate::set_mic_input(app.state(), mic_label).await + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } diff --git a/apps/raycast/README.md b/apps/raycast/README.md new file mode 100644 index 0000000000..351baa7514 --- /dev/null +++ b/apps/raycast/README.md @@ -0,0 +1,66 @@ +# Cap Raycast Extension + +Control [Cap](https://cap.so) screen recordings directly from Raycast using deeplinks. + +## Commands + +| Command | Description | Mode | +|---------|-------------|------| +| **Start Recording** | Start a screen/window recording with configurable options | Form | +| **Stop Recording** | Stop the current recording | Instant | +| **Pause Recording** | Pause the current recording | Instant | +| **Resume Recording** | Resume a paused recording | Instant | +| **Toggle Pause** | Toggle pause/resume on the current recording | Instant | +| **Set Camera** | Switch camera input (pass name as argument) | Instant | +| **Set Microphone** | Switch microphone input (pass label as argument) | Instant | +| **Open Settings** | Open Cap settings window | Instant | + +## How It Works + +This extension communicates with Cap desktop via the `cap-desktop://` URL scheme. Each command constructs a deeplink URL in the format: + +``` +cap-desktop://action?value={json_encoded_action} +``` + +Cap desktop must be running for the commands to work. + +## Deeplink Reference + +### Simple actions (no parameters) + +``` +cap-desktop://action?value="stop_recording" +cap-desktop://action?value="pause_recording" +cap-desktop://action?value="resume_recording" +cap-desktop://action?value="toggle_pause_recording" +``` + +### Start recording + +```json +{ + "start_recording": { + "capture_mode": { "screen": "Built-in Retina Display" }, + "camera": { "ModelID": "FaceTime HD Camera" }, + "mic_label": "MacBook Pro Microphone", + "capture_system_audio": true, + "mode": "studio" + } +} +``` + +### Switch camera/microphone + +```json +{ "set_camera": { "camera": { "ModelID": "FaceTime HD Camera" } } } +{ "set_camera": { "camera": null } } +{ "set_microphone": { "mic_label": "External Microphone" } } +{ "set_microphone": { "mic_label": null } } +``` + +### Open settings + +```json +{ "open_settings": { "page": "recordings" } } +``` diff --git a/apps/raycast/assets/command-icon.png b/apps/raycast/assets/command-icon.png new file mode 100644 index 0000000000..b1ac1ef7d8 Binary files /dev/null and b/apps/raycast/assets/command-icon.png differ diff --git a/apps/raycast/package.json b/apps/raycast/package.json new file mode 100644 index 0000000000..89ef6a6514 --- /dev/null +++ b/apps/raycast/package.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap screen recordings via deeplinks", + "icon": "command-icon.png", + "author": "cap", + "categories": ["Productivity"], + "license": "AGPL-3.0", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "description": "Start a Cap screen recording", + "mode": "view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "description": "Stop the current Cap recording", + "mode": "no-view" + }, + { + "name": "pause-recording", + "title": "Pause Recording", + "description": "Pause the current Cap recording", + "mode": "no-view" + }, + { + "name": "resume-recording", + "title": "Resume Recording", + "description": "Resume a paused Cap recording", + "mode": "no-view" + }, + { + "name": "toggle-pause", + "title": "Toggle Pause", + "description": "Toggle pause/resume on the current Cap recording", + "mode": "no-view" + }, + { + "name": "set-camera", + "title": "Set Camera", + "description": "Switch the camera input for Cap recordings", + "mode": "view", + "arguments": [ + { + "name": "camera", + "placeholder": "Camera name or model", + "type": "text", + "required": false + } + ] + }, + { + "name": "set-microphone", + "title": "Set Microphone", + "description": "Switch the microphone input for Cap recordings", + "mode": "view", + "arguments": [ + { + "name": "microphone", + "placeholder": "Microphone label", + "type": "text", + "required": false + } + ] + }, + { + "name": "open-settings", + "title": "Open Settings", + "description": "Open Cap settings", + "mode": "no-view" + } + ], + "dependencies": { + "@raycast/api": "^1.87.0" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "22.13.4", + "@types/react": "19.0.8", + "eslint": "^9.18.0", + "typescript": "^5.7.3" + }, + "scripts": { + "build": "ray build", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint" + } +} diff --git a/apps/raycast/src/open-settings.tsx b/apps/raycast/src/open-settings.tsx new file mode 100644 index 0000000000..f0c8493544 --- /dev/null +++ b/apps/raycast/src/open-settings.tsx @@ -0,0 +1,7 @@ +import { open, showHUD } from "@raycast/api"; +import { buildDeeplinkUrl } from "./utils"; + +export default async function Command() { + await open(buildDeeplinkUrl({ open_settings: { page: null } })); + await showHUD("Opening Cap settings"); +} diff --git a/apps/raycast/src/pause-recording.tsx b/apps/raycast/src/pause-recording.tsx new file mode 100644 index 0000000000..56d83d7a38 --- /dev/null +++ b/apps/raycast/src/pause-recording.tsx @@ -0,0 +1,7 @@ +import { open, showHUD } from "@raycast/api"; +import { buildDeeplinkUrl } from "./utils"; + +export default async function Command() { + await open(buildDeeplinkUrl("pause_recording")); + await showHUD("Pausing Cap recording"); +} diff --git a/apps/raycast/src/resume-recording.tsx b/apps/raycast/src/resume-recording.tsx new file mode 100644 index 0000000000..e8eb203814 --- /dev/null +++ b/apps/raycast/src/resume-recording.tsx @@ -0,0 +1,7 @@ +import { open, showHUD } from "@raycast/api"; +import { buildDeeplinkUrl } from "./utils"; + +export default async function Command() { + await open(buildDeeplinkUrl("resume_recording")); + await showHUD("Resuming Cap recording"); +} diff --git a/apps/raycast/src/set-camera.tsx b/apps/raycast/src/set-camera.tsx new file mode 100644 index 0000000000..406ba446ed --- /dev/null +++ b/apps/raycast/src/set-camera.tsx @@ -0,0 +1,19 @@ +import { LaunchProps, open, showHUD } from "@raycast/api"; +import { buildDeeplinkUrl } from "./utils"; + +interface Arguments { + camera: string; +} + +export default async function Command(props: LaunchProps<{ arguments: Arguments }>) { + const camera = props.arguments.camera?.trim() || null; + + const url = buildDeeplinkUrl({ + set_camera: { + camera: camera ? { ModelID: camera } : null, + }, + }); + + await open(url); + await showHUD(camera ? `Switching camera to ${camera}` : "Disabling camera"); +} diff --git a/apps/raycast/src/set-microphone.tsx b/apps/raycast/src/set-microphone.tsx new file mode 100644 index 0000000000..361515380e --- /dev/null +++ b/apps/raycast/src/set-microphone.tsx @@ -0,0 +1,19 @@ +import { LaunchProps, open, showHUD } from "@raycast/api"; +import { buildDeeplinkUrl } from "./utils"; + +interface Arguments { + microphone: string; +} + +export default async function Command(props: LaunchProps<{ arguments: Arguments }>) { + const mic = props.arguments.microphone?.trim() || null; + + const url = buildDeeplinkUrl({ + set_microphone: { + mic_label: mic, + }, + }); + + await open(url); + await showHUD(mic ? `Switching microphone to ${mic}` : "Disabling microphone"); +} diff --git a/apps/raycast/src/start-recording.tsx b/apps/raycast/src/start-recording.tsx new file mode 100644 index 0000000000..97983c09e5 --- /dev/null +++ b/apps/raycast/src/start-recording.tsx @@ -0,0 +1,59 @@ +import { Action, ActionPanel, Form, open, showHUD } from "@raycast/api"; +import { buildDeeplinkUrl } from "./utils"; + +export default function Command() { + return ( +
+ + + } + > + + + + + + + + + + + + + + ); +} + +interface FormValues { + captureType: string; + captureName: string; + mode: string; + camera: string; + mic: string; + systemAudio: boolean; +} + +async function handleSubmit(values: FormValues) { + const captureMode = + values.captureType === "screen" + ? { screen: values.captureName } + : { window: values.captureName }; + + const camera = values.camera ? { ModelID: values.camera } : null; + const micLabel = values.mic || null; + + const url = buildDeeplinkUrl({ + start_recording: { + capture_mode: captureMode, + camera, + mic_label: micLabel, + capture_system_audio: values.systemAudio, + mode: values.mode as "studio" | "instant", + }, + }); + + await open(url); + await showHUD("Starting Cap recording"); +} diff --git a/apps/raycast/src/stop-recording.tsx b/apps/raycast/src/stop-recording.tsx new file mode 100644 index 0000000000..4cec410338 --- /dev/null +++ b/apps/raycast/src/stop-recording.tsx @@ -0,0 +1,7 @@ +import { open, showHUD } from "@raycast/api"; +import { buildDeeplinkUrl } from "./utils"; + +export default async function Command() { + await open(buildDeeplinkUrl("stop_recording")); + await showHUD("Stopping Cap recording"); +} diff --git a/apps/raycast/src/toggle-pause.tsx b/apps/raycast/src/toggle-pause.tsx new file mode 100644 index 0000000000..dc6c4f89cc --- /dev/null +++ b/apps/raycast/src/toggle-pause.tsx @@ -0,0 +1,7 @@ +import { open, showHUD } from "@raycast/api"; +import { buildDeeplinkUrl } from "./utils"; + +export default async function Command() { + await open(buildDeeplinkUrl("toggle_pause_recording")); + await showHUD("Toggling Cap recording pause"); +} diff --git a/apps/raycast/src/utils.ts b/apps/raycast/src/utils.ts new file mode 100644 index 0000000000..7210d4f8e8 --- /dev/null +++ b/apps/raycast/src/utils.ts @@ -0,0 +1,60 @@ +/** + * Builds a Cap desktop deeplink URL. + * + * Cap desktop uses the URL scheme: cap-desktop://action?value={json_encoded_action} + * The JSON value follows the Rust serde enum serialization format: + * - Unit variants: "variant_name" + * - Struct variants: { "variant_name": { field: value } } + */ +export function buildDeeplinkUrl(action: DeepLinkAction): string { + const json = JSON.stringify(action); + return `cap-desktop://action?value=${encodeURIComponent(json)}`; +} + +// Unit variants serialize as plain strings +export type DeepLinkAction = + | "stop_recording" + | "pause_recording" + | "resume_recording" + | "toggle_pause_recording" + | StartRecordingAction + | SetCameraAction + | SetMicrophoneAction + | OpenEditorAction + | OpenSettingsAction; + +export interface StartRecordingAction { + start_recording: { + capture_mode: { screen: string } | { window: string }; + camera: CameraId | null; + mic_label: string | null; + capture_system_audio: boolean; + mode: "studio" | "instant"; + }; +} + +export type CameraId = { ModelID: string } | { DeviceID: string }; + +export interface SetCameraAction { + set_camera: { + camera: CameraId | null; + }; +} + +export interface SetMicrophoneAction { + set_microphone: { + mic_label: string | null; + }; +} + +export interface OpenEditorAction { + open_editor: { + project_path: string; + }; +} + +export interface OpenSettingsAction { + open_settings: { + page: string | null; + }; +} diff --git a/apps/raycast/tsconfig.json b/apps/raycast/tsconfig.json new file mode 100644 index 0000000000..296132dba8 --- /dev/null +++ b/apps/raycast/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "lib": ["ES2023"], + "module": "Node16", + "moduleResolution": "Node16", + "target": "ES2022", + "strict": true, + "jsx": "react-jsx", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "./dist" + }, + "include": ["src/**/*"] +}