diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index fce75b4a84..231c7ab0b4 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -26,6 +26,18 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, + PauseRecording, + ResumeRecording, + TogglePause, + TakeScreenshot { + capture_mode: CaptureMode, + }, + SetCamera { + camera: Option, + }, + SetMicrophone { + mic_label: Option, + }, OpenEditor { project_path: PathBuf, }, @@ -104,6 +116,21 @@ impl TryFrom<&Url> for 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 == 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 == name) + .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) + .ok_or_else(|| format!("No window with name \"{}\"", &name)), + } +} + impl DeepLinkAction { pub async fn execute(self, app: &AppHandle) -> Result<(), String> { match self { @@ -119,18 +146,7 @@ impl DeepLinkAction { 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 = resolve_capture_target(capture_mode)?; let inputs = StartRecordingInputs { mode, @@ -146,6 +162,30 @@ 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::TakeScreenshot { capture_mode } => { + let capture_target = resolve_capture_target(capture_mode)?; + + crate::recording::take_screenshot(app.clone(), capture_target) + .await + .map(|_| ()) + } + DeepLinkAction::SetCamera { camera } => { + let state = app.state::>(); + crate::set_camera_input(app.clone(), state, camera, None).await + } + DeepLinkAction::SetMicrophone { mic_label } => { + let state = app.state::>(); + crate::set_mic_input(state, mic_label).await + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } diff --git a/apps/raycast/README.md b/apps/raycast/README.md new file mode 100644 index 0000000000..fa264970cd --- /dev/null +++ b/apps/raycast/README.md @@ -0,0 +1,44 @@ +# Cap Raycast Extension + +Control [Cap](https://cap.so) screen recording directly from [Raycast](https://raycast.com). + +## Commands + +| Command | Description | +|---------|-------------| +| Start Instant Recording | Start an instant screen recording | +| Start Studio Recording | Start a studio screen recording | +| Stop Recording | Stop the current recording | +| Toggle Pause Recording | Pause or resume the current recording | +| Take Screenshot | Take a screenshot | +| Open Settings | Open Cap settings | + +## How It Works + +This extension communicates with the Cap desktop app via deeplinks (`cap-desktop://action?value=...`). The Cap app must be running for commands to work. + +## Supported Deeplink Actions + +The following deeplink actions are available: + +- `start_recording` — Start a recording (instant or studio mode) +- `stop_recording` — Stop the current recording +- `pause_recording` — Pause the current recording +- `resume_recording` — Resume a paused recording +- `toggle_pause` — Toggle pause/resume +- `take_screenshot` — Capture a screenshot +- `set_camera` — Switch camera input +- `set_microphone` — Switch microphone input +- `open_editor` — Open the editor for a project +- `open_settings` — Open the settings window + +### Deeplink Format + +``` +cap-desktop://action?value= +``` + +Example: +``` +cap-desktop://action?value=%7B%22stop_recording%22%3A%7B%7D%7D +``` diff --git a/apps/raycast/assets/cap-icon.png b/apps/raycast/assets/cap-icon.png new file mode 100644 index 0000000000..81a7775e9a Binary files /dev/null and b/apps/raycast/assets/cap-icon.png differ diff --git a/apps/raycast/package.json b/apps/raycast/package.json new file mode 100644 index 0000000000..f2cfa823f5 --- /dev/null +++ b/apps/raycast/package.json @@ -0,0 +1,84 @@ +{ + "$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", + "categories": ["Productivity", "Applications"], + "license": "MIT", + "preferences": [ + { + "name": "displayName", + "title": "Display Name", + "description": "Name of the display to capture (leave empty to use the main display)", + "type": "textfield", + "required": false, + "default": "" + } + ], + "commands": [ + { + "name": "start-instant-recording", + "title": "Start Instant Recording", + "subtitle": "Cap", + "description": "Start an instant screen recording with Cap", + "mode": "no-view" + }, + { + "name": "start-studio-recording", + "title": "Start Studio Recording", + "subtitle": "Cap", + "description": "Start a studio screen recording with Cap", + "mode": "no-view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "subtitle": "Cap", + "description": "Stop the current Cap recording", + "mode": "no-view" + }, + { + "name": "toggle-pause", + "title": "Toggle Pause Recording", + "subtitle": "Cap", + "description": "Pause or resume the current Cap recording", + "mode": "no-view" + }, + { + "name": "take-screenshot", + "title": "Take Screenshot", + "subtitle": "Cap", + "description": "Take a screenshot with Cap", + "mode": "no-view" + }, + { + "name": "open-settings", + "title": "Open Settings", + "subtitle": "Cap", + "description": "Open Cap settings", + "mode": "no-view" + } + ], + "dependencies": { + "@raycast/api": "^1.93.2", + "@raycast/utils": "^1.19.1" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.14", + "@types/node": "22.13.14", + "@types/react": "19.0.12", + "eslint": "^9.23.0", + "prettier": "^3.5.3", + "typescript": "^5.8.2" + }, + "scripts": { + "build": "ray build", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint", + "prepublishOnly": "echo \"Error: no publish\" && exit 1", + "publish": "npx @raycast/api@latest publish" + } +} diff --git a/apps/raycast/src/open-settings.ts b/apps/raycast/src/open-settings.ts new file mode 100644 index 0000000000..14b18cabcc --- /dev/null +++ b/apps/raycast/src/open-settings.ts @@ -0,0 +1,8 @@ +import { executeDeepLink } from "./utils"; + +export default async function command() { + await executeDeepLink( + { open_settings: { page: null } }, + "Opening Cap settings...", + ); +} diff --git a/apps/raycast/src/start-instant-recording.ts b/apps/raycast/src/start-instant-recording.ts new file mode 100644 index 0000000000..05ff162465 --- /dev/null +++ b/apps/raycast/src/start-instant-recording.ts @@ -0,0 +1,16 @@ +import { executeDeepLink, getDisplayName } from "./utils"; + +export default async function command() { + await executeDeepLink( + { + start_recording: { + capture_mode: { screen: getDisplayName() }, + camera: null, + mic_label: null, + capture_system_audio: false, + mode: "instant", + }, + }, + "Starting instant recording...", + ); +} diff --git a/apps/raycast/src/start-studio-recording.ts b/apps/raycast/src/start-studio-recording.ts new file mode 100644 index 0000000000..7244ba9ec9 --- /dev/null +++ b/apps/raycast/src/start-studio-recording.ts @@ -0,0 +1,16 @@ +import { executeDeepLink, getDisplayName } from "./utils"; + +export default async function command() { + await executeDeepLink( + { + start_recording: { + capture_mode: { screen: getDisplayName() }, + camera: null, + mic_label: null, + capture_system_audio: false, + mode: "studio", + }, + }, + "Starting studio recording...", + ); +} diff --git a/apps/raycast/src/stop-recording.ts b/apps/raycast/src/stop-recording.ts new file mode 100644 index 0000000000..6bde420029 --- /dev/null +++ b/apps/raycast/src/stop-recording.ts @@ -0,0 +1,5 @@ +import { executeDeepLink } from "./utils"; + +export default async function command() { + await executeDeepLink({ stop_recording: {} }, "Stopping recording..."); +} diff --git a/apps/raycast/src/take-screenshot.ts b/apps/raycast/src/take-screenshot.ts new file mode 100644 index 0000000000..0485ab8183 --- /dev/null +++ b/apps/raycast/src/take-screenshot.ts @@ -0,0 +1,12 @@ +import { executeDeepLink, getDisplayName } from "./utils"; + +export default async function command() { + await executeDeepLink( + { + take_screenshot: { + capture_mode: { screen: getDisplayName() }, + }, + }, + "Taking screenshot...", + ); +} diff --git a/apps/raycast/src/toggle-pause.ts b/apps/raycast/src/toggle-pause.ts new file mode 100644 index 0000000000..7d48f38860 --- /dev/null +++ b/apps/raycast/src/toggle-pause.ts @@ -0,0 +1,5 @@ +import { executeDeepLink } from "./utils"; + +export default async function command() { + await executeDeepLink({ toggle_pause: {} }, "Toggling pause..."); +} diff --git a/apps/raycast/src/utils.ts b/apps/raycast/src/utils.ts new file mode 100644 index 0000000000..37ddf048c6 --- /dev/null +++ b/apps/raycast/src/utils.ts @@ -0,0 +1,35 @@ +import { getPreferenceValues, open, showToast, Toast } from "@raycast/api"; + +const DEEPLINK_SCHEME = "cap-desktop"; + +interface Preferences { + displayName: string; +} + +export function getDisplayName(): string { + const { displayName } = getPreferenceValues(); + return displayName || "Main Display"; +} + +export function buildDeepLink(action: Record): string { + const json = JSON.stringify(action); + return `${DEEPLINK_SCHEME}://action?value=${encodeURIComponent(json)}`; +} + +export async function executeDeepLink( + action: Record, + successMessage: string, +): Promise { + const url = buildDeepLink(action); + + try { + await open(url); + await showToast({ style: Toast.Style.Success, title: successMessage }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to communicate with Cap", + message: String(error), + }); + } +} diff --git a/apps/raycast/tsconfig.json b/apps/raycast/tsconfig.json new file mode 100644 index 0000000000..fffe58ee5a --- /dev/null +++ b/apps/raycast/tsconfig.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "declaration": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ES2023"], + "module": "Node16", + "moduleResolution": "Node16", + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + }, + "include": ["src/**/*", "env.d.ts"] +}