From a520f2ad2ba1bc6c29ec3f9ef317664de3b24fae Mon Sep 17 00:00:00 2001 From: Kuro Date: Sun, 29 Mar 2026 18:13:03 +0900 Subject: [PATCH 1/2] feat: extend deeplinks + add Raycast extension Deeplinks (Rust): - Add PauseRecording, ResumeRecording, TogglePauseRecording actions - Add SwitchMicrophone and SwitchCamera actions - All new actions delegate to existing recording/input functions Raycast Extension: - Start/Stop/Toggle Pause recording via deeplinks - Open Cap app and settings - Browse recent recordings with search - All commands use cap-desktop:// URL scheme - No-view commands for instant execution Closes #1540 --- .../desktop/src-tauri/src/deeplink_actions.rs | 26 +++++++ extensions/raycast/README.md | 43 +++++++++++ extensions/raycast/package.json | 72 ++++++++++++++++++ extensions/raycast/src/open-cap.ts | 11 +++ extensions/raycast/src/open-settings.ts | 12 +++ extensions/raycast/src/recent-recordings.tsx | 73 +++++++++++++++++++ extensions/raycast/src/start-recording.ts | 20 +++++ extensions/raycast/src/stop-recording.ts | 12 +++ extensions/raycast/src/toggle-pause.ts | 12 +++ extensions/raycast/src/utils.ts | 32 ++++++++ extensions/raycast/tsconfig.json | 16 ++++ 11 files changed, 329 insertions(+) create mode 100644 extensions/raycast/README.md create mode 100644 extensions/raycast/package.json create mode 100644 extensions/raycast/src/open-cap.ts create mode 100644 extensions/raycast/src/open-settings.ts create mode 100644 extensions/raycast/src/recent-recordings.tsx create mode 100644 extensions/raycast/src/start-recording.ts create mode 100644 extensions/raycast/src/stop-recording.ts create mode 100644 extensions/raycast/src/toggle-pause.ts create mode 100644 extensions/raycast/src/utils.ts create mode 100644 extensions/raycast/tsconfig.json diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a117028487..b0e3a1544b 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -26,6 +26,15 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, + PauseRecording, + ResumeRecording, + TogglePauseRecording, + SwitchMicrophone { + label: Option, + }, + SwitchCamera { + camera: Option, + }, OpenEditor { project_path: PathBuf, }, @@ -147,6 +156,23 @@ 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::SwitchMicrophone { label } => { + let state = app.state::>(); + crate::set_mic_input(state, label).await + } + DeepLinkAction::SwitchCamera { camera } => { + let state = app.state::>(); + crate::set_camera_input(app.clone(), state, camera, None).await + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } diff --git a/extensions/raycast/README.md b/extensions/raycast/README.md new file mode 100644 index 0000000000..769ed6ad66 --- /dev/null +++ b/extensions/raycast/README.md @@ -0,0 +1,43 @@ +# Cap for Raycast + +Control [Cap](https://cap.so) screen recorder directly from Raycast. + +## Commands + +| Command | Description | +|---------|-------------| +| **Start Recording** | Start a new screen recording | +| **Stop Recording** | Stop the current recording | +| **Toggle Pause** | Pause or resume the current recording | +| **Open Cap** | Open the Cap application | +| **Open Settings** | Open Cap settings | +| **Recent Recordings** | Browse your recent recordings | + +## Requirements + +- [Cap](https://cap.so) desktop app installed +- macOS + +## How it works + +This extension communicates with Cap via the `cap-desktop://` deep link URL scheme. All commands are executed instantly without opening any UI (except "Recent Recordings" which shows a list view). + +## Deep Link Format + +Cap uses the following deep link format: + +``` +cap-desktop://action?value= +``` + +### Supported Actions + +- `start_recording` — Start recording with capture mode, camera, mic options +- `stop_recording` — Stop the current recording +- `pause_recording` — Pause the current recording +- `resume_recording` — Resume a paused recording +- `toggle_pause_recording` — Toggle pause/resume +- `open_editor` — Open a recording in the editor +- `open_settings` — Open Cap settings +- `switch_microphone` — Switch to a different microphone +- `switch_camera` — Switch to a different camera diff --git a/extensions/raycast/package.json b/extensions/raycast/package.json new file mode 100644 index 0000000000..be1b5c5812 --- /dev/null +++ b/extensions/raycast/package.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap screen recorder — start, stop, pause, and resume recordings directly from Raycast", + "icon": "cap-icon.png", + "author": "cap", + "categories": ["Productivity", "Applications"], + "license": "AGPL-3.0", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "description": "Start a new screen recording with Cap", + "mode": "no-view", + "keywords": ["record", "screen", "capture"] + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "description": "Stop the current recording", + "mode": "no-view", + "keywords": ["stop", "end", "finish"] + }, + { + "name": "toggle-pause", + "title": "Toggle Pause", + "description": "Pause or resume the current recording", + "mode": "no-view", + "keywords": ["pause", "resume", "toggle"] + }, + { + "name": "open-cap", + "title": "Open Cap", + "description": "Open the Cap application", + "mode": "no-view", + "keywords": ["open", "launch"] + }, + { + "name": "open-settings", + "title": "Open Settings", + "description": "Open Cap settings", + "mode": "no-view", + "keywords": ["settings", "preferences", "config"] + }, + { + "name": "recent-recordings", + "title": "Recent Recordings", + "description": "Browse your recent Cap recordings", + "mode": "view", + "keywords": ["recent", "history", "list", "browse"] + } + ], + "dependencies": { + "@raycast/api": "^1.83.1", + "@raycast/utils": "^1.17.0" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "22.5.4", + "@types/react": "18.3.3", + "eslint": "^8.57.0", + "prettier": "^3.3.3", + "typescript": "^5.4.5" + }, + "scripts": { + "build": "ray build", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint" + } +} diff --git a/extensions/raycast/src/open-cap.ts b/extensions/raycast/src/open-cap.ts new file mode 100644 index 0000000000..7b25c2a907 --- /dev/null +++ b/extensions/raycast/src/open-cap.ts @@ -0,0 +1,11 @@ +import { showHUD } from "@raycast/api"; +import { openCap } from "./utils"; + +export default async function command() { + try { + await openCap(); + await showHUD("🎬 Opening Cap..."); + } catch { + await showHUD("❌ Failed to open Cap. Is it installed?"); + } +} diff --git a/extensions/raycast/src/open-settings.ts b/extensions/raycast/src/open-settings.ts new file mode 100644 index 0000000000..96da947a13 --- /dev/null +++ b/extensions/raycast/src/open-settings.ts @@ -0,0 +1,12 @@ +import { showHUD } from "@raycast/api"; +import { sendDeepLink } from "./utils"; + +export default async function command() { + try { + // serde struct variant with optional field + await sendDeepLink({ open_settings: { page: null } }); + await showHUD("⚙️ Cap: Opening settings..."); + } catch { + await showHUD("❌ Failed to open settings. Is Cap running?"); + } +} diff --git a/extensions/raycast/src/recent-recordings.tsx b/extensions/raycast/src/recent-recordings.tsx new file mode 100644 index 0000000000..bb9faf5dc7 --- /dev/null +++ b/extensions/raycast/src/recent-recordings.tsx @@ -0,0 +1,73 @@ +import { Action, ActionPanel, List, open, showHUD } from "@raycast/api"; +import { useState, useEffect } from "react"; +import { readdirSync, statSync, existsSync } from "fs"; +import { join } from "path"; +import { getRecordingsDir } from "./utils"; + +interface Recording { + name: string; + path: string; + date: Date; +} + +function getRecordings(): Recording[] { + const dir = getRecordingsDir(); + if (!existsSync(dir)) return []; + + try { + return readdirSync(dir) + .filter((name) => { + const fullPath = join(dir, name); + return statSync(fullPath).isDirectory(); + }) + .map((name) => { + const fullPath = join(dir, name); + const stat = statSync(fullPath); + return { name, path: fullPath, date: stat.mtime }; + }) + .sort((a, b) => b.date.getTime() - a.date.getTime()) + .slice(0, 50); + } catch { + return []; + } +} + +export default function Command() { + const [recordings, setRecordings] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setRecordings(getRecordings()); + setIsLoading(false); + }, []); + + return ( + + {recordings.length === 0 ? ( + + ) : ( + recordings.map((rec) => ( + + { + await open(`file://${rec.path}`); + await showHUD("Opening in Cap..."); + }} + /> + + + + } + /> + )) + )} + + ); +} diff --git a/extensions/raycast/src/start-recording.ts b/extensions/raycast/src/start-recording.ts new file mode 100644 index 0000000000..e74baede71 --- /dev/null +++ b/extensions/raycast/src/start-recording.ts @@ -0,0 +1,20 @@ +import { showHUD } from "@raycast/api"; +import { sendDeepLink } from "./utils"; + +export default async function command() { + try { + // serde externally-tagged struct variant + await sendDeepLink({ + start_recording: { + capture_mode: { screen: "Main Display" }, + camera: null, + mic_label: null, + capture_system_audio: false, + mode: "instant", + }, + }); + await showHUD("🔴 Cap: Recording started"); + } catch { + await showHUD("❌ Failed to start recording. Is Cap running?"); + } +} diff --git a/extensions/raycast/src/stop-recording.ts b/extensions/raycast/src/stop-recording.ts new file mode 100644 index 0000000000..747ec14740 --- /dev/null +++ b/extensions/raycast/src/stop-recording.ts @@ -0,0 +1,12 @@ +import { showHUD } from "@raycast/api"; +import { sendDeepLink } from "./utils"; + +export default async function command() { + try { + // serde unit variant + await sendDeepLink("stop_recording"); + await showHUD("⏹ Cap: Recording stopped"); + } catch { + await showHUD("❌ Failed to stop recording. Is Cap running?"); + } +} diff --git a/extensions/raycast/src/toggle-pause.ts b/extensions/raycast/src/toggle-pause.ts new file mode 100644 index 0000000000..20e145dc45 --- /dev/null +++ b/extensions/raycast/src/toggle-pause.ts @@ -0,0 +1,12 @@ +import { showHUD } from "@raycast/api"; +import { sendDeepLink } from "./utils"; + +export default async function command() { + try { + // serde unit variant + await sendDeepLink("toggle_pause_recording"); + await showHUD("⏯ Cap: Toggled pause"); + } catch { + await showHUD("❌ Failed to toggle pause. Is Cap running?"); + } +} diff --git a/extensions/raycast/src/utils.ts b/extensions/raycast/src/utils.ts new file mode 100644 index 0000000000..2f2fffca86 --- /dev/null +++ b/extensions/raycast/src/utils.ts @@ -0,0 +1,32 @@ +import { open, showHUD } from "@raycast/api"; + +const CAP_DEEPLINK_SCHEME = "cap-desktop://"; + +/** + * Send a deep link action to the Cap desktop app. + * URL format: cap-desktop://action?value= + * + * The `value` parameter is the serde-serialized DeepLinkAction enum. + * For unit variants: `"stop_recording"` (just a string) + * For struct variants: `{"start_recording": { ... }}` + */ +export async function sendDeepLink(value: string | Record): Promise { + const encodedValue = encodeURIComponent(JSON.stringify(value)); + const url = `${CAP_DEEPLINK_SCHEME}action?value=${encodedValue}`; + await open(url); +} + +/** + * Open the Cap app without a specific action. + */ +export async function openCap(): Promise { + await open(CAP_DEEPLINK_SCHEME); +} + +/** + * Get the Cap recordings directory path. + */ +export function getRecordingsDir(): string { + const home = process.env.HOME || "~"; + return `${home}/.cap/recordings`; +} diff --git a/extensions/raycast/tsconfig.json b/extensions/raycast/tsconfig.json new file mode 100644 index 0000000000..b341d08950 --- /dev/null +++ b/extensions/raycast/tsconfig.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "compilerOptions": { + "strict": true, + "jsx": "react-jsx", + "module": "ES2022", + "moduleResolution": "bundler", + "target": "ES2022", + "lib": ["ES2022"], + "resolveJsonModule": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*", "env.d.ts"] +} From 46b82f28fcfc31134e8fb8dc50f48959a82a1bd1 Mon Sep 17 00:00:00 2001 From: Kuro Date: Sun, 29 Mar 2026 19:31:20 +0900 Subject: [PATCH 2/2] fix: address review feedback on Raycast extension - Fix recordings directory path to use Tauri app data dir (~/Library/Application Support/so.cap.desktop/recordings) - Percent-encode file:// URLs to handle spaces in paths - Optimize statSync to single call per entry (map-then-filter) - Filter recordings by .cap directory extension - Fix tsconfig.json schema URL --- extensions/raycast/src/recent-recordings.tsx | 10 ++++------ extensions/raycast/src/utils.ts | 3 ++- extensions/raycast/tsconfig.json | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/extensions/raycast/src/recent-recordings.tsx b/extensions/raycast/src/recent-recordings.tsx index bb9faf5dc7..2b2a6f68af 100644 --- a/extensions/raycast/src/recent-recordings.tsx +++ b/extensions/raycast/src/recent-recordings.tsx @@ -16,15 +16,13 @@ function getRecordings(): Recording[] { try { return readdirSync(dir) - .filter((name) => { - const fullPath = join(dir, name); - return statSync(fullPath).isDirectory(); - }) .map((name) => { const fullPath = join(dir, name); const stat = statSync(fullPath); - return { name, path: fullPath, date: stat.mtime }; + return { name, path: fullPath, stat }; }) + .filter(({ name, stat }) => name.endsWith(".cap") && stat.isDirectory()) + .map(({ name, path, stat }) => ({ name, path, date: stat.mtime })) .sort((a, b) => b.date.getTime() - a.date.getTime()) .slice(0, 50); } catch { @@ -57,7 +55,7 @@ export default function Command() { { - await open(`file://${rec.path}`); + await open(`file://${encodeURI(rec.path)}`); await showHUD("Opening in Cap..."); }} /> diff --git a/extensions/raycast/src/utils.ts b/extensions/raycast/src/utils.ts index 2f2fffca86..8ff5f6a885 100644 --- a/extensions/raycast/src/utils.ts +++ b/extensions/raycast/src/utils.ts @@ -25,8 +25,9 @@ export async function openCap(): Promise { /** * Get the Cap recordings directory path. + * Cap (Tauri) stores recordings under the app data directory. */ export function getRecordingsDir(): string { const home = process.env.HOME || "~"; - return `${home}/.cap/recordings`; + return `${home}/Library/Application Support/so.cap.desktop/recordings`; } diff --git a/extensions/raycast/tsconfig.json b/extensions/raycast/tsconfig.json index b341d08950..1234b64473 100644 --- a/extensions/raycast/tsconfig.json +++ b/extensions/raycast/tsconfig.json @@ -1,5 +1,5 @@ { - "$schema": "https://www.raycast.com/schemas/extension.json", + "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "strict": true, "jsx": "react-jsx",