diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index dbd90f667f..8fd8ae57f1 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, + SwitchCamera { + camera: DeviceOrModelID, + }, + SwitchMicrophone { + mic_label: String, + }, OpenEditor { project_path: PathBuf, }, @@ -146,6 +155,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::SwitchCamera { camera } => { + let state = app.state::>(); + crate::set_camera_input(app.clone(), state, Some(camera)).await + } + DeepLinkAction::SwitchMicrophone { mic_label } => { + let state = app.state::>(); + crate::set_mic_input(state, Some(mic_label)).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..cfbcf6268e --- /dev/null +++ b/extensions/raycast/README.md @@ -0,0 +1,72 @@ +# Cap Raycast Extension + +Control Cap screen recording directly from Raycast. + +## Features + +- **Start Recording**: Choose between screen or window capture with Studio or Instant mode +- **Stop Recording**: Quickly stop your current recording +- **Pause Recording**: Pause the recording without stopping +- **Resume Recording**: Resume a paused recording +- **Toggle Pause**: Toggle between pause and resume states +- **Switch Camera**: Change the camera being used during recording +- **Switch Microphone**: Change the microphone being used during recording + +## Installation + +### From Source + +1. Clone the Cap repository +2. Navigate to the extension directory: + ```bash + cd extensions/raycast + ``` +3. Install dependencies: + ```bash + npm install + ``` +4. Build and import to Raycast: + ```bash + npm run dev + ``` + +## Usage + +Once installed, you can access all Cap commands from Raycast: + +- Type "Cap" in Raycast to see all available commands +- Use keyboard shortcuts to quickly control your recordings +- Commands execute instantly without opening the Cap UI + +## Requirements + +- Cap desktop application must be installed and running +- macOS (Cap is currently macOS-only) +- Raycast + +## Deep Link Protocol + +This extension uses Cap's deep link protocol (`cap://action`) to communicate with the desktop application. All commands are executed via URL schemes that trigger the corresponding actions in Cap. + +## Development + +```bash +# Install dependencies +npm install + +# Run in development mode +npm run dev + +# Build for production +npm run build + +# Lint code +npm run lint + +# Fix linting issues +npm run fix-lint +``` + +## License + +MIT diff --git a/extensions/raycast/package.json b/extensions/raycast/package.json new file mode 100644 index 0000000000..2831264953 --- /dev/null +++ b/extensions/raycast/package.json @@ -0,0 +1,77 @@ +{ + "$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", + "Media" + ], + "license": "MIT", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "description": "Start a new Cap recording", + "mode": "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 Recording", + "description": "Toggle pause/resume for the current recording", + "mode": "no-view" + }, + { + "name": "switch-camera", + "title": "Switch Camera", + "description": "Switch to a different camera", + "mode": "view" + }, + { + "name": "switch-microphone", + "title": "Switch Microphone", + "description": "Switch to a different microphone", + "mode": "view" + } + ], + "dependencies": { + "@raycast/api": "^1.48.0" + }, + "devDependencies": { + "@types/node": "18.8.3", + "@types/react": "18.0.9", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^8.0.0", + "eslint-config-prettier": "^8.3.0", + "prettier": "^2.5.1", + "typescript": "^4.4.3" + }, + "scripts": { + "build": "ray build -e dist", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint", + "publish": "npx @raycast/api@latest publish" + } +} diff --git a/extensions/raycast/src/pause-recording.tsx b/extensions/raycast/src/pause-recording.tsx new file mode 100644 index 0000000000..cfa31d9b08 --- /dev/null +++ b/extensions/raycast/src/pause-recording.tsx @@ -0,0 +1,26 @@ +import { showToast, Toast, closeMainWindow } from "@raycast/api"; +import { executeCapAction } from "./utils"; + +export default async function Command() { + try { + await closeMainWindow(); + + await showToast({ + style: Toast.Style.Animated, + title: "Pausing recording...", + }); + + await executeCapAction({ pause_recording: {} }); + + await showToast({ + style: Toast.Style.Success, + title: "Recording paused", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to pause recording", + message: String(error), + }); + } +} diff --git a/extensions/raycast/src/resume-recording.tsx b/extensions/raycast/src/resume-recording.tsx new file mode 100644 index 0000000000..ab56453468 --- /dev/null +++ b/extensions/raycast/src/resume-recording.tsx @@ -0,0 +1,26 @@ +import { showToast, Toast, closeMainWindow } from "@raycast/api"; +import { executeCapAction } from "./utils"; + +export default async function Command() { + try { + await closeMainWindow(); + + await showToast({ + style: Toast.Style.Animated, + title: "Resuming recording...", + }); + + await executeCapAction({ resume_recording: {} }); + + await showToast({ + style: Toast.Style.Success, + title: "Recording resumed", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to resume recording", + message: String(error), + }); + } +} diff --git a/extensions/raycast/src/start-recording.tsx b/extensions/raycast/src/start-recording.tsx new file mode 100644 index 0000000000..086d3a62eb --- /dev/null +++ b/extensions/raycast/src/start-recording.tsx @@ -0,0 +1,90 @@ +import { ActionPanel, Action, List, showToast, Toast } from "@raycast/api"; +import { executeCapAction, RecordingMode } from "./utils"; + +export default function Command() { + const captureOptions = [ + { title: "Full Screen", value: "screen" }, + { title: "Window", value: "window" }, + ]; + + const recordingModes: { title: string; value: RecordingMode }[] = [ + { title: "Studio Mode", value: "studio" }, + { title: "Instant Mode", value: "instant" }, + ]; + + async function startRecording( + captureType: "screen" | "window", + mode: RecordingMode, + captureSystemAudio: boolean + ) { + try { + await showToast({ + style: Toast.Style.Animated, + title: "Starting recording...", + }); + + // For simplicity, using default screen/window + // In a real implementation, you'd want to list available screens/windows + const capture_mode = + captureType === "screen" + ? { screen: "Default Screen" } + : { window: "Default Window" }; + + await executeCapAction({ + start_recording: { + capture_mode, + capture_system_audio: captureSystemAudio, + mode, + }, + }); + + await showToast({ + style: Toast.Style.Success, + title: "Recording started", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to start recording", + message: String(error), + }); + } + } + + return ( + + {captureOptions.map((captureOption) => + recordingModes.map((mode) => ( + + + startRecording( + captureOption.value as "screen" | "window", + mode.value, + false + ) + } + /> + + startRecording( + captureOption.value as "screen" | "window", + mode.value, + true + ) + } + /> + + } + /> + )) + )} + + ); +} diff --git a/extensions/raycast/src/stop-recording.tsx b/extensions/raycast/src/stop-recording.tsx new file mode 100644 index 0000000000..95cce2efa0 --- /dev/null +++ b/extensions/raycast/src/stop-recording.tsx @@ -0,0 +1,26 @@ +import { showToast, Toast, closeMainWindow } from "@raycast/api"; +import { executeCapAction } from "./utils"; + +export default async function Command() { + try { + await closeMainWindow(); + + await showToast({ + style: Toast.Style.Animated, + title: "Stopping recording...", + }); + + await executeCapAction({ stop_recording: {} }); + + await showToast({ + style: Toast.Style.Success, + title: "Recording stopped", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to stop recording", + message: String(error), + }); + } +} diff --git a/extensions/raycast/src/switch-camera.tsx b/extensions/raycast/src/switch-camera.tsx new file mode 100644 index 0000000000..395e56ae4b --- /dev/null +++ b/extensions/raycast/src/switch-camera.tsx @@ -0,0 +1,56 @@ +import { ActionPanel, Action, List, showToast, Toast } from "@raycast/api"; +import { executeCapAction } from "./utils"; + +export default function Command() { + // In a real implementation, you would fetch available cameras + // For now, we'll use placeholder camera names + const cameras = [ + { id: "default", name: "Default Camera" }, + { id: "facetime", name: "FaceTime HD Camera" }, + ]; + + async function switchCamera(cameraId: string, cameraName: string) { + try { + await showToast({ + style: Toast.Style.Animated, + title: `Switching to ${cameraName}...`, + }); + + await executeCapAction({ + switch_camera: { + camera: cameraId, + }, + }); + + await showToast({ + style: Toast.Style.Success, + title: `Switched to ${cameraName}`, + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to switch camera", + message: String(error), + }); + } + } + + return ( + + {cameras.map((camera) => ( + + switchCamera(camera.id, camera.name)} + /> + + } + /> + ))} + + ); +} diff --git a/extensions/raycast/src/switch-microphone.tsx b/extensions/raycast/src/switch-microphone.tsx new file mode 100644 index 0000000000..38a61e2b2d --- /dev/null +++ b/extensions/raycast/src/switch-microphone.tsx @@ -0,0 +1,56 @@ +import { ActionPanel, Action, List, showToast, Toast } from "@raycast/api"; +import { executeCapAction } from "./utils"; + +export default function Command() { + // In a real implementation, you would fetch available microphones + // For now, we'll use placeholder microphone names + const microphones = [ + { label: "default", name: "Default Microphone" }, + { label: "built-in", name: "Built-in Microphone" }, + ]; + + async function switchMicrophone(micLabel: string, micName: string) { + try { + await showToast({ + style: Toast.Style.Animated, + title: `Switching to ${micName}...`, + }); + + await executeCapAction({ + switch_microphone: { + mic_label: micLabel, + }, + }); + + await showToast({ + style: Toast.Style.Success, + title: `Switched to ${micName}`, + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to switch microphone", + message: String(error), + }); + } + } + + return ( + + {microphones.map((mic) => ( + + switchMicrophone(mic.label, mic.name)} + /> + + } + /> + ))} + + ); +} diff --git a/extensions/raycast/src/toggle-pause.tsx b/extensions/raycast/src/toggle-pause.tsx new file mode 100644 index 0000000000..f5a4ea1bd0 --- /dev/null +++ b/extensions/raycast/src/toggle-pause.tsx @@ -0,0 +1,26 @@ +import { showToast, Toast, closeMainWindow } from "@raycast/api"; +import { executeCapAction } from "./utils"; + +export default async function Command() { + try { + await closeMainWindow(); + + await showToast({ + style: Toast.Style.Animated, + title: "Toggling pause...", + }); + + await executeCapAction({ toggle_pause_recording: {} }); + + await showToast({ + style: Toast.Style.Success, + title: "Recording pause toggled", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to toggle pause", + message: String(error), + }); + } +} diff --git a/extensions/raycast/src/utils.ts b/extensions/raycast/src/utils.ts new file mode 100644 index 0000000000..a2b4e6d79a --- /dev/null +++ b/extensions/raycast/src/utils.ts @@ -0,0 +1,40 @@ +import { open } from "@raycast/api"; + +export type CaptureMode = + | { screen: string } + | { window: string }; + +export type RecordingMode = "studio" | "instant" | "screenshot"; + +export interface StartRecordingOptions { + capture_mode: CaptureMode; + camera?: string; + mic_label?: string; + capture_system_audio: boolean; + mode: RecordingMode; +} + +export interface SwitchCameraOptions { + camera: string; +} + +export interface SwitchMicrophoneOptions { + mic_label: string; +} + +export type DeepLinkAction = + | { start_recording: StartRecordingOptions } + | { stop_recording: {} } + | { pause_recording: {} } + | { resume_recording: {} } + | { toggle_pause_recording: {} } + | { switch_camera: SwitchCameraOptions } + | { switch_microphone: SwitchMicrophoneOptions }; + +export async function executeCapAction(action: DeepLinkAction): Promise { + const actionJson = JSON.stringify(action); + const encodedAction = encodeURIComponent(actionJson); + const deepLink = `cap://action?value=${encodedAction}`; + + await open(deepLink); +} diff --git a/extensions/raycast/tsconfig.json b/extensions/raycast/tsconfig.json new file mode 100644 index 0000000000..fc9372fd42 --- /dev/null +++ b/extensions/raycast/tsconfig.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "jsx": "react", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "outDir": "dist" + }, + "include": ["src/**/*"] +}