diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a117028487..d090f9d43e 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -4,9 +4,15 @@ use cap_recording::{ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Url}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; +use tauri_specta::Event; use tracing::trace; -use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; +use crate::{ + App, ArcLock, RequestOpenRecordingPicker, RequestStartRecording, + recording::StartRecordingInputs, recording_settings::RecordingTargetMode, + windows::ShowCapWindow, +}; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -18,6 +24,9 @@ pub enum CaptureMode { #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DeepLinkAction { + StartRecordingWithSettings { + mode: RecordingMode, + }, StartRecording { capture_mode: CaptureMode, camera: Option, @@ -26,6 +35,18 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, + PauseRecording, + ResumeRecording, + TogglePauseRecording, + SetMicInput { + label: Option, + }, + SetCameraInput { + id: Option, + }, + OpenRecordingPicker { + target_mode: Option, + }, OpenEditor { project_path: PathBuf, }, @@ -106,8 +127,62 @@ impl TryFrom<&Url> for DeepLinkAction { } impl DeepLinkAction { + fn confirmation_message(&self) -> Option<&'static str> { + match self { + Self::StartRecordingWithSettings { .. } | Self::StartRecording { .. } => { + Some("A deep link is requesting permission to start a Cap recording.") + } + Self::StopRecording => { + Some("A deep link is requesting permission to stop the current Cap recording.") + } + Self::PauseRecording | Self::ResumeRecording | Self::TogglePauseRecording => { + Some("A deep link is requesting permission to control the current Cap recording.") + } + Self::SetMicInput { .. } => { + Some("A deep link is requesting permission to change Cap's microphone input.") + } + Self::SetCameraInput { .. } => { + Some("A deep link is requesting permission to change Cap's camera input.") + } + Self::OpenRecordingPicker { .. } => { + Some("A deep link is requesting permission to open Cap's recording picker.") + } + Self::OpenEditor { .. } | Self::OpenSettings { .. } => None, + } + } + + fn confirm_if_sensitive(&self, app: &AppHandle) -> Result<(), String> { + let Some(message) = self.confirmation_message() else { + return Ok(()); + }; + + let confirmed = app + .dialog() + .message(message) + .title("Allow Cap deep link?") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + "Allow".to_string(), + "Cancel".to_string(), + )) + .blocking_show(); + + if confirmed { + Ok(()) + } else { + Err("Deep link action cancelled".to_string()) + } + } + pub async fn execute(self, app: &AppHandle) -> Result<(), String> { + self.confirm_if_sensitive(app)?; + match self { + DeepLinkAction::StartRecordingWithSettings { mode } => { + RequestStartRecording { mode } + .emit(app) + .map_err(|err| err.to_string()) + } DeepLinkAction::StartRecording { capture_mode, camera, @@ -147,6 +222,24 @@ 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::SetMicInput { label } => crate::set_mic_input(app.state(), label).await, + DeepLinkAction::SetCameraInput { id } => { + crate::set_camera_input(app.clone(), app.state(), id, None).await + } + DeepLinkAction::OpenRecordingPicker { target_mode } => RequestOpenRecordingPicker { + target_mode, + } + .emit(app) + .map_err(|err| err.to_string()), DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } diff --git a/extensions/raycast/README.md b/extensions/raycast/README.md new file mode 100644 index 0000000000..69c7afe9fe --- /dev/null +++ b/extensions/raycast/README.md @@ -0,0 +1,21 @@ +# Cap Raycast Extension + +Control the Cap desktop app from Raycast using Cap's `cap://action` deep links. + +## Commands + +- Start Studio Recording +- Start Instant Recording +- Record Display +- Record Window +- Record Area +- Pause Recording +- Resume Recording +- Toggle Recording Pause +- Stop Recording +- Set Microphone +- Clear Microphone +- Set Camera +- Clear Camera + +The microphone command expects the exact Cap microphone label. The camera command accepts either a camera device ID or model ID. diff --git a/extensions/raycast/extension-icon.png b/extensions/raycast/extension-icon.png new file mode 100644 index 0000000000..b1ac1ef7d8 Binary files /dev/null and b/extensions/raycast/extension-icon.png differ diff --git a/extensions/raycast/package.json b/extensions/raycast/package.json new file mode 100644 index 0000000000..d81d0eff5c --- /dev/null +++ b/extensions/raycast/package.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap recordings from Raycast.", + "icon": "extension-icon.png", + "author": "CapSoftware", + "categories": [ + "Productivity", + "Developer Tools" + ], + "license": "MIT", + "commands": [ + { + "name": "start-studio-recording", + "title": "Start Studio Recording", + "description": "Start a Cap Studio recording with saved settings.", + "mode": "no-view" + }, + { + "name": "start-instant-recording", + "title": "Start Instant Recording", + "description": "Start a Cap Instant recording with saved settings.", + "mode": "no-view" + }, + { + "name": "record-display", + "title": "Record Display", + "description": "Open Cap's display recording picker.", + "mode": "no-view" + }, + { + "name": "record-window", + "title": "Record Window", + "description": "Open Cap's window recording picker.", + "mode": "no-view" + }, + { + "name": "record-area", + "title": "Record Area", + "description": "Open Cap's area recording picker.", + "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 the current Cap recording.", + "mode": "no-view" + }, + { + "name": "toggle-pause-recording", + "title": "Toggle Recording Pause", + "description": "Pause or resume the current Cap recording.", + "mode": "no-view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "description": "Stop the current Cap recording.", + "mode": "no-view" + }, + { + "name": "set-microphone", + "title": "Set Microphone", + "description": "Switch Cap's selected microphone by label.", + "mode": "view" + }, + { + "name": "clear-microphone", + "title": "Clear Microphone", + "description": "Disable Cap's selected microphone.", + "mode": "no-view" + }, + { + "name": "set-camera", + "title": "Set Camera", + "description": "Switch Cap's selected camera by device or model ID.", + "mode": "view" + }, + { + "name": "clear-camera", + "title": "Clear Camera", + "description": "Disable Cap's selected camera.", + "mode": "no-view" + } + ], + "dependencies": { + "@raycast/api": "^1.104.17" + }, + "devDependencies": { + "@types/node": "22.15.17", + "typescript": "^5.8.3" + }, + "scripts": { + "build": "ray build", + "dev": "ray develop", + "lint": "ray lint", + "publish:raycast": "ray publish" + } +} diff --git a/extensions/raycast/raycast-env.d.ts b/extensions/raycast/raycast-env.d.ts new file mode 100644 index 0000000000..2ac95eafd9 --- /dev/null +++ b/extensions/raycast/raycast-env.d.ts @@ -0,0 +1,72 @@ +/// + +/* 🚧 🚧 🚧 + * This file is auto-generated from the extension's manifest. + * Do not modify manually. Instead, update the `package.json` file. + * 🚧 🚧 🚧 */ + +/* eslint-disable @typescript-eslint/ban-types */ + +type ExtensionPreferences = {} + +/** Preferences accessible in all the extension's commands */ +declare type Preferences = ExtensionPreferences + +declare namespace Preferences { + /** Preferences accessible in the `start-studio-recording` command */ + export type StartStudioRecording = ExtensionPreferences & {} + /** Preferences accessible in the `start-instant-recording` command */ + export type StartInstantRecording = ExtensionPreferences & {} + /** Preferences accessible in the `record-display` command */ + export type RecordDisplay = ExtensionPreferences & {} + /** Preferences accessible in the `record-window` command */ + export type RecordWindow = ExtensionPreferences & {} + /** Preferences accessible in the `record-area` command */ + export type RecordArea = ExtensionPreferences & {} + /** Preferences accessible in the `pause-recording` command */ + export type PauseRecording = ExtensionPreferences & {} + /** Preferences accessible in the `resume-recording` command */ + export type ResumeRecording = ExtensionPreferences & {} + /** Preferences accessible in the `toggle-pause-recording` command */ + export type TogglePauseRecording = ExtensionPreferences & {} + /** Preferences accessible in the `stop-recording` command */ + export type StopRecording = ExtensionPreferences & {} + /** Preferences accessible in the `set-microphone` command */ + export type SetMicrophone = ExtensionPreferences & {} + /** Preferences accessible in the `clear-microphone` command */ + export type ClearMicrophone = ExtensionPreferences & {} + /** Preferences accessible in the `set-camera` command */ + export type SetCamera = ExtensionPreferences & {} + /** Preferences accessible in the `clear-camera` command */ + export type ClearCamera = ExtensionPreferences & {} +} + +declare namespace Arguments { + /** Arguments passed to the `start-studio-recording` command */ + export type StartStudioRecording = {} + /** Arguments passed to the `start-instant-recording` command */ + export type StartInstantRecording = {} + /** Arguments passed to the `record-display` command */ + export type RecordDisplay = {} + /** Arguments passed to the `record-window` command */ + export type RecordWindow = {} + /** Arguments passed to the `record-area` command */ + export type RecordArea = {} + /** Arguments passed to the `pause-recording` command */ + export type PauseRecording = {} + /** Arguments passed to the `resume-recording` command */ + export type ResumeRecording = {} + /** Arguments passed to the `toggle-pause-recording` command */ + export type TogglePauseRecording = {} + /** Arguments passed to the `stop-recording` command */ + export type StopRecording = {} + /** Arguments passed to the `set-microphone` command */ + export type SetMicrophone = {} + /** Arguments passed to the `clear-microphone` command */ + export type ClearMicrophone = {} + /** Arguments passed to the `set-camera` command */ + export type SetCamera = {} + /** Arguments passed to the `clear-camera` command */ + export type ClearCamera = {} +} + diff --git a/extensions/raycast/src/cap.ts b/extensions/raycast/src/cap.ts new file mode 100644 index 0000000000..a11ee95475 --- /dev/null +++ b/extensions/raycast/src/cap.ts @@ -0,0 +1,56 @@ +import { open, showToast, Toast } from "@raycast/api"; + +type RecordingMode = "studio" | "instant" | "screenshot"; +type RecordingTargetMode = "display" | "window" | "area" | "camera"; +type DeviceOrModelID = + | { + DeviceID: string; + } + | { + ModelID: string; + }; + +type DeepLinkAction = + | { + start_recording_with_settings: { + mode: RecordingMode; + }; + } + | "stop_recording" + | "pause_recording" + | "resume_recording" + | "toggle_pause_recording" + | { + open_recording_picker: { + target_mode: RecordingTargetMode | null; + }; + } + | { + set_mic_input: { + label: string | null; + }; + } + | { + set_camera_input: { + id: DeviceOrModelID | null; + }; + }; + +export async function runCapAction(action: DeepLinkAction, title: string) { + const url = new URL("cap://action"); + url.searchParams.set("value", JSON.stringify(action)); + + await open(url.toString()); + await showToast({ + style: Toast.Style.Success, + title, + }); +} + +export function deviceId(value: string): DeviceOrModelID { + return { DeviceID: value }; +} + +export function modelId(value: string): DeviceOrModelID { + return { ModelID: value }; +} diff --git a/extensions/raycast/src/clear-camera.ts b/extensions/raycast/src/clear-camera.ts new file mode 100644 index 0000000000..c23594a255 --- /dev/null +++ b/extensions/raycast/src/clear-camera.ts @@ -0,0 +1,5 @@ +import { runCapAction } from "./cap"; + +export default async function Command() { + await runCapAction({ set_camera_input: { id: null } }, "Clearing camera"); +} diff --git a/extensions/raycast/src/clear-microphone.ts b/extensions/raycast/src/clear-microphone.ts new file mode 100644 index 0000000000..9e80dff832 --- /dev/null +++ b/extensions/raycast/src/clear-microphone.ts @@ -0,0 +1,5 @@ +import { runCapAction } from "./cap"; + +export default async function Command() { + await runCapAction({ set_mic_input: { label: null } }, "Clearing microphone"); +} diff --git a/extensions/raycast/src/pause-recording.ts b/extensions/raycast/src/pause-recording.ts new file mode 100644 index 0000000000..d3c9249d9e --- /dev/null +++ b/extensions/raycast/src/pause-recording.ts @@ -0,0 +1,5 @@ +import { runCapAction } from "./cap"; + +export default async function Command() { + await runCapAction("pause_recording", "Pausing recording"); +} diff --git a/extensions/raycast/src/record-area.ts b/extensions/raycast/src/record-area.ts new file mode 100644 index 0000000000..9fed4a549d --- /dev/null +++ b/extensions/raycast/src/record-area.ts @@ -0,0 +1,8 @@ +import { runCapAction } from "./cap"; + +export default async function Command() { + await runCapAction( + { open_recording_picker: { target_mode: "area" } }, + "Opening area picker", + ); +} diff --git a/extensions/raycast/src/record-display.ts b/extensions/raycast/src/record-display.ts new file mode 100644 index 0000000000..477b50a691 --- /dev/null +++ b/extensions/raycast/src/record-display.ts @@ -0,0 +1,8 @@ +import { runCapAction } from "./cap"; + +export default async function Command() { + await runCapAction( + { open_recording_picker: { target_mode: "display" } }, + "Opening display picker", + ); +} diff --git a/extensions/raycast/src/record-window.ts b/extensions/raycast/src/record-window.ts new file mode 100644 index 0000000000..ec3cc83c88 --- /dev/null +++ b/extensions/raycast/src/record-window.ts @@ -0,0 +1,8 @@ +import { runCapAction } from "./cap"; + +export default async function Command() { + await runCapAction( + { open_recording_picker: { target_mode: "window" } }, + "Opening window picker", + ); +} diff --git a/extensions/raycast/src/resume-recording.ts b/extensions/raycast/src/resume-recording.ts new file mode 100644 index 0000000000..e1ef56d384 --- /dev/null +++ b/extensions/raycast/src/resume-recording.ts @@ -0,0 +1,5 @@ +import { runCapAction } from "./cap"; + +export default async function Command() { + await runCapAction("resume_recording", "Resuming recording"); +} diff --git a/extensions/raycast/src/set-camera.tsx b/extensions/raycast/src/set-camera.tsx new file mode 100644 index 0000000000..25fa207419 --- /dev/null +++ b/extensions/raycast/src/set-camera.tsx @@ -0,0 +1,49 @@ +import { Action, ActionPanel, Form, showToast, Toast } from "@raycast/api"; +import { deviceId, modelId, runCapAction } from "./cap"; + +type Values = { + type: "device_id" | "model_id"; + id: string; +}; + +export default function Command() { + async function handleSubmit(values: Values) { + const id = values.id.trim(); + if (!id) { + await showToast({ + style: Toast.Style.Failure, + title: "Enter a camera ID", + }); + return; + } + + await runCapAction( + { + set_camera_input: { + id: values.type === "device_id" ? deviceId(id) : modelId(id), + }, + }, + "Switching camera", + ); + } + + return ( +
+ + + } + > + + + + + + + ); +} diff --git a/extensions/raycast/src/set-microphone.tsx b/extensions/raycast/src/set-microphone.tsx new file mode 100644 index 0000000000..9751e84f73 --- /dev/null +++ b/extensions/raycast/src/set-microphone.tsx @@ -0,0 +1,37 @@ +import { Action, ActionPanel, Form, showToast, Toast } from "@raycast/api"; +import { runCapAction } from "./cap"; + +type Values = { + label: string; +}; + +export default function Command() { + async function handleSubmit(values: Values) { + const label = values.label.trim(); + if (!label) { + await showToast({ + style: Toast.Style.Failure, + title: "Enter a microphone label", + }); + return; + } + + await runCapAction({ set_mic_input: { label } }, "Switching microphone"); + } + + return ( +
+ + + } + > + + + ); +} diff --git a/extensions/raycast/src/start-instant-recording.ts b/extensions/raycast/src/start-instant-recording.ts new file mode 100644 index 0000000000..be4fcb272b --- /dev/null +++ b/extensions/raycast/src/start-instant-recording.ts @@ -0,0 +1,8 @@ +import { runCapAction } from "./cap"; + +export default async function Command() { + await runCapAction( + { start_recording_with_settings: { mode: "instant" } }, + "Starting Instant recording", + ); +} diff --git a/extensions/raycast/src/start-studio-recording.ts b/extensions/raycast/src/start-studio-recording.ts new file mode 100644 index 0000000000..7c498f03e5 --- /dev/null +++ b/extensions/raycast/src/start-studio-recording.ts @@ -0,0 +1,8 @@ +import { runCapAction } from "./cap"; + +export default async function Command() { + await runCapAction( + { start_recording_with_settings: { mode: "studio" } }, + "Starting Studio recording", + ); +} diff --git a/extensions/raycast/src/stop-recording.ts b/extensions/raycast/src/stop-recording.ts new file mode 100644 index 0000000000..83a838dd73 --- /dev/null +++ b/extensions/raycast/src/stop-recording.ts @@ -0,0 +1,5 @@ +import { runCapAction } from "./cap"; + +export default async function Command() { + await runCapAction("stop_recording", "Stopping recording"); +} diff --git a/extensions/raycast/src/toggle-pause-recording.ts b/extensions/raycast/src/toggle-pause-recording.ts new file mode 100644 index 0000000000..a945fa0131 --- /dev/null +++ b/extensions/raycast/src/toggle-pause-recording.ts @@ -0,0 +1,5 @@ +import { runCapAction } from "./cap"; + +export default async function Command() { + await runCapAction("toggle_pause_recording", "Toggling recording pause"); +} diff --git a/extensions/raycast/tsconfig.json b/extensions/raycast/tsconfig.json new file mode 100644 index 0000000000..235b8bb9f2 --- /dev/null +++ b/extensions/raycast/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "lib": ["ES2023"], + "module": "commonjs", + "target": "ES2023", + "jsx": "preserve", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node" + }, + "include": ["src"] +}