diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index dbd90f667f..81095e2e0f 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -26,6 +26,22 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, + PauseRecording, + ResumeRecording, + TogglePauseRecording, + TakeScreenshot { + capture_target: ScreenCaptureTarget, + }, + SetCamera { + id: Option, + }, + SetMicrophone { + label: Option, + }, + ListCameras, + ListMicrophones, + ListDisplays, + ListWindows, OpenEditor { project_path: PathBuf, }, @@ -146,6 +162,73 @@ 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::TakeScreenshot { capture_target } => { + crate::recording::take_screenshot(app.clone(), capture_target) + .await + .map(|_| ()) + } + DeepLinkAction::SetCamera { id } => { + crate::set_camera_input(app.clone(), app.state(), id).await + } + DeepLinkAction::SetMicrophone { label } => { + crate::set_mic_input(app.state(), label).await + } + DeepLinkAction::ListCameras => { + let cameras = crate::recording::list_cameras(); + let cameras_json = serde_json::to_string(&cameras) + .map_err(|e| format!("Failed to serialize cameras: {}", e))?; + tracing::info!("Available cameras: {}", cameras_json); + Ok(()) + } + DeepLinkAction::ListMicrophones => { + use cap_recording::feeds::microphone::MicrophoneFeed; + let microphones: Vec = MicrophoneFeed::list().keys().cloned().collect(); + let mics_json = serde_json::to_string(µphones) + .map_err(|e| format!("Failed to serialize microphones: {}", e))?; + tracing::info!("Available microphones: {}", mics_json); + Ok(()) + } + DeepLinkAction::ListDisplays => { + let displays = cap_recording::screen_capture::list_displays(); + let displays_data: Vec<_> = displays + .into_iter() + .map(|(capture_display, _)| { + serde_json::json!({ + "id": capture_display.id, + "name": capture_display.name, + }) + }) + .collect(); + let displays_json = serde_json::to_string(&displays_data) + .map_err(|e| format!("Failed to serialize displays: {}", e))?; + tracing::info!("Available displays: {}", displays_json); + Ok(()) + } + DeepLinkAction::ListWindows => { + let windows = cap_recording::screen_capture::list_windows(); + let windows_data: Vec<_> = windows + .into_iter() + .map(|(capture_window, _)| { + serde_json::json!({ + "id": capture_window.id, + "name": capture_window.name, + }) + }) + .collect(); + let windows_json = serde_json::to_string(&windows_data) + .map_err(|e| format!("Failed to serialize windows: {}", e))?; + tracing::info!("Available windows: {}", windows_json); + Ok(()) + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 90803f8abe..5576fc6273 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -382,7 +382,7 @@ impl App { #[tauri::command] #[specta::specta] #[instrument(skip(state))] -async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> Result<(), String> { +pub(crate) async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> Result<(), String> { let (mic_feed, studio_handle, current_label) = { let app = state.read().await; let handle = match app.current_recording() { @@ -468,7 +468,7 @@ fn get_system_diagnostics() -> cap_recording::diagnostics::SystemDiagnostics { #[specta::specta] #[instrument(skip(app_handle, state))] #[allow(unused_mut)] -async fn set_camera_input( +pub(crate) async fn set_camera_input( app_handle: AppHandle, state: MutableState<'_, App>, id: Option, diff --git a/apps/raycast-extension/README.md b/apps/raycast-extension/README.md new file mode 100644 index 0000000000..76d51b5fb8 --- /dev/null +++ b/apps/raycast-extension/README.md @@ -0,0 +1,177 @@ +# Cap Raycast Extension + +Control [Cap](https://cap.so) screen recording directly from Raycast! + +## Features + +This extension provides quick access to Cap's recording functionality through Raycast commands: + +### Recording Controls +- **Start Recording** - Start a new screen or window recording with customizable options +- **Stop Recording** - Stop the current recording +- **Pause Recording** - Pause the active recording +- **Resume Recording** - Resume a paused recording +- **Toggle Pause** - Toggle between paused and active recording states + +### Capture +- **Take Screenshot** - Capture a screenshot of a specific display or window + +### Hardware Management +- **Switch Camera** - Change the active camera input or disable camera +- **Switch Microphone** - Change the active microphone input or mute + +## Requirements + +- [Cap](https://cap.so) desktop application (v0.3.0 or later) must be installed +- macOS (Raycast is macOS-only) +- Cap must be running to respond to commands + +## Installation + +### From Raycast Store (Coming Soon) +1. Open Raycast +2. Search for "Cap" +3. Click "Install Extension" + +### Manual Installation (Development) +1. Clone the repository: + ```bash + git clone https://github.com/CapSoftware/Cap.git + cd Cap/apps/raycast-extension + ``` + +2. Install dependencies: + ```bash + npm install + ``` + +3. Build the extension: + ```bash + npm run dev + ``` + +4. The extension will automatically be available in Raycast during development + +## Usage + +### Starting a Recording +1. Open Raycast (⌘ + Space) +2. Type "Start Recording" +3. Fill in the form: + - **Capture Type**: Choose Screen or Window + - **Target Name**: Enter the display/window name + - **Recording Mode**: Studio (editable) or Instant (immediately uploaded) + - **Enable Camera**: Toggle camera on/off + - **Enable Microphone**: Toggle microphone on/off + - **Capture System Audio**: Include system audio in recording +4. Press Enter to start recording + +### Finding Display/Window Names +Use the built-in Cap commands to list available targets: +- In your terminal, run Cap with `--list-displays` or `--list-windows` flags +- Or check the Cap UI for display/window names + +### Quick Actions +All other commands are instant actions: +- **Stop Recording**: Simply run the command +- **Pause/Resume**: Run the respective command while recording +- **Toggle Pause**: Quick shortcut to toggle pause state +- **Take Screenshot**: Fill in the target and capture instantly + +### Hardware Switching +1. Run "Switch Camera" or "Switch Microphone" +2. Enter the device ID or name +3. Toggle enable/disable as needed +4. Press Enter to switch + +**Tip**: Use the "List Cameras" and "List Microphones" Cap commands to see available devices + +## Commands Reference + +| Command | Shortcut | Description | +|---------|----------|-------------| +| Start Recording | - | Start a new recording with options | +| Stop Recording | - | Stop the current recording | +| Pause Recording | - | Pause the active recording | +| Resume Recording | - | Resume a paused recording | +| Toggle Pause | - | Toggle pause state | +| Take Screenshot | - | Capture a screenshot | +| Switch Camera | - | Change camera input | +| Switch Microphone | - | Change microphone input | + +## Troubleshooting + +### Command Not Working +- Ensure Cap is running +- Check that Cap has necessary permissions (Screen Recording, Camera, Microphone) +- Verify you're running Cap v0.3.0 or later with deeplink support + +### "Failed to Start Recording" +- Double-check the display/window name is correct +- Ensure the target display/window exists and is accessible +- Check Cap's permissions in System Settings > Privacy & Security + +### Camera/Microphone Not Switching +- Verify the device ID/name is correct +- Check that the device is connected and recognized by your system +- Ensure Cap has permission to access camera/microphone + +## Development + +### Project Structure +``` +src/ +├── utils/ +│ └── deeplink.ts # Deeplink utility functions +├── start-recording.tsx # Start recording command +├── stop-recording.tsx # Stop recording command +├── pause-recording.tsx # Pause command +├── resume-recording.tsx # Resume command +├── toggle-pause.tsx # Toggle pause command +├── take-screenshot.tsx # Screenshot command +├── switch-camera.tsx # Camera switching command +└── switch-microphone.tsx # Microphone switching command +``` + +### Building +```bash +npm run build +``` + +### Linting +```bash +npm run lint +npm run fix-lint +``` + +## How It Works + +This extension communicates with Cap using the `cap-desktop://` URL scheme. Each command constructs a deeplink URL with JSON-encoded actions and opens it, which Cap intercepts and executes. + +Example deeplink: +``` +cap-desktop://action?value={"pauseRecording":{}} +``` + +## Contributing + +Contributions are welcome! Please: +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Submit a pull request + +## License + +MIT License - see LICENSE file for details + +## Links + +- [Cap Website](https://cap.so) +- [Cap GitHub](https://github.com/CapSoftware/Cap) +- [Report Issues](https://github.com/CapSoftware/Cap/issues) +- [Raycast](https://raycast.com) + +## Credits + +Created for the Cap deeplinks bounty (#1540) diff --git a/apps/raycast-extension/package.json b/apps/raycast-extension/package.json new file mode 100644 index 0000000000..604837056d --- /dev/null +++ b/apps/raycast-extension/package.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap screen recording from Raycast", + "icon": "icon.png", + "author": "cap", + "license": "MIT", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "description": "Start a new screen 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", + "description": "Toggle recording pause state", + "mode": "no-view" + }, + { + "name": "take-screenshot", + "title": "Take Screenshot", + "description": "Capture a screenshot", + "mode": "view" + }, + { + "name": "switch-camera", + "title": "Switch Camera", + "description": "Change camera input", + "mode": "view" + }, + { + "name": "switch-microphone", + "title": "Switch Microphone", + "description": "Change microphone input", + "mode": "view" + } + ], + "dependencies": { + "@raycast/api": "^1.83.2", + "@raycast/utils": "^1.19.0" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "^20.16.5", + "@types/react": "^18.3.3", + "eslint": "^8.57.0", + "prettier": "^3.3.3", + "typescript": "^5.5.4" + }, + "scripts": { + "build": "ray build -e dist", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint", + "publish": "npx @raycast/api@latest publish" + } +} \ No newline at end of file diff --git a/apps/raycast-extension/src/pause-recording.tsx b/apps/raycast-extension/src/pause-recording.tsx new file mode 100644 index 0000000000..78ebe24f4b --- /dev/null +++ b/apps/raycast-extension/src/pause-recording.tsx @@ -0,0 +1,19 @@ +import { showToast, Toast } from "@raycast/api"; +import * as deeplink from "./utils/deeplink"; + +export default async function Command() { + try { + await deeplink.pauseRecording(); + await showToast({ + style: Toast.Style.Success, + title: "Recording Paused", + message: "Cap recording has been paused", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Pause Recording", + message: error instanceof Error ? error.message : "Unknown error occurred", + }); + } +} diff --git a/apps/raycast-extension/src/resume-recording.tsx b/apps/raycast-extension/src/resume-recording.tsx new file mode 100644 index 0000000000..e1846b517c --- /dev/null +++ b/apps/raycast-extension/src/resume-recording.tsx @@ -0,0 +1,19 @@ +import { showToast, Toast } from "@raycast/api"; +import * as deeplink from "./utils/deeplink"; + +export default async function Command() { + try { + await deeplink.resumeRecording(); + await showToast({ + style: Toast.Style.Success, + title: "Recording Resumed", + message: "Cap recording has been resumed", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Resume Recording", + message: error instanceof Error ? error.message : "Unknown error occurred", + }); + } +} diff --git a/apps/raycast-extension/src/start-recording.tsx b/apps/raycast-extension/src/start-recording.tsx new file mode 100644 index 0000000000..66d6144983 --- /dev/null +++ b/apps/raycast-extension/src/start-recording.tsx @@ -0,0 +1,84 @@ +import { Action, ActionPanel, Form, showToast, Toast, popToRoot } from "@raycast/api"; +import { useState } from "react"; +import * as deeplink from "./utils/deeplink"; + +interface FormValues { + captureType: "screen" | "window"; + targetName: string; + camera: boolean; + microphone: boolean; + systemAudio: boolean; + mode: "Studio" | "Instant"; +} + +export default function Command() { + const [isLoading, setIsLoading] = useState(false); + + async function handleSubmit(values: FormValues) { + setIsLoading(true); + try { + const captureMode = + values.captureType === "screen" ? { screen: values.targetName } : { window: values.targetName }; + + await deeplink.startRecording({ + captureMode, + camera: null, + micLabel: null, + captureSystemAudio: values.systemAudio, + mode: values.mode, + }); + + await showToast({ + style: Toast.Style.Success, + title: "Recording Started", + message: `Recording ${values.targetName} in ${values.mode} mode`, + }); + + await popToRoot(); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Start Recording", + message: error instanceof Error ? error.message : "Unknown error occurred", + }); + } finally { + setIsLoading(false); + } + } + + return ( +
+ + + } + > + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/raycast-extension/src/stop-recording.tsx b/apps/raycast-extension/src/stop-recording.tsx new file mode 100644 index 0000000000..b55bfd16ba --- /dev/null +++ b/apps/raycast-extension/src/stop-recording.tsx @@ -0,0 +1,19 @@ +import { showToast, Toast } from "@raycast/api"; +import * as deeplink from "./utils/deeplink"; + +export default async function Command() { + try { + await deeplink.stopRecording(); + await showToast({ + style: Toast.Style.Success, + title: "Recording Stopped", + message: "Cap recording has been stopped", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Stop Recording", + message: error instanceof Error ? error.message : "Unknown error occurred", + }); + } +} diff --git a/apps/raycast-extension/src/switch-camera.tsx b/apps/raycast-extension/src/switch-camera.tsx new file mode 100644 index 0000000000..a9c6086f27 --- /dev/null +++ b/apps/raycast-extension/src/switch-camera.tsx @@ -0,0 +1,57 @@ +import { Action, ActionPanel, Form, showToast, Toast, popToRoot } from "@raycast/api"; +import { useState } from "react"; +import * as deeplink from "./utils/deeplink"; + +interface FormValues { + cameraId: string; + enableCamera: boolean; +} + +export default function Command() { + const [isLoading, setIsLoading] = useState(false); + + async function handleSubmit(values: FormValues) { + setIsLoading(true); + try { + const cameraDevice = values.enableCamera && values.cameraId ? { Device: values.cameraId } : null; + + await deeplink.setCamera(cameraDevice); + + await showToast({ + style: Toast.Style.Success, + title: values.enableCamera ? "Camera Switched" : "Camera Disabled", + message: values.enableCamera ? `Switched to camera: ${values.cameraId}` : "Camera has been disabled", + }); + + await popToRoot(); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Switch Camera", + message: error instanceof Error ? error.message : "Unknown error occurred", + }); + } finally { + setIsLoading(false); + } + } + + return ( +
+ + + } + > + + + + + ); +} diff --git a/apps/raycast-extension/src/switch-microphone.tsx b/apps/raycast-extension/src/switch-microphone.tsx new file mode 100644 index 0000000000..1856f57d35 --- /dev/null +++ b/apps/raycast-extension/src/switch-microphone.tsx @@ -0,0 +1,57 @@ +import { Action, ActionPanel, Form, showToast, Toast, popToRoot } from "@raycast/api"; +import { useState } from "react"; +import * as deeplink from "./utils/deeplink"; + +interface FormValues { + microphoneName: string; + enableMicrophone: boolean; +} + +export default function Command() { + const [isLoading, setIsLoading] = useState(false); + + async function handleSubmit(values: FormValues) { + setIsLoading(true); + try { + const micLabel = values.enableMicrophone && values.microphoneName ? values.microphoneName : null; + + await deeplink.setMicrophone(micLabel); + + await showToast({ + style: Toast.Style.Success, + title: values.enableMicrophone ? "Microphone Switched" : "Microphone Muted", + message: values.enableMicrophone ? `Switched to: ${values.microphoneName}` : "Microphone has been muted", + }); + + await popToRoot(); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Switch Microphone", + message: error instanceof Error ? error.message : "Unknown error occurred", + }); + } finally { + setIsLoading(false); + } + } + + return ( +
+ + + } + > + + + + + ); +} diff --git a/apps/raycast-extension/src/take-screenshot.tsx b/apps/raycast-extension/src/take-screenshot.tsx new file mode 100644 index 0000000000..7a97ec4ce6 --- /dev/null +++ b/apps/raycast-extension/src/take-screenshot.tsx @@ -0,0 +1,63 @@ +import { Action, ActionPanel, Form, showToast, Toast, popToRoot } from "@raycast/api"; +import { useState } from "react"; +import * as deeplink from "./utils/deeplink"; + +interface FormValues { + captureType: "screen" | "window"; + targetName: string; +} + +export default function Command() { + const [isLoading, setIsLoading] = useState(false); + + async function handleSubmit(values: FormValues) { + setIsLoading(true); + try { + const captureTarget = + values.captureType === "screen" + ? { display: { id: values.targetName } } + : { window: { id: values.targetName } }; + + await deeplink.takeScreenshot(captureTarget); + + await showToast({ + style: Toast.Style.Success, + title: "Screenshot Captured", + message: `Screenshot of ${values.targetName} saved`, + }); + + await popToRoot(); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Take Screenshot", + message: error instanceof Error ? error.message : "Unknown error occurred", + }); + } finally { + setIsLoading(false); + } + } + + return ( +
+ + + } + > + + + + + + + + ); +} diff --git a/apps/raycast-extension/src/toggle-pause.tsx b/apps/raycast-extension/src/toggle-pause.tsx new file mode 100644 index 0000000000..af42f042a7 --- /dev/null +++ b/apps/raycast-extension/src/toggle-pause.tsx @@ -0,0 +1,19 @@ +import { showToast, Toast } from "@raycast/api"; +import * as deeplink from "./utils/deeplink"; + +export default async function Command() { + try { + await deeplink.togglePauseRecording(); + await showToast({ + style: Toast.Style.Success, + title: "Recording Toggled", + message: "Cap recording pause state toggled", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to Toggle Pause", + message: error instanceof Error ? error.message : "Unknown error occurred", + }); + } +} diff --git a/apps/raycast-extension/src/utils/deeplink.ts b/apps/raycast-extension/src/utils/deeplink.ts new file mode 100644 index 0000000000..a687263bff --- /dev/null +++ b/apps/raycast-extension/src/utils/deeplink.ts @@ -0,0 +1,89 @@ +export interface CaptureTarget { + display?: { id: string }; + window?: { id: string }; +} + +export interface DeviceOrModelID { + Device?: string; + ModelID?: string; +} + +export function buildDeeplinkURL(action: Record): string { + const jsonValue = JSON.stringify(action); + const encodedValue = encodeURIComponent(jsonValue); + return `cap-desktop://action?value=${encodedValue}`; +} + +export async function triggerDeeplink(action: Record): Promise { + const url = buildDeeplinkURL(action); + const { open } = await import("@raycast/api"); + await open(url); +} + +export async function startRecording(options: { + captureMode: { screen: string } | { window: string }; + camera?: DeviceOrModelID | null; + micLabel?: string | null; + captureSystemAudio?: boolean; + mode?: "Studio" | "Instant"; +}): Promise { + const action = { + start_recording: { + capture_mode: options.captureMode, + camera: options.camera ?? null, + mic_label: options.micLabel ?? null, + capture_system_audio: options.captureSystemAudio ?? false, + mode: options.mode ?? "Studio", + }, + }; + await triggerDeeplink(action); +} + +export async function stopRecording(): Promise { + await triggerDeeplink({ stop_recording: {} }); +} + +export async function pauseRecording(): Promise { + await triggerDeeplink({ pause_recording: {} }); +} + +export async function resumeRecording(): Promise { + await triggerDeeplink({ resume_recording: {} }); +} + +export async function togglePauseRecording(): Promise { + await triggerDeeplink({ toggle_pause_recording: {} }); +} + +export async function takeScreenshot(captureTarget: CaptureTarget): Promise { + const action = { + take_screenshot: { + capture_target: captureTarget, + }, + }; + await triggerDeeplink(action); +} + +export async function setCamera(id: DeviceOrModelID | null): Promise { + await triggerDeeplink({ set_camera: { id } }); +} + +export async function setMicrophone(label: string | null): Promise { + await triggerDeeplink({ set_microphone: { label } }); +} + +export async function listCameras(): Promise { + await triggerDeeplink({ list_cameras: {} }); +} + +export async function listMicrophones(): Promise { + await triggerDeeplink({ list_microphones: {} }); +} + +export async function listDisplays(): Promise { + await triggerDeeplink({ list_displays: {} }); +} + +export async function listWindows(): Promise { + await triggerDeeplink({ list_windows: {} }); +} diff --git a/apps/raycast-extension/tsconfig.json b/apps/raycast-extension/tsconfig.json new file mode 100644 index 0000000000..83aa6c05c2 --- /dev/null +++ b/apps/raycast-extension/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2021", + "lib": [ + "ES2021" + ], + "module": "commonjs", + "jsx": "react", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file