diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 8e8eaf4327..4b38d579d0 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -15,6 +15,7 @@ import "./styles/theme.css"; import { CapErrorBoundary } from "./components/CapErrorBoundary"; import { generalSettingsStore } from "./store"; import { initAnonymousUser } from "./utils/analytics"; +import { initDeepLinkCommands } from "./utils/deep-link-commands"; import { type AppTheme, commands } from "./utils/tauri"; import titlebar from "./utils/titlebar-state"; @@ -102,6 +103,7 @@ function Inner() { onMount(() => { initAnonymousUser(); + initDeepLinkCommands(); }); return ( diff --git a/apps/desktop/src/utils/deep-link-commands.ts b/apps/desktop/src/utils/deep-link-commands.ts new file mode 100644 index 0000000000..4a4a90f5bc --- /dev/null +++ b/apps/desktop/src/utils/deep-link-commands.ts @@ -0,0 +1,230 @@ +import { listen } from "@tauri-apps/api/event"; +import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; +import { commands } from "./tauri"; +import type { RecordingMode, StartRecordingInputs } from "./tauri"; + +/** + * Deep link command handlers for Cap + * Supports: cap://record, cap://stop, cap://pause, cap://resume, + * cap://toggle-pause, cap://switch-mic, cap://switch-camera + */ + +export interface DeepLinkCommand { + action: "record" | "stop" | "pause" | "resume" | "toggle-pause" | "switch-mic" | "switch-camera"; + params?: Record; +} + +/** + * Parse deep link URL and extract command + */ +export function parseDeepLinkCommand(url: string): DeepLinkCommand | null { + try { + const urlObj = new URL(url); + + // Only handle cap:// protocol + if (urlObj.protocol !== "cap:") { + return null; + } + + const action = urlObj.hostname as DeepLinkCommand["action"]; + const params: Record = {}; + + urlObj.searchParams.forEach((value, key) => { + params[key] = value; + }); + + // Validate action + const validActions: DeepLinkCommand["action"][] = [ + "record", "stop", "pause", "resume", "toggle-pause", "switch-mic", "switch-camera" + ]; + + if (!validActions.includes(action)) { + console.warn(`Unknown deep link action: ${action}`); + return null; + } + + return { action, params }; + } catch (error) { + console.error("Failed to parse deep link:", error); + return null; + } +} + +/** + * Execute deep link command + */ +export async function executeDeepLinkCommand(command: DeepLinkCommand): Promise { + const { action, params = {} } = command; + + console.log(`Executing deep link command: ${action}`, params); + + switch (action) { + case "record": + await handleRecordCommand(params); + break; + case "stop": + await handleStopCommand(); + break; + case "pause": + await handlePauseCommand(); + break; + case "resume": + await handleResumeCommand(); + break; + case "toggle-pause": + await handleTogglePauseCommand(); + break; + case "switch-mic": + await handleSwitchMicCommand(params); + break; + case "switch-camera": + await handleSwitchCameraCommand(params); + break; + default: + console.warn(`Unhandled deep link action: ${action}`); + } +} + +/** + * Handle record command + * Params: mode ("instant" | "studio"), camera?, microphone? + */ +async function handleRecordCommand(params: Record): Promise { + const mode = (params.mode as RecordingMode) || "instant"; + + // Set recording mode + await commands.setRecordingMode(mode); + + const inputs: StartRecordingInputs = { + mode, + capture_target: params.target || "screen", + }; + + // Add camera if specified + if (params.camera) { + inputs.camera_label = params.camera; + } + + // Add microphone if specified + if (params.microphone) { + inputs.audio_inputs = [{ label: params.microphone, device_type: "mic" }]; + } + + const result = await commands.startRecording(inputs); + + if (result !== "Started") { + console.error(`Failed to start recording: ${result}`); + } +} + +/** + * Handle stop command + */ +async function handleStopCommand(): Promise { + await commands.stopRecording(); +} + +/** + * Handle pause command + */ +async function handlePauseCommand(): Promise { + await commands.pauseRecording(); +} + +/** + * Handle resume command + */ +async function handleResumeCommand(): Promise { + await commands.resumeRecording(); +} + +/** + * Handle toggle pause command + */ +async function handleTogglePauseCommand(): Promise { + await commands.togglePauseRecording(); +} + +/** + * Handle switch microphone command + * Params: label (microphone name/device ID) + */ +async function handleSwitchMicCommand(params: Record): Promise { + const label = params.label || params.device; + + if (!label) { + console.error("No microphone label provided"); + return; + } + + await commands.setMicInput(label); +} + +/** + * Handle switch camera command + * Params: id (camera device ID) + */ +async function handleSwitchCameraCommand(params: Record): Promise { + const id = params.id || params.device; + + if (!id) { + console.error("No camera ID provided"); + return; + } + + await commands.setCameraInput(id, true); +} + +/** + * Initialize deep link command listener + * Returns unsubscribe function + */ +export async function initDeepLinkCommands(): Promise<() => void> { + console.log("Initializing deep link commands..."); + + const unsubscribe = await onOpenUrl(async (urls) => { + for (const url of urls) { + const command = parseDeepLinkCommand(url); + + if (command) { + try { + await executeDeepLinkCommand(command); + } catch (error) { + console.error(`Failed to execute command from ${url}:`, error); + } + } + } + }); + + return unsubscribe; +} + +/** + * Generate deep link URL for a command + */ +export function generateDeepLink( + action: DeepLinkCommand["action"], + params?: Record +): string { + const url = new URL(`cap://${action}`); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + } + + return url.toString(); +} + +// Export convenience functions for generating deep links +export const deepLinks = { + record: (params?: { mode?: RecordingMode; camera?: string; microphone?: string }) => + generateDeepLink("record", params as Record), + stop: () => generateDeepLink("stop"), + pause: () => generateDeepLink("pause"), + resume: () => generateDeepLink("resume"), + togglePause: () => generateDeepLink("toggle-pause"), + switchMic: (label: string) => generateDeepLink("switch-mic", { label }), + switchCamera: (id: string) => generateDeepLink("switch-camera", { id }), +}; diff --git a/extensions/raycast-cap/README.md b/extensions/raycast-cap/README.md new file mode 100644 index 0000000000..a63f1a24e6 --- /dev/null +++ b/extensions/raycast-cap/README.md @@ -0,0 +1,49 @@ +# Cap Raycast Extension + +Control Cap screen recording from Raycast. + +## Features + +- **Start Recording** - Start a new screen recording instantly +- **Stop Recording** - Stop the current recording +- **Pause Recording** - Pause the recording +- **Resume Recording** - Resume a paused recording +- **Toggle Pause** - Toggle between pause and resume +- **Switch Microphone** - Change to a different microphone input +- **Switch Camera** - Change to a different camera input + +## Installation + +1. Make sure you have [Raycast](https://raycast.com/) installed +2. Install the Cap extension from the Raycast Store +3. Ensure [Cap](https://cap.so) is installed on your Mac + +## Deep Link Protocol + +This extension uses Cap's deep link protocol to control the app: + +- `cap://record` - Start recording +- `cap://stop` - Stop recording +- `cap://pause` - Pause recording +- `cap://resume` - Resume recording +- `cap://toggle-pause` - Toggle pause state +- `cap://switch-mic?label=` - Switch microphone +- `cap://switch-camera?id=` - Switch camera + +## Requirements + +- macOS 11.0 or later +- Cap app installed +- Raycast v1.50.0 or later + +## Development + +```bash +cd extensions/raycast-cap +npm install +npm run dev +``` + +## License + +MIT diff --git a/extensions/raycast-cap/package.json b/extensions/raycast-cap/package.json new file mode 100644 index 0000000000..758cf6193e --- /dev/null +++ b/extensions/raycast-cap/package.json @@ -0,0 +1,67 @@ +{ + "name": "cap", + "title": "Cap", + "description": "Control Cap screen recording from Raycast", + "icon": "cap-icon.png", + "author": "divol89", + "categories": ["Productivity", "Media"], + "license": "MIT", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "description": "Start a new screen recording", + "mode": "no-view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "description": "Stop the current recording", + "mode": "no-view" + }, + { + "name": "pause-recording", + "title": "Pause Recording", + "description": "Pause the current recording", + "mode": "no-view" + }, + { + "name": "resume-recording", + "title": "Resume Recording", + "description": "Resume the paused recording", + "mode": "no-view" + }, + { + "name": "toggle-pause", + "title": "Toggle Pause", + "description": "Toggle pause/resume recording", + "mode": "no-view" + }, + { + "name": "switch-microphone", + "title": "Switch Microphone", + "description": "Switch to a different microphone", + "mode": "view" + }, + { + "name": "switch-camera", + "title": "Switch Camera", + "description": "Switch to a different camera", + "mode": "view" + } + ], + "dependencies": { + "@raycast/api": "^1.64.0", + "@raycast/utils": "^1.10.0" + }, + "devDependencies": { + "@types/node": "^20.8.10", + "typescript": "^5.2.2" + }, + "scripts": { + "build": "ray build -e dist", + "dev": "ray develop", + "fix-lint": "ray fix-lint", + "lint": "ray lint" + } +} diff --git a/extensions/raycast-cap/src/pause-recording.ts b/extensions/raycast-cap/src/pause-recording.ts new file mode 100644 index 0000000000..785d15faeb --- /dev/null +++ b/extensions/raycast-cap/src/pause-recording.ts @@ -0,0 +1,19 @@ +import { showToast, Toast } from "@raycast/api"; +import { openDeepLink, generateDeepLink } from "./utils"; + +export default async function Command() { + try { + await openDeepLink(generateDeepLink("pause")); + + await showToast({ + style: Toast.Style.Success, + title: "Paused Recording", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Pause Recording", + message: String(error), + }); + } +} diff --git a/extensions/raycast-cap/src/resume-recording.ts b/extensions/raycast-cap/src/resume-recording.ts new file mode 100644 index 0000000000..8fb9aff404 --- /dev/null +++ b/extensions/raycast-cap/src/resume-recording.ts @@ -0,0 +1,19 @@ +import { showToast, Toast } from "@raycast/api"; +import { openDeepLink, generateDeepLink } from "./utils"; + +export default async function Command() { + try { + await openDeepLink(generateDeepLink("resume")); + + await showToast({ + style: Toast.Style.Success, + title: "Resumed Recording", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Resume Recording", + message: String(error), + }); + } +} diff --git a/extensions/raycast-cap/src/start-recording.ts b/extensions/raycast-cap/src/start-recording.ts new file mode 100644 index 0000000000..f09634ba43 --- /dev/null +++ b/extensions/raycast-cap/src/start-recording.ts @@ -0,0 +1,19 @@ +import { showToast, Toast } from "@raycast/api"; +import { openDeepLink, generateDeepLink } from "./utils"; + +export default async function Command() { + try { + await openDeepLink(generateDeepLink("record")); + + await showToast({ + style: Toast.Style.Success, + title: "Started Recording", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Start Recording", + message: String(error), + }); + } +} diff --git a/extensions/raycast-cap/src/stop-recording.ts b/extensions/raycast-cap/src/stop-recording.ts new file mode 100644 index 0000000000..d2cacc9ab6 --- /dev/null +++ b/extensions/raycast-cap/src/stop-recording.ts @@ -0,0 +1,19 @@ +import { showToast, Toast } from "@raycast/api"; +import { openDeepLink, generateDeepLink } from "./utils"; + +export default async function Command() { + try { + await openDeepLink(generateDeepLink("stop")); + + await showToast({ + style: Toast.Style.Success, + title: "Stopped Recording", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Stop Recording", + message: String(error), + }); + } +} diff --git a/extensions/raycast-cap/src/switch-camera.tsx b/extensions/raycast-cap/src/switch-camera.tsx new file mode 100644 index 0000000000..7fd0cbf580 --- /dev/null +++ b/extensions/raycast-cap/src/switch-camera.tsx @@ -0,0 +1,57 @@ +import { Action, ActionPanel, List, showToast, Toast } from "@raycast/api"; +import { useState, useEffect } from "react"; +import { openDeepLink, generateDeepLink } from "./utils"; + +interface Camera { + id: string; + name: string; +} + +export default function Command() { + const [cameras, setCameras] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // In a real implementation, this would fetch from Cap + // For now, showing example cameras + setCameras([ + { id: "default", name: "Default Camera" }, + { id: "built-in", name: "Built-in Camera" }, + { id: "external", name: "External Camera" }, + ]); + setIsLoading(false); + }, []); + + async function switchCamera(camera: Camera) { + try { + await openDeepLink(generateDeepLink("switch-camera", { id: camera.id })); + + await showToast({ + style: Toast.Style.Success, + title: `Switched to ${camera.name}`, + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Switch Camera", + message: String(error), + }); + } + } + + return ( + + {cameras.map((camera) => ( + + switchCamera(camera)} /> + + } + /> + ))} + + ); +} diff --git a/extensions/raycast-cap/src/switch-microphone.tsx b/extensions/raycast-cap/src/switch-microphone.tsx new file mode 100644 index 0000000000..45ff89b137 --- /dev/null +++ b/extensions/raycast-cap/src/switch-microphone.tsx @@ -0,0 +1,57 @@ +import { Action, ActionPanel, List, showToast, Toast } from "@raycast/api"; +import { useState, useEffect } from "react"; +import { openDeepLink, generateDeepLink } from "./utils"; + +interface Microphone { + id: string; + name: string; +} + +export default function Command() { + const [microphones, setMicrophones] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // In a real implementation, this would fetch from Cap + // For now, showing example microphones + setMicrophones([ + { id: "default", name: "Default Microphone" }, + { id: "built-in", name: "Built-in Microphone" }, + { id: "external", name: "External Microphone" }, + ]); + setIsLoading(false); + }, []); + + async function switchMicrophone(mic: Microphone) { + try { + await openDeepLink(generateDeepLink("switch-mic", { label: mic.id })); + + await showToast({ + style: Toast.Style.Success, + title: `Switched to ${mic.name}`, + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Switch Microphone", + message: String(error), + }); + } + } + + return ( + + {microphones.map((mic) => ( + + switchMicrophone(mic)} /> + + } + /> + ))} + + ); +} diff --git a/extensions/raycast-cap/src/toggle-pause.ts b/extensions/raycast-cap/src/toggle-pause.ts new file mode 100644 index 0000000000..28d98272d5 --- /dev/null +++ b/extensions/raycast-cap/src/toggle-pause.ts @@ -0,0 +1,19 @@ +import { showToast, Toast } from "@raycast/api"; +import { openDeepLink, generateDeepLink } from "./utils"; + +export default async function Command() { + try { + await openDeepLink(generateDeepLink("toggle-pause")); + + await showToast({ + style: Toast.Style.Success, + title: "Toggled Recording Pause", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Toggle Pause", + message: String(error), + }); + } +} diff --git a/extensions/raycast-cap/src/utils.ts b/extensions/raycast-cap/src/utils.ts new file mode 100644 index 0000000000..150f78a6fa --- /dev/null +++ b/extensions/raycast-cap/src/utils.ts @@ -0,0 +1,41 @@ +import { showToast, Toast } from "@raycast/api"; +import { exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); + +/** + * Open a deep link URL in Cap + */ +export async function openDeepLink(url: string): Promise { + try { + // Try using open command on macOS + await execAsync(`open "${url}"`); + } catch (error) { + // Fallback: show error + await showToast({ + style: Toast.Style.Failure, + title: "Failed to open Cap", + message: "Make sure Cap is installed", + }); + throw error; + } +} + +/** + * Generate deep link URL for Cap commands + */ +export function generateDeepLink( + action: string, + params?: Record +): string { + const url = new URL(`cap://${action}`); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + } + + return url.toString(); +} diff --git a/extensions/raycast-cap/tsconfig.json b/extensions/raycast-cap/tsconfig.json new file mode 100644 index 0000000000..2348a4e30f --- /dev/null +++ b/extensions/raycast-cap/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "outDir": "dist" + }, + "include": ["src/**/*"] +}