diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a117028487..b0500d14ad 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, + ToggleMicrophone { + mic_label: Option, + }, + ToggleCamera { + camera: Option, + }, OpenEditor { project_path: PathBuf, }, @@ -89,19 +98,115 @@ impl TryFrom<&Url> for DeepLinkAction { } match url.domain() { + Some("action") => parse_action_url(url), Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction), _ => Err(ActionParseFromUrlError::Invalid), - }?; - - let params = url - .query_pairs() - .collect::>(); - let json_value = params - .get("value") - .ok_or(ActionParseFromUrlError::Invalid)?; - let action: Self = serde_json::from_str(json_value) - .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?; - Ok(action) + } + } +} + +fn parse_action_url(url: &Url) -> Result { + let params = url + .query_pairs() + .collect::>(); + + if let Some(action) = params.get("action").or_else(|| params.get("value")) { + return serde_json::from_str::(action) + .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string())); + } + + let command = url + .path_segments() + .and_then(|mut segments| segments.next()) + .filter(|segment| !segment.is_empty()) + .ok_or(ActionParseFromUrlError::Invalid)?; + + match command { + "record" | "start-recording" | "start_recording" => Ok(DeepLinkAction::StartRecording { + capture_mode: parse_capture_mode(¶ms)?, + camera: parse_camera(¶ms)?, + mic_label: params + .get("mic") + .or_else(|| params.get("mic_label")) + .map(|v| v.to_string()), + capture_system_audio: parse_bool_param(¶ms, "system_audio", false), + mode: parse_recording_mode(¶ms)?, + }), + "stop" | "stop-recording" | "stop_recording" => Ok(DeepLinkAction::StopRecording), + "pause" | "pause-recording" | "pause_recording" => Ok(DeepLinkAction::PauseRecording), + "resume" | "resume-recording" | "resume_recording" => Ok(DeepLinkAction::ResumeRecording), + "toggle-pause" | "toggle_pause" | "toggle-pause-recording" | "toggle_pause_recording" => { + Ok(DeepLinkAction::TogglePauseRecording) + } + "toggle-microphone" | "toggle_microphone" | "mic" => Ok(DeepLinkAction::ToggleMicrophone { + mic_label: params + .get("mic") + .or_else(|| params.get("mic_label")) + .map(|v| v.to_string()), + }), + "toggle-camera" | "toggle_camera" | "camera" => Ok(DeepLinkAction::ToggleCamera { + camera: parse_camera(¶ms)?, + }), + "settings" | "open-settings" | "open_settings" => Ok(DeepLinkAction::OpenSettings { + page: params.get("page").map(|v| v.to_string()), + }), + _ => Err(ActionParseFromUrlError::Invalid), + } +} + +fn parse_capture_mode( + params: &std::collections::HashMap, std::borrow::Cow<'_, str>>, +) -> Result { + if let Some(display) = params.get("display").or_else(|| params.get("screen")) { + return Ok(CaptureMode::Screen(display.to_string())); + } + + if let Some(window) = params.get("window") { + return Ok(CaptureMode::Window(window.to_string())); + } + + Err(ActionParseFromUrlError::Invalid) +} + +fn parse_camera( + params: &std::collections::HashMap, std::borrow::Cow<'_, str>>, +) -> Result, ActionParseFromUrlError> { + if let Some(device_id) = params + .get("camera_device_id") + .or_else(|| params.get("camera")) + { + return Ok(Some(DeviceOrModelID::DeviceID(device_id.to_string()))); + } + + if let Some(model_id) = params.get("camera_model_id") { + return serde_json::from_value(serde_json::json!({ "ModelID": model_id.to_string() })) + .map(Some) + .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string())); + } + + Ok(None) +} + +fn parse_bool_param( + params: &std::collections::HashMap, std::borrow::Cow<'_, str>>, + key: &str, + default: bool, +) -> bool { + params + .get(key) + .and_then(|value| value.parse::().ok()) + .unwrap_or(default) +} + +fn parse_recording_mode( + params: &std::collections::HashMap, std::borrow::Cow<'_, str>>, +) -> Result { + match params.get("mode").map(|v| v.as_ref()) { + Some("instant") => Ok(RecordingMode::Instant), + Some("studio") | None => Ok(RecordingMode::Studio), + Some(value) => Err(ActionParseFromUrlError::ParseFailed(format!( + "Unsupported recording mode: {value}" + ))), } } @@ -121,16 +226,20 @@ impl DeepLinkAction { crate::set_mic_input(state.clone(), mic_label).await?; let capture_target: ScreenCaptureTarget = match capture_mode { - CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays() - .into_iter() - .find(|(s, _)| s.name == name) - .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id }) - .ok_or(format!("No screen with name \"{}\"", &name))?, - CaptureMode::Window(name) => cap_recording::screen_capture::list_windows() - .into_iter() - .find(|(w, _)| w.name == name) - .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) - .ok_or(format!("No window with name \"{}\"", &name))?, + CaptureMode::Screen(name) => { + cap_recording::sources::screen_capture::list_displays() + .into_iter() + .find(|(s, _)| s.name == name) + .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id }) + .ok_or(format!("No screen with name \"{}\"", &name))? + } + CaptureMode::Window(name) => { + cap_recording::sources::screen_capture::list_windows() + .into_iter() + .find(|(w, _)| w.name == name) + .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) + .ok_or(format!("No window with name \"{}\"", &name))? + } }; let inputs = StartRecordingInputs { @@ -147,6 +256,19 @@ impl DeepLinkAction { DeepLinkAction::StopRecording => { crate::recording::stop_recording(app.clone(), app.state()).await } + DeepLinkAction::PauseRecording => crate::recording::pause_recording(app.state()).await, + DeepLinkAction::ResumeRecording => { + crate::recording::resume_recording(app.state()).await + } + DeepLinkAction::TogglePauseRecording => { + crate::recording::toggle_pause_recording(app.state()).await + } + DeepLinkAction::ToggleMicrophone { mic_label } => { + crate::set_mic_input(app.state(), mic_label).await + } + DeepLinkAction::ToggleCamera { camera } => { + crate::set_camera_input(app.clone(), app.state(), camera, None).await + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } diff --git a/apps/raycast/.eslintrc.json b/apps/raycast/.eslintrc.json new file mode 100644 index 0000000000..bcf78d698a --- /dev/null +++ b/apps/raycast/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["@raycast"] +} diff --git a/apps/raycast/assets/command-icon.png b/apps/raycast/assets/command-icon.png new file mode 100644 index 0000000000..a3749e0411 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..cc0682eb05 --- /dev/null +++ b/apps/raycast/package.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap-raycast", + "title": "Cap", + "description": "Control Cap recordings with deeplinks.", + "icon": "command-icon.png", + "author": "Cap", + "license": "MIT", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "description": "Start a Cap recording with a display or window target.", + "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 the current Cap recording.", + "mode": "no-view" + }, + { + "name": "toggle-pause-recording", + "title": "Toggle Pause Recording", + "description": "Toggle pause for the current Cap recording.", + "mode": "no-view" + }, + { + "name": "set-microphone", + "title": "Set Microphone", + "description": "Set or clear Cap's microphone input.", + "mode": "view" + }, + { + "name": "set-camera", + "title": "Set Camera", + "description": "Set or clear Cap's camera input.", + "mode": "view" + }, + { + "name": "open-settings", + "title": "Open Settings", + "description": "Open Cap settings.", + "mode": "view" + } + ], + "dependencies": { + "@raycast/api": "^1.83.2" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "^20.17.6", + "eslint": "^8.57.1", + "prettier": "^3.3.3", + "typescript": "^5.6.3" + }, + "scripts": { + "build": "ray build -e dist", + "dev": "ray develop", + "lint": "ray lint", + "fix-lint": "ray lint --fix" + } +} diff --git a/apps/raycast/raycast-env.d.ts b/apps/raycast/raycast-env.d.ts new file mode 100644 index 0000000000..98de326395 --- /dev/null +++ b/apps/raycast/raycast-env.d.ts @@ -0,0 +1,52 @@ +/// + +/* 🚧 🚧 🚧 + * 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-recording` command */ + export type StartRecording = ExtensionPreferences & {} + /** Preferences accessible in the `stop-recording` command */ + export type StopRecording = 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 `set-microphone` command */ + export type SetMicrophone = ExtensionPreferences & {} + /** Preferences accessible in the `set-camera` command */ + export type SetCamera = ExtensionPreferences & {} + /** Preferences accessible in the `open-settings` command */ + export type OpenSettings = ExtensionPreferences & {} +} + +declare namespace Arguments { + /** Arguments passed to the `start-recording` command */ + export type StartRecording = {} + /** Arguments passed to the `stop-recording` command */ + export type StopRecording = {} + /** 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 `set-microphone` command */ + export type SetMicrophone = {} + /** Arguments passed to the `set-camera` command */ + export type SetCamera = {} + /** Arguments passed to the `open-settings` command */ + export type OpenSettings = {} +} + diff --git a/apps/raycast/src/cap.ts b/apps/raycast/src/cap.ts new file mode 100644 index 0000000000..106358c280 --- /dev/null +++ b/apps/raycast/src/cap.ts @@ -0,0 +1,21 @@ +import { open, showHUD } from "@raycast/api"; + +const SCHEME = "cap-desktop://action"; + +export type QueryParams = Record; + +export async function openCapAction(action: string, params: QueryParams = {}) { + const url = new URL(`${SCHEME}/${action}`); + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== "") + url.searchParams.set(key, String(value)); + } + + await open(url.toString()); +} + +export async function runNoViewAction(action: string, hudTitle: string) { + await openCapAction(action); + await showHUD(hudTitle); +} diff --git a/apps/raycast/src/open-settings.tsx b/apps/raycast/src/open-settings.tsx new file mode 100644 index 0000000000..bd52b4390e --- /dev/null +++ b/apps/raycast/src/open-settings.tsx @@ -0,0 +1,30 @@ +import { Action, ActionPanel, Form, showToast, Toast } from "@raycast/api"; +import { openCapAction } from "./cap"; + +type Values = { page?: string }; + +export default function Command() { + async function submit(values: Values) { + await openCapAction("settings", { page: values.page?.trim() }); + await showToast({ + style: Toast.Style.Success, + title: "Opening Cap settings", + }); + } + + return ( +
+ + + } + > + + + ); +} diff --git a/apps/raycast/src/pause-recording.ts b/apps/raycast/src/pause-recording.ts new file mode 100644 index 0000000000..a63e4f15d9 --- /dev/null +++ b/apps/raycast/src/pause-recording.ts @@ -0,0 +1,4 @@ +import { runNoViewAction } from "./cap"; +export default async function Command() { + await runNoViewAction("pause", "Pausing Cap recording"); +} diff --git a/apps/raycast/src/resume-recording.ts b/apps/raycast/src/resume-recording.ts new file mode 100644 index 0000000000..ff5499042f --- /dev/null +++ b/apps/raycast/src/resume-recording.ts @@ -0,0 +1,4 @@ +import { runNoViewAction } from "./cap"; +export default async function Command() { + await runNoViewAction("resume", "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..027b8476ac --- /dev/null +++ b/apps/raycast/src/set-camera.tsx @@ -0,0 +1,32 @@ +import { Action, ActionPanel, Form, showToast, Toast } from "@raycast/api"; +import { openCapAction } from "./cap"; + +type Values = { cameraDeviceId?: string }; + +export default function Command() { + async function submit(values: Values) { + await openCapAction("toggle-camera", { + camera_device_id: values.cameraDeviceId?.trim(), + }); + await showToast({ + style: Toast.Style.Success, + title: values.cameraDeviceId ? "Setting camera" : "Clearing camera", + }); + } + + return ( +
+ + + } + > + + + ); +} diff --git a/apps/raycast/src/set-microphone.tsx b/apps/raycast/src/set-microphone.tsx new file mode 100644 index 0000000000..629287efd1 --- /dev/null +++ b/apps/raycast/src/set-microphone.tsx @@ -0,0 +1,32 @@ +import { Action, ActionPanel, Form, showToast, Toast } from "@raycast/api"; +import { openCapAction } from "./cap"; + +type Values = { microphone?: string }; + +export default function Command() { + async function submit(values: Values) { + await openCapAction("toggle-microphone", { + mic: values.microphone?.trim(), + }); + await showToast({ + style: Toast.Style.Success, + title: values.microphone ? "Setting microphone" : "Clearing microphone", + }); + } + + return ( +
+ + + } + > + + + ); +} diff --git a/apps/raycast/src/start-recording.tsx b/apps/raycast/src/start-recording.tsx new file mode 100644 index 0000000000..141e92d11e --- /dev/null +++ b/apps/raycast/src/start-recording.tsx @@ -0,0 +1,76 @@ +import { Action, ActionPanel, Form, showToast, Toast } from "@raycast/api"; +import { openCapAction } from "./cap"; + +type Values = { + mode: "studio" | "instant"; + targetType: "display" | "window"; + targetName: string; + microphone?: string; + cameraDeviceId?: string; + systemAudio: boolean; +}; + +export default function Command() { + async function submit(values: Values) { + if (!values.targetName.trim()) { + await showToast({ + style: Toast.Style.Failure, + title: "Target name is required", + }); + return; + } + + await openCapAction("record", { + mode: values.mode, + [values.targetType]: values.targetName.trim(), + mic: values.microphone?.trim(), + camera_device_id: values.cameraDeviceId?.trim(), + system_audio: values.systemAudio, + }); + + await showToast({ + style: Toast.Style.Success, + title: "Starting Cap recording", + }); + } + + return ( +
+ + + } + > + + + + + + + + + + + + + + ); +} diff --git a/apps/raycast/src/stop-recording.ts b/apps/raycast/src/stop-recording.ts new file mode 100644 index 0000000000..7afa758413 --- /dev/null +++ b/apps/raycast/src/stop-recording.ts @@ -0,0 +1,4 @@ +import { runNoViewAction } from "./cap"; +export default async function Command() { + await runNoViewAction("stop", "Stopping Cap recording"); +} diff --git a/apps/raycast/src/toggle-pause-recording.ts b/apps/raycast/src/toggle-pause-recording.ts new file mode 100644 index 0000000000..3b752edd78 --- /dev/null +++ b/apps/raycast/src/toggle-pause-recording.ts @@ -0,0 +1,4 @@ +import { runNoViewAction } from "./cap"; +export default async function Command() { + await runNoViewAction("toggle-pause", "Toggling Cap pause"); +} diff --git a/apps/raycast/tsconfig.json b/apps/raycast/tsconfig.json new file mode 100644 index 0000000000..89da8c37ff --- /dev/null +++ b/apps/raycast/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "lib": ["ES2023"], + "module": "commonjs", + "target": "ES2022", + "strict": true, + "jsx": "preserve", + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + } +}