diff --git a/apps/desktop/src-tauri/DEEPLINKS.md b/apps/desktop/src-tauri/DEEPLINKS.md new file mode 100644 index 0000000000..d90d3b13cd --- /dev/null +++ b/apps/desktop/src-tauri/DEEPLINKS.md @@ -0,0 +1,43 @@ +# Cap Desktop Deep Links + +Cap registers the `cap-desktop://` URL scheme via `tauri-plugin-deep-link` and exposes a `cap-desktop://action` endpoint that consumes a JSON-encoded `DeepLinkAction` from the `value` query parameter. + +``` +cap-desktop://action?value= +``` + +The full set of supported actions lives in `apps/desktop/src-tauri/src/deeplink_actions.rs`. + +## Recording control + +| Action | JSON payload | Notes | +| --- | --- | --- | +| Start recording | `{"start_recording": { "capture_mode": { "screen": "Display 1" } \| { "window": "Safari — example.com" }, "camera": null \| {"DeviceID": "..."} \| {"ModelID": "..."}, "mic_label": null \| "Built-in Microphone", "capture_system_audio": false, "mode": "studio" \| "instant" }}` | Same wire shape as `commands.startRecording`. `camera` and `mic_label` may be `null` to use the current selection. | +| Stop recording | `"stop_recording"` | No-op if nothing is recording. | +| Pause recording | `"pause_recording"` | No-op if no recording or already paused. | +| Resume recording | `"resume_recording"` | No-op if no recording or not paused. | +| Switch camera | `{"switch_camera": {"camera": null \| {"DeviceID": "..."} \| {"ModelID": "..."}}}` | Set `camera` to `null` to clear the camera selection. Works while recording is active or idle. | +| Switch microphone | `{"switch_microphone": {"mic_label": null \| "Built-in Microphone"}}` | Set `mic_label` to `null` to clear the microphone selection. | + +## Editor / settings + +| Action | JSON payload | Notes | +| --- | --- | --- | +| Open editor | `{"open_editor": {"project_path": "/path/to/project.cap"}}` | macOS also accepts a bare `file://` URL for the same effect. | +| Open settings | `{"open_settings": {"page": null \| "general" \| "recordings" \| ...}}` | `page` selects which settings pane to focus. | + +## Calling from the command line + +macOS: + +```sh +open 'cap-desktop://action?value=%22pause_recording%22' +``` + +Windows: + +```powershell +start 'cap-desktop://action?value=%22pause_recording%22' +``` + +A Raycast extension that wraps the no-view commands lives in `apps/raycast`. diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a117028487..3526968df0 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -26,6 +26,14 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, + PauseRecording, + ResumeRecording, + SwitchCamera { + camera: Option, + }, + SwitchMicrophone { + mic_label: Option, + }, OpenEditor { project_path: PathBuf, }, @@ -89,7 +97,8 @@ impl TryFrom<&Url> for DeepLinkAction { } match url.domain() { - Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction), + Some("action") => Ok(()), + Some(_) => Err(ActionParseFromUrlError::NotAction), _ => Err(ActionParseFromUrlError::Invalid), }?; @@ -147,6 +156,20 @@ 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::SwitchCamera { camera } => { + let state = app.state::>(); + crate::set_camera_input(app.clone(), state, camera, None).await + } + DeepLinkAction::SwitchMicrophone { mic_label } => { + let state = app.state::>(); + crate::set_mic_input(state, mic_label).await + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } @@ -156,3 +179,104 @@ impl DeepLinkAction { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(value: &str) -> Result { + let url = Url::parse_with_params("cap-desktop://action", &[("value", value)]) + .expect("test URL must parse"); + DeepLinkAction::try_from(&url) + } + + #[test] + fn parses_stop_recording() { + let action = parse(r#""stop_recording""#).expect("parses"); + assert!(matches!(action, DeepLinkAction::StopRecording)); + } + + #[test] + fn parses_pause_recording() { + let action = parse(r#""pause_recording""#).expect("parses"); + assert!(matches!(action, DeepLinkAction::PauseRecording)); + } + + #[test] + fn parses_resume_recording() { + let action = parse(r#""resume_recording""#).expect("parses"); + assert!(matches!(action, DeepLinkAction::ResumeRecording)); + } + + #[test] + fn parses_switch_camera_with_label() { + let value = r#"{"switch_camera":{"camera":{"ModelID":"FaceTime HD Camera"}}}"#; + match parse(value).expect("parses") { + DeepLinkAction::SwitchCamera { camera } => { + assert!(camera.is_some()); + } + other => panic!("expected SwitchCamera, got {other:?}"), + } + } + + #[test] + fn parses_switch_camera_to_none() { + let value = r#"{"switch_camera":{"camera":null}}"#; + match parse(value).expect("parses") { + DeepLinkAction::SwitchCamera { camera } => { + assert!(camera.is_none()); + } + other => panic!("expected SwitchCamera, got {other:?}"), + } + } + + #[test] + fn parses_switch_microphone_with_label() { + let value = r#"{"switch_microphone":{"mic_label":"MacBook Pro Microphone"}}"#; + match parse(value).expect("parses") { + DeepLinkAction::SwitchMicrophone { mic_label } => { + assert_eq!(mic_label.as_deref(), Some("MacBook Pro Microphone")); + } + other => panic!("expected SwitchMicrophone, got {other:?}"), + } + } + + #[test] + fn parses_switch_microphone_to_none() { + let value = r#"{"switch_microphone":{"mic_label":null}}"#; + match parse(value).expect("parses") { + DeepLinkAction::SwitchMicrophone { mic_label } => { + assert!(mic_label.is_none()); + } + other => panic!("expected SwitchMicrophone, got {other:?}"), + } + } + + #[test] + fn rejects_non_action_domain() { + let url = Url::parse("cap-desktop://login?token=x").expect("test URL must parse"); + assert!(matches!( + DeepLinkAction::try_from(&url), + Err(ActionParseFromUrlError::NotAction) + )); + } + + #[test] + fn rejects_missing_value() { + let url = Url::parse("cap-desktop://action?other=x").expect("test URL must parse"); + assert!(matches!( + DeepLinkAction::try_from(&url), + Err(ActionParseFromUrlError::Invalid) + )); + } + + #[test] + fn rejects_malformed_json_value() { + let url = + Url::parse("cap-desktop://action?value=%7Bnot-json").expect("test URL must parse"); + assert!(matches!( + DeepLinkAction::try_from(&url), + Err(ActionParseFromUrlError::ParseFailed(_)) + )); + } +} diff --git a/apps/raycast/README.md b/apps/raycast/README.md new file mode 100644 index 0000000000..524e9170b2 --- /dev/null +++ b/apps/raycast/README.md @@ -0,0 +1,23 @@ +# Cap Recorder for Raycast + +Drive Cap's recording controls from Raycast via the `cap-desktop://` URL scheme. Five commands are bundled: + +- **Stop Cap Recording** — stops the active recording. +- **Pause Cap Recording** — pauses the active recording. +- **Resume Cap Recording** — resumes a paused recording. +- **Switch Cap Microphone** — switches the active microphone by label. Leave the argument blank to clear the selection. +- **Switch Cap Camera** — switches the active camera. An 8+ character hex/dash identifier is interpreted as a `DeviceID`; anything else is treated as a `ModelID`. Leave blank to clear. + +The extension is a thin Raycast wrapper: each command builds a `cap-desktop://action?value=` URL and opens it via `@raycast/api`'s `open(...)`. The desktop app's `DeepLinkAction::handle` parses the action and dispatches it. See [`apps/desktop/src-tauri/DEEPLINKS.md`](../desktop/src-tauri/DEEPLINKS.md) for the full action wire format and additional payloads that the app accepts (start recording, open editor, open settings). + +## Development + +```sh +cd apps/raycast +pnpm install +pnpm dev +``` + +`pnpm dev` launches Raycast in development mode against this extension. `pnpm build` produces a Raycast-store-shaped bundle; `pnpm lint` runs Raycast's lint pass. + +Cap must be running for the deep links to land — the macOS app registers the `cap-desktop` scheme on first launch. diff --git a/apps/raycast/icon.png b/apps/raycast/icon.png new file mode 100644 index 0000000000..b1ac1ef7d8 Binary files /dev/null and b/apps/raycast/icon.png differ diff --git a/apps/raycast/package.json b/apps/raycast/package.json new file mode 100644 index 0000000000..af8e4c28a5 --- /dev/null +++ b/apps/raycast/package.json @@ -0,0 +1,81 @@ +{ + "name": "@cap/raycast", + "version": "0.0.1", + "private": true, + "type": "module", + "description": "Control Cap recording from Raycast via cap-desktop:// deep links.", + "scripts": { + "build": "ray build", + "dev": "ray develop", + "lint": "ray lint" + }, + "dependencies": { + "@raycast/api": "^1.83.0", + "@raycast/utils": "^1.17.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19.1.9", + "react": "^19", + "typescript": "^5" + }, + "raycast": { + "schemaVersion": 1, + "title": "Cap Recorder", + "description": "Control Cap recording (start, stop, pause, resume, switch camera/microphone) without leaving the keyboard.", + "icon": "icon.png", + "author": "cap", + "categories": [ + "Productivity", + "Developer Tools" + ], + "commands": [ + { + "name": "stop-recording", + "title": "Stop Cap Recording", + "description": "Stop the active Cap recording.", + "mode": "no-view" + }, + { + "name": "pause-recording", + "title": "Pause Cap Recording", + "description": "Pause the active Cap recording.", + "mode": "no-view" + }, + { + "name": "resume-recording", + "title": "Resume Cap Recording", + "description": "Resume a paused Cap recording.", + "mode": "no-view" + }, + { + "name": "switch-microphone", + "title": "Switch Cap Microphone", + "description": "Switch the active Cap microphone by label.", + "mode": "no-view", + "arguments": [ + { + "name": "label", + "placeholder": "Microphone label (empty to clear)", + "type": "text", + "required": false + } + ] + }, + { + "name": "switch-camera", + "title": "Switch Cap Camera", + "description": "Switch the active Cap camera by model or device ID.", + "mode": "no-view", + "arguments": [ + { + "name": "identifier", + "placeholder": "Camera model or device ID (empty to clear)", + "type": "text", + "required": false + } + ] + } + ] + } +} diff --git a/apps/raycast/src/deeplink.ts b/apps/raycast/src/deeplink.ts new file mode 100644 index 0000000000..cf4d0bc75f --- /dev/null +++ b/apps/raycast/src/deeplink.ts @@ -0,0 +1,36 @@ +import { closeMainWindow, open, showHUD, showToast, Toast } from "@raycast/api"; + +type ActionPayload = + | "stop_recording" + | "pause_recording" + | "resume_recording" + | { + switch_camera: { + camera: { ModelID: string } | { DeviceID: string } | null; + }; + } + | { switch_microphone: { mic_label: string | null } }; + +export function buildActionUrl(payload: ActionPayload): string { + const value = JSON.stringify(payload); + const url = new URL("cap-desktop://action"); + url.searchParams.set("value", value); + return url.toString(); +} + +export async function fireAction( + payload: ActionPayload, + successMessage: string, +): Promise { + try { + await open(buildActionUrl(payload)); + await closeMainWindow(); + await showHUD(successMessage); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to reach Cap", + message: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/apps/raycast/src/pause-recording.ts b/apps/raycast/src/pause-recording.ts new file mode 100644 index 0000000000..039c965751 --- /dev/null +++ b/apps/raycast/src/pause-recording.ts @@ -0,0 +1,5 @@ +import { fireAction } from "./deeplink"; + +export default async function PauseRecording(): Promise { + await fireAction("pause_recording", "Cap recording paused"); +} diff --git a/apps/raycast/src/resume-recording.ts b/apps/raycast/src/resume-recording.ts new file mode 100644 index 0000000000..a26dab441c --- /dev/null +++ b/apps/raycast/src/resume-recording.ts @@ -0,0 +1,5 @@ +import { fireAction } from "./deeplink"; + +export default async function ResumeRecording(): Promise { + await fireAction("resume_recording", "Cap recording resumed"); +} diff --git a/apps/raycast/src/stop-recording.ts b/apps/raycast/src/stop-recording.ts new file mode 100644 index 0000000000..810a261aaa --- /dev/null +++ b/apps/raycast/src/stop-recording.ts @@ -0,0 +1,5 @@ +import { fireAction } from "./deeplink"; + +export default async function StopRecording(): Promise { + await fireAction("stop_recording", "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..c9536232fd --- /dev/null +++ b/apps/raycast/src/switch-camera.ts @@ -0,0 +1,23 @@ +import type { LaunchProps } from "@raycast/api"; +import { fireAction } from "./deeplink"; + +interface Arguments { + identifier?: string; +} + +const DEVICE_ID_PATTERN = + /^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$/i; + +export default async function SwitchCamera( + props: LaunchProps<{ arguments: Arguments }>, +): Promise { + const trimmed = props.arguments.identifier?.trim(); + if (!trimmed || trimmed.length === 0) { + await fireAction({ switch_camera: { camera: null } }, "Cap camera cleared"); + return; + } + const camera = DEVICE_ID_PATTERN.test(trimmed) + ? { DeviceID: trimmed } + : { ModelID: trimmed }; + await fireAction({ switch_camera: { camera } }, `Cap camera → ${trimmed}`); +} diff --git a/apps/raycast/src/switch-microphone.ts b/apps/raycast/src/switch-microphone.ts new file mode 100644 index 0000000000..614292edd6 --- /dev/null +++ b/apps/raycast/src/switch-microphone.ts @@ -0,0 +1,17 @@ +import type { LaunchProps } from "@raycast/api"; +import { fireAction } from "./deeplink"; + +interface Arguments { + label?: string; +} + +export default async function SwitchMicrophone( + props: LaunchProps<{ arguments: Arguments }>, +): Promise { + const trimmed = props.arguments.label?.trim(); + const mic_label = trimmed && trimmed.length > 0 ? trimmed : null; + const message = mic_label + ? `Cap microphone → ${mic_label}` + : "Cap microphone cleared"; + await fireAction({ switch_microphone: { mic_label } }, message); +} diff --git a/apps/raycast/tsconfig.json b/apps/raycast/tsconfig.json new file mode 100644 index 0000000000..5c3e0986c9 --- /dev/null +++ b/apps/raycast/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "include": ["src/**/*", "raycast-env.d.ts"], + "compilerOptions": { + "lib": ["ES2023"], + "module": "ESNext", + "target": "ES2022", + "strict": true, + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "resolveJsonModule": true, + "moduleResolution": "Bundler" + } +}