diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index fce75b4a84..e417a4053a 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -15,9 +15,46 @@ pub enum CaptureMode { Window(String), } +/// Response types for deeplink queries +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct RecordingStatusResponse { + pub is_recording: bool, + pub is_paused: bool, + pub mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DisplayInfo { + pub name: String, + pub id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct WindowInfo { + pub name: String, + pub owner_name: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct AudioDeviceInfo { + pub label: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct CameraInfo { + pub name: String, + pub id: String, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DeepLinkAction { + /// Start a new recording StartRecording { capture_mode: CaptureMode, camera: Option, @@ -25,13 +62,44 @@ pub enum DeepLinkAction { capture_system_audio: bool, mode: RecordingMode, }, + /// Stop the current recording StopRecording, + /// Pause the current recording + PauseRecording, + /// Resume a paused recording + ResumeRecording, + /// Toggle pause state of the current recording + TogglePause, + /// Switch the microphone input + SetMicrophone { + label: Option, + }, + /// Switch the camera input + SetCamera { + device_id: Option, + }, + /// Take a screenshot + TakeScreenshot, + /// Open a project in the editor OpenEditor { project_path: PathBuf, }, + /// Open the settings window OpenSettings { page: Option, }, + /// Show the main Cap window + ShowMainWindow, + /// List available displays + ListDisplays, + /// List available windows + ListWindows, + /// List available microphones + ListMicrophones, + /// List available cameras + ListCameras, + /// Get current recording status + GetRecordingStatus, } pub fn handle(app_handle: &AppHandle, urls: Vec) { @@ -146,12 +214,115 @@ 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::TogglePause => { + crate::recording::toggle_pause_recording(app.clone(), app.state()).await + } + DeepLinkAction::SetMicrophone { label } => { + let state = app.state::>(); + crate::set_mic_input(state, label).await + } + DeepLinkAction::SetCamera { device_id } => { + let state = app.state::>(); + let camera_id = device_id.map(|id| DeviceOrModelID::DeviceID(id)); + crate::set_camera_input(app.clone(), state, camera_id, None).await + } + DeepLinkAction::TakeScreenshot => { + // Take a screenshot of the primary display + let displays = cap_recording::screen_capture::list_displays(); + if let Some((display, _)) = displays.into_iter().next() { + let target = ScreenCaptureTarget::Display { id: display.id }; + crate::recording::take_screenshot(app.clone(), target) + .await + .map(|_| ()) + } else { + Err("No display found for screenshot".to_string()) + } + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } DeepLinkAction::OpenSettings { page } => { crate::show_window(app.clone(), ShowCapWindow::Settings { page }).await } + DeepLinkAction::ShowMainWindow => { + crate::show_window(app.clone(), ShowCapWindow::Main { init_target_mode: None }).await + } + DeepLinkAction::ListDisplays => { + let displays: Vec = cap_recording::screen_capture::list_displays() + .into_iter() + .map(|(d, _)| DisplayInfo { + name: d.name.clone(), + id: format!("{:?}", d.id), + }) + .collect(); + // Log for debugging; in practice this could be returned via a different mechanism + trace!("Available displays: {:?}", displays); + Ok(()) + } + DeepLinkAction::ListWindows => { + let windows: Vec = cap_recording::screen_capture::list_windows() + .into_iter() + .map(|(w, _)| WindowInfo { + name: w.name.clone(), + owner_name: w.owner_name.clone(), + }) + .collect(); + trace!("Available windows: {:?}", windows); + Ok(()) + } + DeepLinkAction::ListMicrophones => { + use cap_recording::feeds::microphone::MicrophoneFeed; + let mics: Vec = MicrophoneFeed::list() + .keys() + .map(|label| AudioDeviceInfo { + label: label.clone(), + }) + .collect(); + trace!("Available microphones: {:?}", mics); + Ok(()) + } + DeepLinkAction::ListCameras => { + let cameras: Vec = cap_camera::list_cameras() + .map(|c| CameraInfo { + name: c.display_name().to_string(), + id: c.device_id().to_string(), + }) + .collect(); + trace!("Available cameras: {:?}", cameras); + Ok(()) + } + DeepLinkAction::GetRecordingStatus => { + let state = app.state::>(); + let app_state = state.read().await; + let status = match &app_state.recording_state { + crate::RecordingState::None => RecordingStatusResponse { + is_recording: false, + is_paused: false, + mode: None, + }, + crate::RecordingState::Pending { mode, .. } => RecordingStatusResponse { + is_recording: false, + is_paused: false, + mode: Some(*mode), + }, + crate::RecordingState::Active(recording) => { + let is_paused = recording.is_paused().await.unwrap_or(false); + RecordingStatusResponse { + is_recording: true, + is_paused, + mode: Some(recording.mode()), + } + } + }; + trace!("Recording status: {:?}", status); + Ok(()) + } } } } diff --git a/extensions/raycast-cap/README.md b/extensions/raycast-cap/README.md new file mode 100644 index 0000000000..19165205ea --- /dev/null +++ b/extensions/raycast-cap/README.md @@ -0,0 +1,61 @@ +# Cap Raycast Extension + +Control [Cap](https://cap.so) screen recording directly from Raycast. + +## Features + +- **Start Recording** - Start a new screen recording +- **Stop Recording** - Stop the current recording +- **Pause Recording** - Pause the current recording +- **Resume Recording** - Resume a paused recording +- **Toggle Pause** - Toggle pause state of the current recording +- **Take Screenshot** - Capture the current screen +- **Open Cap** - Open the Cap application +- **Open Settings** - Open Cap settings +- **Recording Controls** - Quick access to all Cap commands in a list view + +## Requirements + +- [Cap](https://cap.so) must be installed on your Mac +- macOS 11.0 or later + +## How It Works + +This extension uses Cap's deeplink protocol (`cap-desktop://`) to communicate with the Cap application. When you trigger a command, it opens a deeplink URL that Cap handles to perform the requested action. + +### Deeplink Format + +Cap deeplinks use the following format: +``` +cap-desktop://action?value= +``` + +Available actions: +- `start_recording` - Start recording with capture mode, camera, mic settings +- `stop_recording` - Stop the current recording +- `pause_recording` - Pause the current recording +- `resume_recording` - Resume a paused recording +- `toggle_pause` - Toggle pause state +- `take_screenshot` - Take a screenshot +- `open_settings` - Open settings with optional page parameter +- `show_main_window` - Show the main Cap window + +## Development + +```bash +# Install dependencies +npm install + +# Start development +npm run dev + +# Build the extension +npm run build + +# Publish to Raycast Store +npm run publish +``` + +## License + +AGPL-3.0 - See [LICENSE](../../LICENSE) for details. diff --git a/extensions/raycast-cap/assets/cap-icon.png b/extensions/raycast-cap/assets/cap-icon.png new file mode 100644 index 0000000000..b1ac1ef7d8 Binary files /dev/null and b/extensions/raycast-cap/assets/cap-icon.png differ diff --git a/extensions/raycast-cap/package.json b/extensions/raycast-cap/package.json new file mode 100644 index 0000000000..1d80527e79 --- /dev/null +++ b/extensions/raycast-cap/package.json @@ -0,0 +1,99 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap screen recording from Raycast", + "icon": "cap-icon.png", + "author": "Cap Software", + "authorUrl": "https://cap.so", + "categories": ["Productivity", "Media"], + "license": "AGPL-3.0", + "repository": { + "type": "git", + "url": "https://github.com/CapSoftware/Cap" + }, + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "subtitle": "Cap", + "description": "Start a new screen recording with Cap", + "mode": "no-view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "subtitle": "Cap", + "description": "Stop the current recording", + "mode": "no-view" + }, + { + "name": "pause-recording", + "title": "Pause Recording", + "subtitle": "Cap", + "description": "Pause the current recording", + "mode": "no-view" + }, + { + "name": "resume-recording", + "title": "Resume Recording", + "subtitle": "Cap", + "description": "Resume a paused recording", + "mode": "no-view" + }, + { + "name": "toggle-pause", + "title": "Toggle Pause", + "subtitle": "Cap", + "description": "Toggle pause state of the current recording", + "mode": "no-view" + }, + { + "name": "take-screenshot", + "title": "Take Screenshot", + "subtitle": "Cap", + "description": "Take a screenshot with Cap", + "mode": "no-view" + }, + { + "name": "open-cap", + "title": "Open Cap", + "subtitle": "Cap", + "description": "Open the Cap application", + "mode": "no-view" + }, + { + "name": "open-settings", + "title": "Open Settings", + "subtitle": "Cap", + "description": "Open Cap settings", + "mode": "no-view" + }, + { + "name": "recording-controls", + "title": "Recording Controls", + "subtitle": "Cap", + "description": "Quick actions for Cap recording", + "mode": "view" + } + ], + "dependencies": { + "@raycast/api": "^1.83.0" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "22.10.2", + "@types/react": "18.3.16", + "eslint": "^9.16.0", + "prettier": "^3.4.2", + "typescript": "^5.7.2" + }, + "scripts": { + "build": "ray build --skip-types -e dist -o dist", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint", + "prepublishOnly": "echo \"\\n\\nIt seems like you are trying to publish the Raycast extension to npm.\\n\\nIf you did intend to publish it to npm, remove the \\`prepublishOnly\\` script and rerun \\`npm publish\\` again.\\nIf you wanted to publish it to the Raycast Store instead, use \\`npm run publish\\` instead.\\n\\n\" && exit 1", + "publish": "npx @raycast/api@latest publish" + } +} diff --git a/extensions/raycast-cap/src/open-cap.tsx b/extensions/raycast-cap/src/open-cap.tsx new file mode 100644 index 0000000000..f82d2abd5c --- /dev/null +++ b/extensions/raycast-cap/src/open-cap.tsx @@ -0,0 +1,5 @@ +import { openCap } from "./utils/deeplink"; + +export default async function Command() { + await openCap(); +} diff --git a/extensions/raycast-cap/src/open-settings.tsx b/extensions/raycast-cap/src/open-settings.tsx new file mode 100644 index 0000000000..5e1c5d6594 --- /dev/null +++ b/extensions/raycast-cap/src/open-settings.tsx @@ -0,0 +1,8 @@ +import { executeCapAction } from "./utils/deeplink"; + +export default async function Command() { + await executeCapAction( + { open_settings: { page: null } }, + "Opening Settings" + ); +} diff --git a/extensions/raycast-cap/src/pause-recording.tsx b/extensions/raycast-cap/src/pause-recording.tsx new file mode 100644 index 0000000000..5e8f642458 --- /dev/null +++ b/extensions/raycast-cap/src/pause-recording.tsx @@ -0,0 +1,5 @@ +import { executeCapAction } from "./utils/deeplink"; + +export default async function Command() { + await executeCapAction("pause_recording", "Recording Paused"); +} diff --git a/extensions/raycast-cap/src/recording-controls.tsx b/extensions/raycast-cap/src/recording-controls.tsx new file mode 100644 index 0000000000..62022e6f2d --- /dev/null +++ b/extensions/raycast-cap/src/recording-controls.tsx @@ -0,0 +1,192 @@ +import { ActionPanel, Action, List, Icon, Color, showToast, Toast } from "@raycast/api"; +import { executeCapAction, isCapInstalled, openCap } from "./utils/deeplink"; +import { useEffect, useState } from "react"; + +interface RecordingAction { + id: string; + title: string; + subtitle: string; + icon: Icon; + iconColor?: Color; + action: () => Promise; +} + +export default function Command() { + const [isInstalled, setIsInstalled] = useState(null); + + useEffect(() => { + isCapInstalled().then(setIsInstalled); + }, []); + + if (isInstalled === null) { + return ; + } + + if (!isInstalled) { + return ( + + + + + } + /> + + ); + } + + const actions: RecordingAction[] = [ + { + id: "start-recording", + title: "Start Recording", + subtitle: "Start a new screen recording", + icon: Icon.Video, + iconColor: Color.Red, + action: async () => { + await executeCapAction( + { + start_recording: { + capture_mode: { screen: "Main Display" }, + camera: null, + mic_label: null, + capture_system_audio: false, + mode: "instant", + }, + }, + "Recording Started" + ); + }, + }, + { + id: "stop-recording", + title: "Stop Recording", + subtitle: "Stop the current recording", + icon: Icon.Stop, + iconColor: Color.Orange, + action: async () => { + await executeCapAction("stop_recording", "Recording Stopped"); + }, + }, + { + id: "toggle-pause", + title: "Toggle Pause", + subtitle: "Pause or resume the current recording", + icon: Icon.Pause, + iconColor: Color.Yellow, + action: async () => { + await executeCapAction("toggle_pause", "Toggled Pause"); + }, + }, + { + id: "pause-recording", + title: "Pause Recording", + subtitle: "Pause the current recording", + icon: Icon.Pause, + action: async () => { + await executeCapAction("pause_recording", "Recording Paused"); + }, + }, + { + id: "resume-recording", + title: "Resume Recording", + subtitle: "Resume a paused recording", + icon: Icon.Play, + iconColor: Color.Green, + action: async () => { + await executeCapAction("resume_recording", "Recording Resumed"); + }, + }, + { + id: "take-screenshot", + title: "Take Screenshot", + subtitle: "Capture the current screen", + icon: Icon.Camera, + iconColor: Color.Blue, + action: async () => { + await executeCapAction("take_screenshot", "Screenshot Taken"); + }, + }, + { + id: "open-cap", + title: "Open Cap", + subtitle: "Open the Cap application", + icon: Icon.Window, + action: openCap, + }, + { + id: "open-settings", + title: "Open Settings", + subtitle: "Open Cap settings", + icon: Icon.Gear, + action: async () => { + await executeCapAction({ open_settings: { page: null } }, "Opening Settings"); + }, + }, + ]; + + return ( + + + {actions.slice(0, 5).map((item) => ( + + + + } + /> + ))} + + + {actions.slice(5, 6).map((item) => ( + + + + } + /> + ))} + + + {actions.slice(6).map((item) => ( + + + + } + /> + ))} + + + ); +} diff --git a/extensions/raycast-cap/src/resume-recording.tsx b/extensions/raycast-cap/src/resume-recording.tsx new file mode 100644 index 0000000000..cd539bbd9b --- /dev/null +++ b/extensions/raycast-cap/src/resume-recording.tsx @@ -0,0 +1,5 @@ +import { executeCapAction } from "./utils/deeplink"; + +export default async function Command() { + await executeCapAction("resume_recording", "Recording Resumed"); +} diff --git a/extensions/raycast-cap/src/start-recording.tsx b/extensions/raycast-cap/src/start-recording.tsx new file mode 100644 index 0000000000..f0f9ab2469 --- /dev/null +++ b/extensions/raycast-cap/src/start-recording.tsx @@ -0,0 +1,16 @@ +import { executeCapAction } from "./utils/deeplink"; + +export default async function Command() { + await executeCapAction( + { + start_recording: { + capture_mode: { screen: "Main Display" }, + camera: null, + mic_label: null, + capture_system_audio: false, + mode: "instant", + }, + }, + "Recording Started" + ); +} diff --git a/extensions/raycast-cap/src/stop-recording.tsx b/extensions/raycast-cap/src/stop-recording.tsx new file mode 100644 index 0000000000..e6b24c9b3b --- /dev/null +++ b/extensions/raycast-cap/src/stop-recording.tsx @@ -0,0 +1,5 @@ +import { executeCapAction } from "./utils/deeplink"; + +export default async function Command() { + await executeCapAction("stop_recording", "Recording Stopped"); +} diff --git a/extensions/raycast-cap/src/take-screenshot.tsx b/extensions/raycast-cap/src/take-screenshot.tsx new file mode 100644 index 0000000000..e0e1a12cf4 --- /dev/null +++ b/extensions/raycast-cap/src/take-screenshot.tsx @@ -0,0 +1,5 @@ +import { executeCapAction } from "./utils/deeplink"; + +export default async function Command() { + await executeCapAction("take_screenshot", "Screenshot Taken"); +} diff --git a/extensions/raycast-cap/src/toggle-pause.tsx b/extensions/raycast-cap/src/toggle-pause.tsx new file mode 100644 index 0000000000..fca7f27417 --- /dev/null +++ b/extensions/raycast-cap/src/toggle-pause.tsx @@ -0,0 +1,5 @@ +import { executeCapAction } from "./utils/deeplink"; + +export default async function Command() { + await executeCapAction("toggle_pause", "Toggled Pause"); +} diff --git a/extensions/raycast-cap/src/utils/deeplink.ts b/extensions/raycast-cap/src/utils/deeplink.ts new file mode 100644 index 0000000000..9806ff0779 --- /dev/null +++ b/extensions/raycast-cap/src/utils/deeplink.ts @@ -0,0 +1,129 @@ +import { open, showToast, Toast, getApplications } from "@raycast/api"; + +const CAP_BUNDLE_ID = "so.cap.desktop"; +const CAP_DEEPLINK_SCHEME = "cap-desktop"; + +/** + * Deep link action types matching the Rust DeepLinkAction enum. + * Uses snake_case to match serde(rename_all = "snake_case") + */ +export type DeepLinkAction = + | { start_recording: StartRecordingParams } + | "stop_recording" + | "pause_recording" + | "resume_recording" + | "toggle_pause" + | { set_microphone: { label: string | null } } + | { set_camera: { device_id: string | null } } + | "take_screenshot" + | { open_editor: { project_path: string } } + | { open_settings: { page: string | null } } + | "show_main_window" + | "list_displays" + | "list_windows" + | "list_microphones" + | "list_cameras" + | "get_recording_status"; + +export interface StartRecordingParams { + capture_mode: CaptureMode; + camera?: DeviceOrModelID | null; + mic_label?: string | null; + capture_system_audio: boolean; + mode: RecordingMode; +} + +// CaptureMode matches Rust enum with snake_case +export type CaptureMode = { screen: string } | { window: string }; + +// DeviceOrModelID matches Rust enum (PascalCase variants) +export type DeviceOrModelID = { DeviceID: string } | { ModelID: string }; + +// RecordingMode matches Rust enum with snake_case (lowercase variants) +export type RecordingMode = "instant" | "studio" | "screenshot"; + +/** + * Check if Cap is installed on the system + */ +export async function isCapInstalled(): Promise { + const apps = await getApplications(); + return apps.some((app) => app.bundleId === CAP_BUNDLE_ID); +} + +/** + * Build a deeplink URL for a Cap action + */ +export function buildDeeplinkUrl(action: DeepLinkAction): string { + const actionValue = + typeof action === "string" ? JSON.stringify(action) : JSON.stringify(action); + const encodedValue = encodeURIComponent(actionValue); + return `${CAP_DEEPLINK_SCHEME}://action?value=${encodedValue}`; +} + +/** + * Execute a Cap action via deeplink + */ +export async function executeCapAction( + action: DeepLinkAction, + successMessage?: string +): Promise { + const isInstalled = await isCapInstalled(); + + if (!isInstalled) { + await showToast({ + style: Toast.Style.Failure, + title: "Cap Not Found", + message: "Please install Cap from https://cap.so", + }); + return; + } + + const url = buildDeeplinkUrl(action); + + try { + await open(url); + + if (successMessage) { + await showToast({ + style: Toast.Style.Success, + title: successMessage, + }); + } + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Execute Action", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * Open the Cap application + */ +export async function openCap(): Promise { + const isInstalled = await isCapInstalled(); + + if (!isInstalled) { + await showToast({ + style: Toast.Style.Failure, + title: "Cap Not Found", + message: "Please install Cap from https://cap.so", + }); + return; + } + + try { + await open("", CAP_BUNDLE_ID); + await showToast({ + style: Toast.Style.Success, + title: "Cap Opened", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Open Cap", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/extensions/raycast-cap/tsconfig.json b/extensions/raycast-cap/tsconfig.json new file mode 100644 index 0000000000..46d63a7111 --- /dev/null +++ b/extensions/raycast-cap/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Raycast Extension", + "compilerOptions": { + "lib": ["ES2023"], + "module": "Node16", + "target": "ES2022", + "moduleResolution": "Node16", + "resolveJsonModule": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "noEmit": true + }, + "include": ["src/**/*"] +}