diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a1170284877..694d2906148 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -1,12 +1,17 @@ use cap_recording::{ RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget, }; +use scap_targets::Display; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Url}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use tracing::trace; -use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; +use crate::{ + App, ArcLock, recording::StartRecordingInputs, recording_settings::RecordingSettingsStore, + windows::ShowCapWindow, +}; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -18,6 +23,7 @@ pub enum CaptureMode { #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DeepLinkAction { + StartDefaultRecording, StartRecording { capture_mode: CaptureMode, camera: Option, @@ -88,10 +94,19 @@ impl TryFrom<&Url> for DeepLinkAction { .map_err(|_| ActionParseFromUrlError::Invalid); } - match url.domain() { - Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction), - _ => Err(ActionParseFromUrlError::Invalid), - }?; + if url.scheme() == "cap" { + return match url.host_str() { + Some("record") => Ok(Self::StartDefaultRecording), + Some("stop") => Ok(Self::StopRecording), + _ => Err(ActionParseFromUrlError::Invalid), + }; + } + + match url.host_str() { + Some("action") => {} + Some(_) => return Err(ActionParseFromUrlError::NotAction), + None => return Err(ActionParseFromUrlError::Invalid), + } let params = url .query_pairs() @@ -108,6 +123,44 @@ impl TryFrom<&Url> for DeepLinkAction { impl DeepLinkAction { pub async fn execute(self, app: &AppHandle) -> Result<(), String> { match self { + DeepLinkAction::StartDefaultRecording => { + let proceed = app + .dialog() + .message("Start a new recording from an external deep link?") + .title("Cap") + .kind(MessageDialogKind::Info) + .buttons(MessageDialogButtons::OkCancel) + .blocking_show(); + + if !proceed { + return Ok(()); + } + + let state = app.state::>(); + let settings = RecordingSettingsStore::get(app) + .ok() + .flatten() + .unwrap_or_default(); + + crate::set_mic_input(state.clone(), settings.mic_name).await?; + crate::set_camera_input(app.clone(), state.clone(), settings.camera_id, None) + .await?; + + let inputs = StartRecordingInputs { + capture_target: settings.target.unwrap_or_else(|| { + ScreenCaptureTarget::Display { + id: Display::primary().id(), + } + }), + mode: settings.mode.unwrap_or_default(), + capture_system_audio: settings.system_audio, + organization_id: settings.organization_id, + }; + + crate::recording::start_recording(app.clone(), state, inputs) + .await + .map(|_| ()) + } DeepLinkAction::StartRecording { capture_mode, camera, @@ -156,3 +209,30 @@ impl DeepLinkAction { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_cap_record_as_start_default_recording() { + let url = Url::parse("cap://record").expect("valid cap record url"); + let action = DeepLinkAction::try_from(&url).expect("cap record should parse"); + assert!(matches!(action, DeepLinkAction::StartDefaultRecording)); + } + + #[test] + fn parses_cap_stop_as_stop_recording() { + let url = Url::parse("cap://stop").expect("valid cap stop url"); + let action = DeepLinkAction::try_from(&url).expect("cap stop should parse"); + assert!(matches!(action, DeepLinkAction::StopRecording)); + } + + #[test] + fn parses_existing_action_format() { + let url = Url::parse("cap-desktop://action?value=%7B%22stop_recording%22%3Anull%7D") + .expect("valid action deep link"); + let action = DeepLinkAction::try_from(&url).expect("action deep link should parse"); + assert!(matches!(action, DeepLinkAction::StopRecording)); + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 4d8febbeb97..7caa974d382 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -30,7 +30,7 @@ "updater": { "active": false, "pubkey": "" }, "deep-link": { "desktop": { - "schemes": ["cap-desktop"] + "schemes": ["cap-desktop", "cap"] } } }, diff --git a/extensions/raycast/README.md b/extensions/raycast/README.md new file mode 100644 index 00000000000..279bbe5005e --- /dev/null +++ b/extensions/raycast/README.md @@ -0,0 +1,14 @@ +# Cap Raycast Extension + +Lightweight Raycast commands for Cap Desktop. + +## Commands +- Start Recording: opens `cap://record` and starts recording with your current Cap defaults. +- Stop Recording: opens `cap://stop` and stops the active recording. +- Open Dashboard: opens `https://cap.so/dashboard` in your browser. + +## Local usage +1. `cd extensions/raycast` +2. `npm install` +3. `npm run dev` +4. In Raycast, import this extension from the local folder. diff --git a/extensions/raycast/assets/cap-icon.png b/extensions/raycast/assets/cap-icon.png new file mode 100644 index 00000000000..b1ac1ef7d8a Binary files /dev/null and b/extensions/raycast/assets/cap-icon.png differ diff --git a/extensions/raycast/package.json b/extensions/raycast/package.json new file mode 100644 index 00000000000..56cd8fc5d32 --- /dev/null +++ b/extensions/raycast/package.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap Desktop recording from Raycast", + "icon": "assets/cap-icon.png", + "author": "cap-software", + "categories": ["Productivity", "Developer Tools"], + "license": "MIT", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "description": "Start recording in Cap with your current default settings", + "mode": "no-view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "description": "Stop the current Cap recording", + "mode": "no-view" + }, + { + "name": "open-dashboard", + "title": "Open Dashboard", + "description": "Open Cap dashboard in your browser", + "mode": "no-view" + } + ], + "dependencies": { + "@raycast/api": "^1.83.0" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "20.8.10", + "eslint": "^8.57.0", + "prettier": "^3.3.3", + "typescript": "^5.4.5" + }, + "scripts": { + "build": "ray build --skip-types -e dist -o dist", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint", + "prepublishOnly": "echo \"\\n\\nUse npm run publish for Raycast Store release.\\n\" && exit 1", + "publish": "ray publish" + } +} diff --git a/extensions/raycast/src/open-dashboard.ts b/extensions/raycast/src/open-dashboard.ts new file mode 100644 index 00000000000..650a2c85e5e --- /dev/null +++ b/extensions/raycast/src/open-dashboard.ts @@ -0,0 +1,14 @@ +import { closeMainWindow, open, showToast, Toast } from "@raycast/api"; + +export default async function Command() { + try { + await closeMainWindow(); + await open("https://cap.so/dashboard"); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to open dashboard", + message: String(error), + }); + } +} diff --git a/extensions/raycast/src/start-recording.ts b/extensions/raycast/src/start-recording.ts new file mode 100644 index 00000000000..6fe484b274f --- /dev/null +++ b/extensions/raycast/src/start-recording.ts @@ -0,0 +1,15 @@ +import { closeMainWindow, open, showHUD, showToast, Toast } from "@raycast/api"; + +export default async function Command() { + try { + await closeMainWindow(); + await open("cap://record"); + await showHUD("Sent start request to Cap"); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to start Cap recording", + message: String(error), + }); + } +} diff --git a/extensions/raycast/src/stop-recording.ts b/extensions/raycast/src/stop-recording.ts new file mode 100644 index 00000000000..f2885b6c0a4 --- /dev/null +++ b/extensions/raycast/src/stop-recording.ts @@ -0,0 +1,15 @@ +import { closeMainWindow, open, showHUD, showToast, Toast } from "@raycast/api"; + +export default async function Command() { + try { + await closeMainWindow(); + await open("cap://stop"); + await showHUD("Sent stop request to Cap"); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to stop Cap recording", + message: String(error), + }); + } +} diff --git a/extensions/raycast/tsconfig.json b/extensions/raycast/tsconfig.json new file mode 100644 index 00000000000..be2e0c77390 --- /dev/null +++ b/extensions/raycast/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": true, + "skipLibCheck": true + }, + "include": ["src"] +}