diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index fce75b4a84..e30e72a5a0 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Url}; use tracing::trace; -use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; +use crate::{App, ArcLock, general_settings::GeneralSettingsStore, recording::StartRecordingInputs, windows::ShowCapWindow}; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -26,6 +26,14 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, + PauseRecording, + ResumeRecording, + SwitchMicrophone { + mic_label: Option, + }, + SwitchCamera { + camera_id: Option, + }, OpenEditor { project_path: PathBuf, }, @@ -106,6 +114,29 @@ impl TryFrom<&Url> for DeepLinkAction { impl DeepLinkAction { pub async fn execute(self, app: &AppHandle) -> Result<(), String> { + // Check if deeplink actions are enabled for sensitive operations + let requires_permission = matches!( + &self, + DeepLinkAction::StartRecording { .. } + | DeepLinkAction::StopRecording + | DeepLinkAction::PauseRecording + | DeepLinkAction::ResumeRecording + | DeepLinkAction::SwitchMicrophone { .. } + | DeepLinkAction::SwitchCamera { .. } + ); + + if requires_permission { + let settings = GeneralSettingsStore::get(app) + .map_err(|e| format!("Failed to read settings: {e}"))? + .unwrap_or_default(); + + if !settings.enable_deeplink_actions { + return Err( + "Deeplink actions are disabled. Enable 'Allow deeplink actions' in Settings to use this feature.".to_string() + ); + } + } + match self { DeepLinkAction::StartRecording { capture_mode, @@ -146,6 +177,18 @@ 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::SwitchMicrophone { mic_label } => { + crate::set_mic_input(app.state(), mic_label).await + } + DeepLinkAction::SwitchCamera { camera_id } => { + crate::set_camera_input(app.clone(), app.state(), camera_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/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index b9c9318d69..6b1005a813 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -139,6 +139,8 @@ pub struct GeneralSettingsStore { pub main_window_position: Option, #[serde(default)] pub camera_window_position: Option, + #[serde(default)] + pub enable_deeplink_actions: bool, } fn default_enable_native_camera_preview() -> bool { @@ -207,6 +209,7 @@ impl Default for GeneralSettingsStore { editor_preview_quality: EditorPreviewQuality::Half, main_window_position: None, camera_window_position: None, + enable_deeplink_actions: false, } } } diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 4ba8d8c8a5..e77fc5aba6 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -442,6 +442,12 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { handleChange("enableNotifications", value); }} /> + handleChange("enableDeeplinkActions", value)} + /> )} diff --git a/apps/raycast-extension/.gitignore b/apps/raycast-extension/.gitignore new file mode 100644 index 0000000000..94510244f1 --- /dev/null +++ b/apps/raycast-extension/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.DS_Store +*.log diff --git a/apps/raycast-extension/README.md b/apps/raycast-extension/README.md new file mode 100644 index 0000000000..af5291dff3 --- /dev/null +++ b/apps/raycast-extension/README.md @@ -0,0 +1,63 @@ +# Cap Raycast Extension + +A Raycast extension to control the Cap recording app via deeplinks. + +## Features + +- **Start Recording**: Start a new recording session +- **Stop Recording**: Stop the current recording +- **Pause Recording**: Pause the current recording +- **Resume Recording**: Resume a paused recording +- **Switch Microphone**: Switch to a different microphone input +- **Switch Camera**: Switch to a different camera input + +## Installation + +1. Open Raycast +2. Go to Extensions → Create Extension +3. Select "Import Extension" +4. Point to this directory + +Or use the Raycast CLI: + +```bash +cd apps/raycast-extension +pnpm install +ray dev +``` + +## Usage + +All commands are available through Raycast's command palette. Simply search for "Cap" and select the desired action. + +**Important Security Note**: Deeplink actions for recording control (start, stop, pause, resume, switch devices) require opt-in permission in Cap settings. Go to Settings → General and enable "Allow deeplink actions" to use these features. This prevents unauthorized apps or websites from controlling your recordings via URL schemes. + +## Deeplink Format + +The extension uses the `cap-desktop://` URL scheme to communicate with the Cap app. The format is: + +``` +cap-desktop://action?value={JSON_ACTION} +``` + +Where `JSON_ACTION` is a JSON-encoded action matching the `DeepLinkAction` enum in the Cap desktop app. + +## Development + +```bash +# Install dependencies +pnpm install + +# Develop in Raycast +pnpm dev + +# Build for production +pnpm build + +# Lint +pnpm lint +``` + +## License + +MIT diff --git a/apps/raycast-extension/icon.png b/apps/raycast-extension/icon.png new file mode 100644 index 0000000000..718226caf2 Binary files /dev/null and b/apps/raycast-extension/icon.png differ diff --git a/apps/raycast-extension/package.json b/apps/raycast-extension/package.json new file mode 100644 index 0000000000..a601045c03 --- /dev/null +++ b/apps/raycast-extension/package.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap recording app via Raycast", + "icon": "icon.png", + "author": "CapSoftware", + "categories": [ + "Productivity", + "Media" + ], + "license": "MIT", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "description": "Start a new recording in Cap", + "mode": "no-view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "description": "Stop the current recording", + "mode": "no-view" + }, + { + "name": "pause-recording", + "title": "Pause Recording", + "description": "Pause the current recording", + "mode": "no-view" + }, + { + "name": "resume-recording", + "title": "Resume Recording", + "description": "Resume a paused recording", + "mode": "no-view" + }, + { + "name": "switch-microphone", + "title": "Switch Microphone", + "description": "Switch to a different microphone", + "mode": "view" + }, + { + "name": "switch-camera", + "title": "Switch Camera", + "description": "Switch to a different camera", + "mode": "view" + } + ], + "dependencies": { + "@raycast/api": "^1.69.0" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "^20.11.5", + "@types/react": "^18.2.48", + "eslint": "^8.56.0", + "prettier": "^3.2.4", + "typescript": "^5.3.3" + }, + "scripts": { + "build": "ray build -e dist", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint", + "publish": "npx @raycast/api@latest publish" + } +} diff --git a/apps/raycast-extension/src/pause-recording.ts b/apps/raycast-extension/src/pause-recording.ts new file mode 100644 index 0000000000..a90459ed09 --- /dev/null +++ b/apps/raycast-extension/src/pause-recording.ts @@ -0,0 +1,19 @@ +import { open, showToast, Toast } from "@raycast/api"; + +export default async function Command() { + const action = { pause_recording: null }; + + const url = `cap-desktop://action?value=${encodeURIComponent(JSON.stringify(action))}`; + + try { + await open(url); + await showToast({ style: Toast.Style.Success, title: "Recording paused" }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to pause recording", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +} + diff --git a/apps/raycast-extension/src/resume-recording.ts b/apps/raycast-extension/src/resume-recording.ts new file mode 100644 index 0000000000..c4c01babbc --- /dev/null +++ b/apps/raycast-extension/src/resume-recording.ts @@ -0,0 +1,35 @@ +import { open, showToast, Toast } from "@raycast/api"; + +export default async function Command() { + const action = { resume_recording: null }; + + const url = `cap-desktop://action?value=${encodeURIComponent(JSON.stringify(action))}`; + + try { + await open(url); + await showToast({ style: Toast.Style.Success, title: "Recording resumed" }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to resume recording", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +} +import { open, showToast, Toast } from "@raycast/api"; + +export default async function Command() { + const action = { resume_recording: null }; + + const url = `cap-desktop://action?value=${encodeURIComponent(JSON.stringify(action))}`; + + try { + await open(url); + await showToast({ style: Toast.Style.Success, title: "Recording resumed" }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to resume recording", + message: error instanceof Error ? error.message : "Unknown error", + }); + } diff --git a/apps/raycast-extension/src/start-recording.ts b/apps/raycast-extension/src/start-recording.ts new file mode 100644 index 0000000000..038aa24725 --- /dev/null +++ b/apps/raycast-extension/src/start-recording.ts @@ -0,0 +1,66 @@ +import { open, showToast, Toast } from "@raycast/api"; + +export default async function Command() { + const action = { + start_recording: { + capture_mode: { screen: "Main Display" }, + camera: null, + mic_label: null, + capture_system_audio: false, + mode: "studio", + }, + }; + + const url = `cap-desktop://action?value=${encodeURIComponent(JSON.stringify(action))}`; + + try { + await open(url); + await showToast({ style: Toast.Style.Success, title: "Started recording" }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to start recording", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +import { open, showToast, Toast } from "@raycast/api"; + +export default async function Command() { + const action = { + start_recording: { + capture_mode: { screen: "Main Display" }, + camera: null, + mic_label: null, + capture_system_audio: false, + mode: "studio", + }, + }; + + const url = `cap-desktop://action?value=${encodeURIComponent(JSON.stringify(action))}`; + + try { + await open(url); + await showToast({ style: Toast.Style.Success, title: "Started recording" }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to start recording", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +} + const action = { + start_recording: { + capture_mode: { screen: "Main Display" }, + camera: null, + mic_label: null, + capture_system_audio: false, + mode: "studio", + }, + }; + + const url = `cap-desktop://action?value=${encodeURIComponent(JSON.stringify(action))}`; + await open(url); +} diff --git a/apps/raycast-extension/src/stop-recording.ts b/apps/raycast-extension/src/stop-recording.ts new file mode 100644 index 0000000000..60d031839d --- /dev/null +++ b/apps/raycast-extension/src/stop-recording.ts @@ -0,0 +1,18 @@ +import { open, showToast, Toast } from "@raycast/api"; + +export default async function Command() { + try { + const action = { stop_recording: null }; + + const url = `cap-desktop://action?value=${encodeURIComponent(JSON.stringify(action))}`; + await open(url); + + await showToast({ style: Toast.Style.Success, title: "Stopped recording" }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to stop recording", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/apps/raycast-extension/src/switch-camera.tsx b/apps/raycast-extension/src/switch-camera.tsx new file mode 100644 index 0000000000..905685054c --- /dev/null +++ b/apps/raycast-extension/src/switch-camera.tsx @@ -0,0 +1,95 @@ +import { List, ActionPanel, Action, showToast, Toast, open } from "@raycast/api"; + +export default function Command() { + const isLoading = false; + + + const handleSwitchCamera = async (cameraId: string | null) => { + try { + // Camera ID can be either a model string or a device ID object + // For simplicity, we'll use model string format + const action = { + switch_camera: { + const action = { + switch_camera: { + camera_id: cameraId ? { DeviceID: cameraId } : null, + }, + }; + }, + }; +import { Action, ActionPanel, Form, List, Toast, open, showToast } from "@raycast/api"; +import { useState } from "react"; + +async function switchCamera(cameraId: string | null) { + const action = { + switch_camera: { + camera_id: cameraId ? { DeviceID: cameraId } : null, + }, + }; + + const url = `cap-desktop://action?value=${encodeURIComponent(JSON.stringify(action))}`; + + try { + await open(url); + await showToast({ + style: Toast.Style.Success, + title: "Camera switched", + message: cameraId ? `Switched to ${cameraId}` : "Camera disabled", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to switch camera", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +function CameraIdForm() { + const [cameraId, setCameraId] = useState(""); + + return ( +
+ switchCamera(cameraId.trim() || null)} + /> + + } + > + + + ); +} + +export default function Command() { + return ( + + + switchCamera(null)} /> + + } + /> + + } /> + + } + /> + + ); +} diff --git a/apps/raycast-extension/src/switch-microphone.tsx b/apps/raycast-extension/src/switch-microphone.tsx new file mode 100644 index 0000000000..24251f0a29 --- /dev/null +++ b/apps/raycast-extension/src/switch-microphone.tsx @@ -0,0 +1,76 @@ +import { Action, ActionPanel, Form, List, Toast, open, showToast } from "@raycast/api"; +import { useState } from "react"; + +function buildActionUrl(action: unknown) { + return `cap-desktop://action?value=${encodeURIComponent(JSON.stringify(action))}`; +} + +async function switchMicrophone(micLabel: string | null) { + const action = { + switch_microphone: { + mic_label: micLabel, + }, + }; + + try { + await open(buildActionUrl(action)); + await showToast({ + style: Toast.Style.Success, + title: micLabel ? "Microphone switched" : "Microphone disabled", + message: micLabel ?? undefined, + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to switch microphone", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +function MicLabelForm() { + const [micLabel, setMicLabel] = useState(""); + + return ( +
+ switchMicrophone(micLabel.trim() || null)} + /> + + } + > + + + ); +} + +export default function Command() { + return ( + + + switchMicrophone(null)} /> + + } + /> + + } /> + + } + /> + + ); +} diff --git a/apps/raycast-extension/tsconfig.json b/apps/raycast-extension/tsconfig.json new file mode 100644 index 0000000000..c6cc889051 --- /dev/null +++ b/apps/raycast-extension/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2021", + "lib": ["ES2021"], + "module": "commonjs", + "moduleResolution": "node", + "jsx": "react", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"] +}