diff --git a/apps/desktop/src-tauri/DEEPLINKS.md b/apps/desktop/src-tauri/DEEPLINKS.md new file mode 100644 index 0000000000..9a13e5aa89 --- /dev/null +++ b/apps/desktop/src-tauri/DEEPLINKS.md @@ -0,0 +1,181 @@ +# Cap Desktop Deeplinks + +Cap desktop registers the `cap-desktop://` URL scheme for external automation and integrations (e.g. Raycast, Alfred, shell scripts). + +## URL Format + +All actions use the `action` host with a JSON-encoded `value` query parameter: + +``` +cap-desktop://action?value= +``` + +### Unit actions (no parameters) + +The JSON value is a quoted string: + +``` +cap-desktop://action?value=%22stop_recording%22 +``` + +### Parameterized actions + +The JSON value is an object keyed by the action name (shown unencoded for readability; URL-encode the JSON in actual deeplinks): + +``` +cap-desktop://action?value={"start_recording":{"capture_mode":null,"camera":null,"mic_label":null,"capture_system_audio":false,"mode":"studio"}} +``` + +## Available Actions + +### Recording Controls + +| Action | Type | Description | +| ------------------------- | ------------- | --------------------------------------------------- | +| `start_recording` | Parameterized | Start a new recording with explicit settings | +| `start_current_recording` | Parameterized | Start a recording using saved settings from the app | +| `stop_recording` | Unit | Stop the current recording | +| `pause_recording` | Unit | Pause the current recording | +| `resume_recording` | Unit | Resume a paused recording | +| `toggle_pause_recording` | Unit | Toggle pause/resume on the current recording | +| `restart_recording` | Unit | Restart the current recording | + +### Screenshots + +| Action | Type | Description | +| ----------------- | ------------- | -------------------- | +| `take_screenshot` | Parameterized | Capture a screenshot | + +### Device Management + +| Action | Type | Description | +| ------------------ | ------------- | ----------------------------------------------- | +| `list_cameras` | Unit | Copy available cameras as JSON to clipboard | +| `set_camera` | Parameterized | Set the active camera | +| `list_microphones` | Unit | Copy available microphones as JSON to clipboard | +| `set_microphone` | Parameterized | Set the active microphone | +| `list_displays` | Unit | Copy available displays as JSON to clipboard | +| `list_windows` | Unit | Copy available windows as JSON to clipboard | + +### Other + +| Action | Type | Description | +| --------------- | ------------- | ---------------------------- | +| `open_editor` | Parameterized | Open a project in the editor | +| `open_settings` | Parameterized | Open the settings window | + +## Action Parameters + +### `start_recording` + +| Field | Type | Required | Description | +| ---------------------- | -------------------------------------------------------- | -------- | ---------------------------------------------------------------------- | +| `capture_mode` | `null` \| `{"screen":""}` \| `{"window":""}` | No | Target to capture. Defaults to primary display when omitted or `null`. | +| `camera` | `null` \| device ID object | No | Camera device. Defaults to no camera when omitted or `null`. | +| `mic_label` | `null` \| `string` | No | Microphone label. Defaults to no microphone when omitted or `null`. | +| `capture_system_audio` | `boolean` | Yes | Whether to capture system audio. | +| `mode` | `"studio"` \| `"instant"` | Yes | Recording mode. | + +### `start_current_recording` + +| Field | Type | Required | Description | +| ------ | ----------------------------------- | -------- | ----------------------------------------------------------------------------------- | +| `mode` | `null` \| `"studio"` \| `"instant"` | No | Override the saved recording mode. `null` uses the saved mode (defaults to studio). | + +### `take_screenshot` + +| Field | Type | Required | Description | +| -------------- | -------------------------------------------------------- | -------- | --------------------------------------------------- | +| `capture_mode` | `null` \| `{"screen":""}` \| `{"window":""}` | No | Target to capture. `null` uses the primary display. | + +### `set_camera` + +| Field | Type | Required | Description | +| ----- | -------------------------- | -------- | ----------------------------------------------- | +| `id` | `null` \| device ID object | No | Camera to activate. `null` disables the camera. | + +### `set_microphone` + +| Field | Type | Required | Description | +| ------- | ------------------ | -------- | ------------------------------------------------- | +| `label` | `null` \| `string` | No | Microphone label. `null` disables the microphone. | + +### `list_cameras` clipboard output + +JSON array of camera objects: + +```json +[{ "device_id": "...", "model_id": "...", "display_name": "..." }] +``` + +Pass `{ "DeviceID": "" }` or `{ "ModelID": "" }` as the `id` field of `set_camera`. + +### `list_microphones` clipboard output + +JSON array of label strings (sorted): + +```json +["Built-in Microphone", "External Headset"] +``` + +Pass a label string directly as the `label` field of `set_microphone`. + +### `list_displays` clipboard output + +JSON array of display objects: + +```json +[{ "id": 1, "name": "Built-in Retina Display", "refresh_rate": 60 }] +``` + +### `list_windows` clipboard output + +JSON array of window objects: + +```json +[{ "id": 1, "owner_name": "Safari", "name": "Page Title", "bounds": { ... }, "refresh_rate": 60, "bundle_identifier": "com.apple.Safari" }] +``` + +### `open_editor` + +| Field | Type | Required | Description | +| -------------- | -------- | -------- | --------------------------------------- | +| `project_path` | `string` | Yes | Absolute path to the project directory. | + +### `open_settings` + +| Field | Type | Required | Description | +| ------ | ------------------ | -------- | ----------------------------------------------------- | +| `page` | `null` \| `string` | No | Settings page to open. `null` opens the default page. | + +## Examples + +Start a studio recording on the primary display: + +```bash +open "cap-desktop://action?value=$(python3 -c 'import urllib.parse, json; print(urllib.parse.quote(json.dumps({"start_recording":{"capture_mode":None,"camera":None,"mic_label":None,"capture_system_audio":False,"mode":"studio"}})))')" +``` + +Start a recording using saved app settings: + +```bash +open "cap-desktop://action?value=$(python3 -c 'import urllib.parse, json; print(urllib.parse.quote(json.dumps({"start_current_recording":{"mode":None}})))')" +``` + +Stop a recording: + +```bash +open "cap-desktop://action?value=%22stop_recording%22" +``` + +Take a screenshot: + +```bash +open "cap-desktop://action?value=$(python3 -c 'import urllib.parse, json; print(urllib.parse.quote(json.dumps({"take_screenshot":{"capture_mode":None}})))')" +``` + +List available microphones (copies JSON to clipboard): + +```bash +open "cap-desktop://action?value=%22list_microphones%22" +``` diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index fce75b4a84..64962ac1ff 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_clipboard_manager::ClipboardExt; use tracing::trace; -use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; +use crate::{ + App, ArcLock, permissions, recording::StartRecordingInputs, + recording_settings::RecordingSettingsStore, windows::ShowCapWindow, +}; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -19,13 +24,33 @@ pub enum CaptureMode { #[serde(rename_all = "snake_case")] pub enum DeepLinkAction { StartRecording { - capture_mode: CaptureMode, + capture_mode: Option, camera: Option, mic_label: Option, capture_system_audio: bool, mode: RecordingMode, }, + StartCurrentRecording { + mode: Option, + }, StopRecording, + PauseRecording, + ResumeRecording, + TogglePauseRecording, + RestartRecording, + TakeScreenshot { + capture_mode: Option, + }, + ListCameras, + SetCamera { + id: Option, + }, + ListMicrophones, + SetMicrophone { + label: Option, + }, + ListDisplays, + ListWindows, OpenEditor { project_path: PathBuf, }, @@ -49,7 +74,6 @@ pub fn handle(app_handle: &AppHandle, urls: Vec) { ActionParseFromUrlError::Invalid => { eprintln!("Invalid deep link format \"{}\"", &url) } - // Likely login action, not handled here. ActionParseFromUrlError::NotAction => {} }) .ok() @@ -83,14 +107,17 @@ impl TryFrom<&Url> for DeepLinkAction { #[cfg(target_os = "macos")] if url.scheme() == "file" { return Ok(Self::OpenEditor { - project_path: url.to_file_path().unwrap(), + project_path: url + .to_file_path() + .map_err(|_| ActionParseFromUrlError::Invalid)?, }); } - match url.domain() { - Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction), - _ => Err(ActionParseFromUrlError::Invalid), - }?; + match url.host_str() { + Some("action") => {} + Some(_) => return Err(ActionParseFromUrlError::NotAction), + None => return Err(ActionParseFromUrlError::Invalid), + } let params = url .query_pairs() @@ -105,6 +132,32 @@ impl TryFrom<&Url> for DeepLinkAction { } impl DeepLinkAction { + fn resolve_capture_target(capture_mode: &CaptureMode) -> Result { + match capture_mode { + CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays() + .into_iter() + .find(|(s, _)| s.name.eq_ignore_ascii_case(name)) + .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id }) + .ok_or_else(|| format!("No screen with name \"{}\"", name)), + CaptureMode::Window(name) => cap_recording::screen_capture::list_windows() + .into_iter() + .find(|(w, _)| w.name.eq_ignore_ascii_case(name)) + .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) + .ok_or_else(|| format!("No window with name \"{}\"", name)), + } + } + + fn default_display_target() -> Result { + let primary_id = Display::primary().id(); + let displays = cap_recording::screen_capture::list_displays(); + displays + .iter() + .find(|(s, _)| s.id == primary_id) + .or_else(|| displays.first()) + .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id.clone() }) + .ok_or_else(|| "No displays found".to_string()) + } + pub async fn execute(self, app: &AppHandle) -> Result<(), String> { match self { DeepLinkAction::StartRecording { @@ -114,29 +167,84 @@ impl DeepLinkAction { capture_system_audio, mode, } => { + let perms = permissions::do_permissions_check(false); + if !perms.screen_recording.permitted() { + return Err("Screen recording permission not granted".to_string()); + } + if camera.is_some() && !perms.camera.permitted() { + return Err("Camera permission not granted".to_string()); + } + if mic_label.is_some() && !perms.microphone.permitted() { + return Err("Microphone permission not granted".to_string()); + } + let state = app.state::>(); crate::set_camera_input(app.clone(), state.clone(), camera, None).await?; 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))?, + let capture_target = match capture_mode { + Some(mode) => Self::resolve_capture_target(&mode)?, + None => Self::default_display_target()?, }; + let organization_id = RecordingSettingsStore::get(app) + .inspect_err(|e| eprintln!("Failed to read recording settings: {e}")) + .ok() + .flatten() + .and_then(|s| s.organization_id); + let inputs = StartRecordingInputs { mode, capture_target, capture_system_audio, - organization_id: None, + organization_id, + }; + + crate::recording::start_recording(app.clone(), state, inputs) + .await + .map(|_| ()) + } + DeepLinkAction::StartCurrentRecording { mode } => { + let settings = RecordingSettingsStore::get(app) + .inspect_err(|e| eprintln!("Failed to read recording settings: {e}")) + .ok() + .flatten() + .unwrap_or_default(); + + let RecordingSettingsStore { + target, + mic_name, + camera_id, + mode: saved_mode, + system_audio, + organization_id, + } = settings; + + let perms = permissions::do_permissions_check(false); + if !perms.screen_recording.permitted() { + return Err("Screen recording permission not granted".to_string()); + } + if camera_id.is_some() && !perms.camera.permitted() { + return Err("Camera permission not granted".to_string()); + } + if mic_name.is_some() && !perms.microphone.permitted() { + return Err("Microphone permission not granted".to_string()); + } + + let state = app.state::>(); + + crate::set_mic_input(state.clone(), mic_name).await?; + crate::set_camera_input(app.clone(), state.clone(), camera_id, None).await?; + + let inputs = StartRecordingInputs { + mode: mode.or(saved_mode).unwrap_or(RecordingMode::Studio), + capture_target: match target { + Some(t) => t, + None => Self::default_display_target()?, + }, + capture_system_audio: system_audio, + organization_id, }; crate::recording::start_recording(app.clone(), state, inputs) @@ -146,6 +254,109 @@ 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::RestartRecording => { + crate::recording::restart_recording(app.clone(), app.state()) + .await + .map(|_| ()) + } + DeepLinkAction::TakeScreenshot { capture_mode } => { + if !permissions::do_permissions_check(false) + .screen_recording + .permitted() + { + return Err("Screen recording permission not granted".to_string()); + } + let target = match capture_mode { + Some(mode) => Self::resolve_capture_target(&mode)?, + None => Self::default_display_target()?, + }; + + crate::recording::take_screenshot(app.clone(), target) + .await + .map(|_| ()) + } + DeepLinkAction::ListCameras => { + if !permissions::do_permissions_check(false).camera.permitted() { + return Err("Camera permission not granted".to_string()); + } + let cameras = crate::recording::list_cameras(); + let json = serde_json::to_string(&cameras).map_err(|e| e.to_string())?; + app.clipboard() + .write_text(&json) + .map_err(|e| e.to_string())?; + Ok(()) + } + DeepLinkAction::SetCamera { id } => { + if id.is_some() && !permissions::do_permissions_check(false).camera.permitted() { + return Err("Camera permission not granted".to_string()); + } + let state = app.state::>(); + crate::set_camera_input(app.clone(), state, id, None).await + } + DeepLinkAction::ListMicrophones => { + if !permissions::do_permissions_check(false) + .microphone + .permitted() + { + return Err("Microphone permission not granted".to_string()); + } + let mics = cap_recording::feeds::microphone::MicrophoneFeed::list(); + let mut labels: Vec = mics.keys().cloned().collect(); + labels.sort(); + let json = serde_json::to_string(&labels).map_err(|e| e.to_string())?; + app.clipboard() + .write_text(&json) + .map_err(|e| e.to_string())?; + Ok(()) + } + DeepLinkAction::SetMicrophone { label } => { + if label.is_some() + && !permissions::do_permissions_check(false) + .microphone + .permitted() + { + return Err("Microphone permission not granted".to_string()); + } + let state = app.state::>(); + crate::set_mic_input(state, label).await + } + DeepLinkAction::ListDisplays => { + if !permissions::do_permissions_check(false) + .screen_recording + .permitted() + { + return Err("Screen recording permission not granted".to_string()); + } + let displays = crate::recording::list_capture_displays().await; + let json = serde_json::to_string(&displays).map_err(|e| e.to_string())?; + app.clipboard() + .write_text(&json) + .map_err(|e| e.to_string())?; + Ok(()) + } + DeepLinkAction::ListWindows => { + if !permissions::do_permissions_check(false) + .screen_recording + .permitted() + { + return Err("Screen recording permission not granted".to_string()); + } + let windows = crate::recording::list_capture_windows().await; + let json = serde_json::to_string(&windows).map_err(|e| e.to_string())?; + app.clipboard() + .write_text(&json) + .map_err(|e| e.to_string())?; + Ok(()) + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } @@ -155,3 +366,220 @@ impl DeepLinkAction { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn action_url(value: &str) -> String { + let mut url = Url::parse("cap-desktop://action").unwrap(); + url.query_pairs_mut().append_pair("value", value); + url.to_string() + } + + fn parse(url_str: &str) -> Result { + let url = Url::parse(url_str).unwrap(); + DeepLinkAction::try_from(&url) + } + + #[test] + fn parse_unit_variants() { + let cases = [ + ("stop_recording", "StopRecording"), + ("pause_recording", "PauseRecording"), + ("resume_recording", "ResumeRecording"), + ("toggle_pause_recording", "TogglePauseRecording"), + ("restart_recording", "RestartRecording"), + ("list_cameras", "ListCameras"), + ("list_microphones", "ListMicrophones"), + ("list_displays", "ListDisplays"), + ("list_windows", "ListWindows"), + ]; + + for (action_str, label) in cases { + let url = action_url(&format!("\"{}\"", action_str)); + let result = parse(&url); + assert!( + result.is_ok(), + "Failed to parse {label}: {:?}", + result.err() + ); + } + } + + #[test] + fn parse_start_recording_studio() { + let json = serde_json::json!({ + "start_recording": { + "capture_mode": null, + "camera": null, + "mic_label": null, + "capture_system_audio": false, + "mode": "studio" + } + }); + let url = action_url(&json.to_string()); + let action = parse(&url).unwrap(); + assert!(matches!( + action, + DeepLinkAction::StartRecording { + mode: RecordingMode::Studio, + .. + } + )); + } + + #[test] + fn parse_start_recording_instant() { + let json = serde_json::json!({ + "start_recording": { + "capture_mode": null, + "camera": null, + "mic_label": null, + "capture_system_audio": true, + "mode": "instant" + } + }); + let url = action_url(&json.to_string()); + let action = parse(&url).unwrap(); + assert!(matches!( + action, + DeepLinkAction::StartRecording { + mode: RecordingMode::Instant, + capture_system_audio: true, + .. + } + )); + } + + #[test] + fn parse_start_current_recording() { + let json = serde_json::json!({ "start_current_recording": { "mode": null } }); + let url = action_url(&json.to_string()); + let action = parse(&url).unwrap(); + assert!(matches!( + action, + DeepLinkAction::StartCurrentRecording { mode: None } + )); + } + + #[test] + fn parse_start_current_recording_with_mode() { + let json = serde_json::json!({ "start_current_recording": { "mode": "instant" } }); + let url = action_url(&json.to_string()); + let action = parse(&url).unwrap(); + assert!(matches!( + action, + DeepLinkAction::StartCurrentRecording { + mode: Some(RecordingMode::Instant) + } + )); + } + + #[test] + fn parse_take_screenshot() { + let json = serde_json::json!({ "take_screenshot": { "capture_mode": null } }); + let url = action_url(&json.to_string()); + let action = parse(&url).unwrap(); + assert!(matches!( + action, + DeepLinkAction::TakeScreenshot { capture_mode: None } + )); + } + + #[test] + fn parse_set_camera() { + let json = serde_json::json!({ "set_camera": { "id": null } }); + let url = action_url(&json.to_string()); + let action = parse(&url).unwrap(); + assert!(matches!(action, DeepLinkAction::SetCamera { id: None })); + } + + #[test] + fn parse_set_microphone() { + let json = serde_json::json!({ "set_microphone": { "label": "Built-in Microphone" } }); + let url = action_url(&json.to_string()); + let action = parse(&url).unwrap(); + assert!(matches!( + action, + DeepLinkAction::SetMicrophone { label: Some(_) } + )); + } + + #[test] + fn parse_open_editor() { + let json = serde_json::json!({ "open_editor": { "project_path": "/tmp/test-project" } }); + let url = action_url(&json.to_string()); + let action = parse(&url).unwrap(); + assert!(matches!(action, DeepLinkAction::OpenEditor { .. })); + } + + #[test] + fn parse_open_settings() { + let json = serde_json::json!({ "open_settings": { "page": "general" } }); + let url = action_url(&json.to_string()); + let action = parse(&url).unwrap(); + assert!(matches!( + action, + DeepLinkAction::OpenSettings { page: Some(_) } + )); + } + + #[test] + fn parse_invalid_domain_returns_not_action() { + let url = "cap-desktop://something-else?value=%22stop_recording%22"; + let result = parse(url); + assert!(matches!(result, Err(ActionParseFromUrlError::NotAction))); + } + + #[test] + fn parse_missing_value_param_returns_invalid() { + let url = "cap-desktop://action?other=123"; + let result = parse(url); + assert!(matches!(result, Err(ActionParseFromUrlError::Invalid))); + } + + #[test] + fn parse_malformed_json_returns_parse_failed() { + let url = "cap-desktop://action?value=not-valid-json"; + let result = parse(url); + assert!(matches!( + result, + Err(ActionParseFromUrlError::ParseFailed(_)) + )); + } + + #[test] + fn parse_capture_mode_screen() { + let json = serde_json::json!({ + "take_screenshot": { + "capture_mode": { "screen": "Main Display" } + } + }); + let url = action_url(&json.to_string()); + let action = parse(&url).unwrap(); + assert!(matches!( + action, + DeepLinkAction::TakeScreenshot { + capture_mode: Some(CaptureMode::Screen(_)) + } + )); + } + + #[test] + fn parse_capture_mode_window() { + let json = serde_json::json!({ + "take_screenshot": { + "capture_mode": { "window": "Safari" } + } + }); + let url = action_url(&json.to_string()); + let action = parse(&url).unwrap(); + assert!(matches!( + action, + DeepLinkAction::TakeScreenshot { + capture_mode: Some(CaptureMode::Window(_)) + } + )); + } +} diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 4723d0e2be..281a0529c2 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -950,7 +950,6 @@ pub async fn start_recording( e })?; - let progressive_upload = InstantMultipartUpload::spawn( app_handle.clone(), recording_dir.join("content/output.mp4"), diff --git a/extensions/raycast/README.md b/extensions/raycast/README.md new file mode 100644 index 0000000000..29a53b4b21 --- /dev/null +++ b/extensions/raycast/README.md @@ -0,0 +1,37 @@ +# Cap Raycast Extension + +Control [Cap](https://cap.so) screen recorder directly from Raycast. + +## Commands + +| Command | Description | +| ------------------------------ | --------------------------------------------- | +| Start Instant Recording | Start an instant screen recording | +| Start Studio Recording | Start a studio screen recording | +| Start Recording (Saved Settings) | Start a recording using your saved Cap settings | +| Stop Recording | Stop the current recording | +| Pause Recording | Pause the current recording | +| Resume Recording | Resume a paused recording | +| Toggle Pause Recording | Toggle pause/resume | +| Restart Recording | Restart the current recording | +| Take Screenshot | Take a screenshot | +| Open Settings | Open Cap settings | + +## How It Works + +The extension communicates with the Cap desktop app through deeplinks using the `cap-desktop://` URL scheme. All commands dispatch actions via deeplink URLs that Cap handles natively. + +See [DEEPLINKS.md](../../apps/desktop/src-tauri/DEEPLINKS.md) for full deeplink documentation. + +## Prerequisites + +- [Cap](https://cap.so) desktop app installed and running +- [Raycast](https://raycast.com) installed + +## Development + +```bash +cd extensions/raycast +pnpm install +pnpm dev +``` diff --git a/extensions/raycast/assets/cap-icon.png b/extensions/raycast/assets/cap-icon.png new file mode 100644 index 0000000000..718226caf2 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 0000000000..fc298b3cbd --- /dev/null +++ b/extensions/raycast/package.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap screen recorder — start, stop, pause, resume recordings, take screenshots, and manage devices.", + "icon": "cap-icon.png", + "author": "capsoftware", + "categories": [ + "Productivity", + "Developer Tools" + ], + "license": "MIT", + "commands": [ + { + "name": "start-instant-recording", + "title": "Start Instant Recording", + "description": "Start an instant recording using your saved Cap device and display settings", + "mode": "no-view" + }, + { + "name": "start-studio-recording", + "title": "Start Studio Recording", + "description": "Start a studio recording using your saved Cap device and display settings", + "mode": "no-view" + }, + { + "name": "start-current-recording", + "title": "Start Recording (Saved Settings)", + "description": "Start a recording using all of your saved Cap settings, including mode", + "mode": "no-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 a paused Cap recording", + "mode": "no-view" + }, + { + "name": "toggle-pause-recording", + "title": "Toggle Pause Recording", + "description": "Toggle pause/resume on the current Cap recording", + "mode": "no-view" + }, + { + "name": "restart-recording", + "title": "Restart Recording", + "description": "Restart the current Cap recording", + "mode": "no-view" + }, + { + "name": "take-screenshot", + "title": "Take Screenshot", + "description": "Take a screenshot with Cap", + "mode": "no-view" + }, + { + "name": "open-settings", + "title": "Open Settings", + "description": "Open Cap settings", + "mode": "no-view" + } + ], + "dependencies": { + "@raycast/api": "^1.93.3" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "22.13.14", + "@types/react": "19.0.10", + "eslint": "^9.22.0", + "typescript": "^5.8.2" + }, + "scripts": { + "build": "ray build", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint" + } +} diff --git a/extensions/raycast/src/lib/deeplink.ts b/extensions/raycast/src/lib/deeplink.ts new file mode 100644 index 0000000000..21fd7e8018 --- /dev/null +++ b/extensions/raycast/src/lib/deeplink.ts @@ -0,0 +1,15 @@ +import { closeMainWindow, open, showHUD } from '@raycast/api'; + +type DeepLinkAction = string | Record; + +export async function runDeepLinkAction(action: DeepLinkAction, successMessage: string) { + const value = JSON.stringify(action); + const deeplink = `cap-desktop://action?value=${encodeURIComponent(value)}`; + await closeMainWindow().catch(() => undefined); + try { + await open(deeplink); + await showHUD(successMessage); + } catch { + await showHUD("Failed to open Cap — make sure it is installed and running"); + } +} diff --git a/extensions/raycast/src/open-settings.ts b/extensions/raycast/src/open-settings.ts new file mode 100644 index 0000000000..cc63705d01 --- /dev/null +++ b/extensions/raycast/src/open-settings.ts @@ -0,0 +1,5 @@ +import { runDeepLinkAction } from "./lib/deeplink"; + +export default async function openSettings() { + await runDeepLinkAction({ open_settings: { page: null } }, "Open settings dispatched"); +} diff --git a/extensions/raycast/src/pause-recording.ts b/extensions/raycast/src/pause-recording.ts new file mode 100644 index 0000000000..052685f7ec --- /dev/null +++ b/extensions/raycast/src/pause-recording.ts @@ -0,0 +1,5 @@ +import { runDeepLinkAction } from "./lib/deeplink"; + +export default async function pauseRecording() { + await runDeepLinkAction("pause_recording", "Pause recording dispatched"); +} diff --git a/extensions/raycast/src/restart-recording.ts b/extensions/raycast/src/restart-recording.ts new file mode 100644 index 0000000000..77bf59a54d --- /dev/null +++ b/extensions/raycast/src/restart-recording.ts @@ -0,0 +1,5 @@ +import { runDeepLinkAction } from "./lib/deeplink"; + +export default async function restartRecording() { + await runDeepLinkAction("restart_recording", "Restart recording dispatched"); +} diff --git a/extensions/raycast/src/resume-recording.ts b/extensions/raycast/src/resume-recording.ts new file mode 100644 index 0000000000..5554d51f31 --- /dev/null +++ b/extensions/raycast/src/resume-recording.ts @@ -0,0 +1,5 @@ +import { runDeepLinkAction } from "./lib/deeplink"; + +export default async function resumeRecording() { + await runDeepLinkAction("resume_recording", "Resume recording dispatched"); +} diff --git a/extensions/raycast/src/start-current-recording.ts b/extensions/raycast/src/start-current-recording.ts new file mode 100644 index 0000000000..e501d972fd --- /dev/null +++ b/extensions/raycast/src/start-current-recording.ts @@ -0,0 +1,8 @@ +import { runDeepLinkAction } from "./lib/deeplink"; + +export default async function startCurrentRecording() { + await runDeepLinkAction( + { start_current_recording: { mode: null } }, + "Recording dispatched with saved settings", + ); +} diff --git a/extensions/raycast/src/start-instant-recording.ts b/extensions/raycast/src/start-instant-recording.ts new file mode 100644 index 0000000000..3203c0dd81 --- /dev/null +++ b/extensions/raycast/src/start-instant-recording.ts @@ -0,0 +1,8 @@ +import { runDeepLinkAction } from "./lib/deeplink"; + +export default async function startInstantRecording() { + await runDeepLinkAction( + { start_current_recording: { mode: "instant" } }, + "Instant recording dispatched", + ); +} diff --git a/extensions/raycast/src/start-studio-recording.ts b/extensions/raycast/src/start-studio-recording.ts new file mode 100644 index 0000000000..a685368f1e --- /dev/null +++ b/extensions/raycast/src/start-studio-recording.ts @@ -0,0 +1,8 @@ +import { runDeepLinkAction } from "./lib/deeplink"; + +export default async function startStudioRecording() { + await runDeepLinkAction( + { start_current_recording: { mode: "studio" } }, + "Studio recording dispatched", + ); +} diff --git a/extensions/raycast/src/stop-recording.ts b/extensions/raycast/src/stop-recording.ts new file mode 100644 index 0000000000..a3ae9b5be2 --- /dev/null +++ b/extensions/raycast/src/stop-recording.ts @@ -0,0 +1,5 @@ +import { runDeepLinkAction } from "./lib/deeplink"; + +export default async function stopRecording() { + await runDeepLinkAction("stop_recording", "Stop recording dispatched"); +} diff --git a/extensions/raycast/src/take-screenshot.ts b/extensions/raycast/src/take-screenshot.ts new file mode 100644 index 0000000000..2c9967f313 --- /dev/null +++ b/extensions/raycast/src/take-screenshot.ts @@ -0,0 +1,8 @@ +import { runDeepLinkAction } from "./lib/deeplink"; + +export default async function takeScreenshot() { + await runDeepLinkAction( + { take_screenshot: { capture_mode: null } }, + "Screenshot dispatched", + ); +} diff --git a/extensions/raycast/src/toggle-pause-recording.ts b/extensions/raycast/src/toggle-pause-recording.ts new file mode 100644 index 0000000000..4b38fa1009 --- /dev/null +++ b/extensions/raycast/src/toggle-pause-recording.ts @@ -0,0 +1,8 @@ +import { runDeepLinkAction } from "./lib/deeplink"; + +export default async function togglePauseRecording() { + await runDeepLinkAction( + "toggle_pause_recording", + "Pause toggle dispatched", + ); +} diff --git a/extensions/raycast/tsconfig.json b/extensions/raycast/tsconfig.json new file mode 100644 index 0000000000..756769e94d --- /dev/null +++ b/extensions/raycast/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ES2023"], + "module": "Node16", + "target": "ES2022", + "moduleResolution": "Node16", + "strict": true, + "declaration": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 614993f77e..76d6bff551 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - "apps/*" + - "extensions/*" - "packages/*" - "crates/tauri-plugin-*" - "infra"