From 1b5bc9fc4092cbbe2bab70ee99e74f1aebb64b3b Mon Sep 17 00:00:00 2001 From: Grayking1905 Date: Sun, 1 Feb 2026 23:33:24 +0530 Subject: [PATCH] feat: Add deeplinks support and Raycast extension - Extended DeepLinkAction enum with pause/resume/toggle recording - Added device management deeplinks (switch microphone/camera) - Added device discovery deeplinks (list microphones/cameras) - Implemented input validation and error handling - Created Raycast extension with 6 commands - Added comprehensive unit tests for Rust and TypeScript - Added documentation and usage examples --- .../desktop/src-tauri/src/deeplink_actions.rs | 287 ++++++++++++++++++ extensions/raycast/README.md | 155 ++++++++++ extensions/raycast/assets/.gitkeep | 0 extensions/raycast/package.json | 64 ++++ .../raycast/src/__tests__/deeplink.test.ts | 132 ++++++++ extensions/raycast/src/pause-recording.tsx | 19 ++ extensions/raycast/src/resume-recording.tsx | 19 ++ extensions/raycast/src/stop-recording.tsx | 19 ++ extensions/raycast/src/switch-camera.tsx | 62 ++++ extensions/raycast/src/switch-microphone.tsx | 61 ++++ extensions/raycast/src/toggle-pause.tsx | 19 ++ extensions/raycast/src/utils/deeplink.ts | 15 + extensions/raycast/src/utils/devices.ts | 46 +++ extensions/raycast/tsconfig.json | 18 ++ 14 files changed, 916 insertions(+) create mode 100644 extensions/raycast/README.md create mode 100644 extensions/raycast/assets/.gitkeep create mode 100644 extensions/raycast/package.json create mode 100644 extensions/raycast/src/__tests__/deeplink.test.ts create mode 100644 extensions/raycast/src/pause-recording.tsx create mode 100644 extensions/raycast/src/resume-recording.tsx create mode 100644 extensions/raycast/src/stop-recording.tsx create mode 100644 extensions/raycast/src/switch-camera.tsx create mode 100644 extensions/raycast/src/switch-microphone.tsx create mode 100644 extensions/raycast/src/toggle-pause.tsx create mode 100644 extensions/raycast/src/utils/deeplink.ts create mode 100644 extensions/raycast/src/utils/devices.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 fce75b4a84..f3b36d201e 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -26,6 +26,17 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, + PauseRecording, + ResumeRecording, + TogglePauseRecording, + SwitchMicrophone { + mic_label: String, + }, + SwitchCamera { + camera: DeviceOrModelID, + }, + ListMicrophones, + ListCameras, OpenEditor { project_path: PathBuf, }, @@ -146,6 +157,68 @@ 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 { mic_label } => { + use cap_recording::feeds::microphone::MicrophoneFeed; + + let available_mics = MicrophoneFeed::list(); + if !available_mics.contains_key(&mic_label) { + let available: Vec = available_mics.keys().cloned().collect(); + return Err(format!( + "Microphone '{}' not found. Available microphones: {}", + mic_label, + available.join(", ") + )); + } + + crate::set_mic_input(app.state(), Some(mic_label)).await + } + DeepLinkAction::SwitchCamera { camera } => { + let available_cameras: Vec<_> = cap_camera::list_cameras().collect(); + let camera_exists = available_cameras.iter().any(|c| { + c.device_id() == camera.device_id() + || camera + .model_id() + .map_or(false, |mid| Some(mid) == c.model_id()) + }); + + if !camera_exists { + let available: Vec = available_cameras + .iter() + .map(|c| format!("{} ({})", c.display_name(), c.device_id())) + .collect(); + return Err(format!( + "Camera not found. Available cameras: {}", + available.join(", ") + )); + } + + crate::set_camera_input(app.clone(), app.state(), Some(camera), None) + .await + .map(|_| ()) + } + DeepLinkAction::ListMicrophones => { + let mics = list_available_microphones()?; + let json = serde_json::to_string(&mics) + .map_err(|e| format!("Failed to serialize microphones: {}", e))?; + println!("{}", json); + Ok(()) + } + DeepLinkAction::ListCameras => { + let cameras = list_available_cameras()?; + let json = serde_json::to_string(&cameras) + .map_err(|e| format!("Failed to serialize cameras: {}", e))?; + println!("{}", json); + Ok(()) + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } @@ -155,3 +228,217 @@ impl DeepLinkAction { } } } + +#[derive(Debug, Serialize, Deserialize)] +pub struct MicrophoneInfo { + pub label: String, + pub is_default: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CameraInfo { + pub id: String, + pub name: String, + pub is_default: bool, +} + +fn list_available_microphones() -> Result, String> { + use cap_recording::feeds::microphone::MicrophoneFeed; + + let mics: Vec = MicrophoneFeed::list() + .into_iter() + .enumerate() + .map(|(idx, (label, _))| MicrophoneInfo { + label, + is_default: idx == 0, + }) + .collect(); + + Ok(mics) +} + +fn list_available_cameras() -> Result, String> { + let cameras: Vec = cap_camera::list_cameras() + .enumerate() + .map(|(idx, camera)| CameraInfo { + id: camera.device_id().to_string(), + name: camera.display_name().to_string(), + is_default: idx == 0, + }) + .collect(); + + Ok(cameras) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pause_recording_deeplink_parsing() { + let json = r#"{"pause_recording":{}}"#; + let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); + let url = Url::parse(&url_str).unwrap(); + + let action = DeepLinkAction::try_from(&url).unwrap(); + assert!(matches!(action, DeepLinkAction::PauseRecording)); + } + + #[test] + fn test_resume_recording_deeplink_parsing() { + let json = r#"{"resume_recording":{}}"#; + let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); + let url = Url::parse(&url_str).unwrap(); + + let action = DeepLinkAction::try_from(&url).unwrap(); + assert!(matches!(action, DeepLinkAction::ResumeRecording)); + } + + #[test] + fn test_toggle_pause_recording_deeplink_parsing() { + let json = r#"{"toggle_pause_recording":{}}"#; + let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); + let url = Url::parse(&url_str).unwrap(); + + let action = DeepLinkAction::try_from(&url).unwrap(); + assert!(matches!(action, DeepLinkAction::TogglePauseRecording)); + } + + #[test] + fn test_switch_microphone_deeplink_parsing() { + let json = r#"{"switch_microphone":{"mic_label":"Test Microphone"}}"#; + let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); + let url = Url::parse(&url_str).unwrap(); + + let action = DeepLinkAction::try_from(&url).unwrap(); + match action { + DeepLinkAction::SwitchMicrophone { mic_label } => { + assert_eq!(mic_label, "Test Microphone"); + } + _ => panic!("Expected SwitchMicrophone action"), + } + } + + #[test] + fn test_switch_camera_deeplink_parsing() { + let json = r#"{"switch_camera":{"camera":{"device_id":"test-camera-id"}}}"#; + let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); + let url = Url::parse(&url_str).unwrap(); + + let action = DeepLinkAction::try_from(&url).unwrap(); + match action { + DeepLinkAction::SwitchCamera { camera } => { + assert_eq!(camera.device_id(), "test-camera-id"); + } + _ => panic!("Expected SwitchCamera action"), + } + } + + #[test] + fn test_list_microphones_deeplink_parsing() { + let json = r#"{"list_microphones":{}}"#; + let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); + let url = Url::parse(&url_str).unwrap(); + + let action = DeepLinkAction::try_from(&url).unwrap(); + assert!(matches!(action, DeepLinkAction::ListMicrophones)); + } + + #[test] + fn test_list_cameras_deeplink_parsing() { + let json = r#"{"list_cameras":{}}"#; + let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(json)); + let url = Url::parse(&url_str).unwrap(); + + let action = DeepLinkAction::try_from(&url).unwrap(); + assert!(matches!(action, DeepLinkAction::ListCameras)); + } + + #[test] + fn test_invalid_json_returns_error() { + let invalid_json = r#"{"invalid_json"#; + let url_str = format!( + "cap-desktop://action?value={}", + urlencoding::encode(invalid_json) + ); + let url = Url::parse(&url_str).unwrap(); + + let result = DeepLinkAction::try_from(&url); + assert!(result.is_err()); + } + + #[test] + fn test_missing_value_parameter_returns_error() { + let url = Url::parse("cap-desktop://action").unwrap(); + let result = DeepLinkAction::try_from(&url); + assert!(result.is_err()); + } + + #[test] + fn test_non_action_domain_returns_not_action_error() { + let url = Url::parse("cap-desktop://other?value=test").unwrap(); + let result = DeepLinkAction::try_from(&url); + assert!(result.is_err()); + } + + #[test] + fn test_deeplink_round_trip_pause() { + let original = DeepLinkAction::PauseRecording; + let json = serde_json::to_string(&original).unwrap(); + let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(&json)); + let url = Url::parse(&url_str).unwrap(); + let parsed = DeepLinkAction::try_from(&url).unwrap(); + + assert!(matches!(parsed, DeepLinkAction::PauseRecording)); + } + + #[test] + fn test_deeplink_round_trip_switch_microphone() { + let original = DeepLinkAction::SwitchMicrophone { + mic_label: "Test Mic".to_string(), + }; + let json = serde_json::to_string(&original).unwrap(); + let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(&json)); + let url = Url::parse(&url_str).unwrap(); + let parsed = DeepLinkAction::try_from(&url).unwrap(); + + match parsed { + DeepLinkAction::SwitchMicrophone { mic_label } => { + assert_eq!(mic_label, "Test Mic"); + } + _ => panic!("Expected SwitchMicrophone action"), + } + } + + #[test] + fn test_url_encoding_special_characters() { + let mic_label = "Test Mic (Built-in)"; + let original = DeepLinkAction::SwitchMicrophone { + mic_label: mic_label.to_string(), + }; + let json = serde_json::to_string(&original).unwrap(); + let url_str = format!("cap-desktop://action?value={}", urlencoding::encode(&json)); + let url = Url::parse(&url_str).unwrap(); + let parsed = DeepLinkAction::try_from(&url).unwrap(); + + match parsed { + DeepLinkAction::SwitchMicrophone { + mic_label: parsed_label, + } => { + assert_eq!(parsed_label, mic_label); + } + _ => panic!("Expected SwitchMicrophone action"), + } + } + + #[test] + fn test_serialization_maintains_snake_case() { + let action = DeepLinkAction::PauseRecording; + let json = serde_json::to_string(&action).unwrap(); + assert!(json.contains("pause_recording")); + + let action = DeepLinkAction::TogglePauseRecording; + let json = serde_json::to_string(&action).unwrap(); + assert!(json.contains("toggle_pause_recording")); + } +} diff --git a/extensions/raycast/README.md b/extensions/raycast/README.md new file mode 100644 index 0000000000..a9d3a470d9 --- /dev/null +++ b/extensions/raycast/README.md @@ -0,0 +1,155 @@ +# Cap Raycast Extension + +Control Cap screen recordings directly from Raycast. + +## Features + +- **Pause Recording** - Pause your current recording +- **Resume Recording** - Resume a paused recording +- **Toggle Pause** - Toggle between pause and resume states +- **Stop Recording** - Stop the current recording +- **Switch Microphone** - Change your active microphone from a searchable list +- **Switch Camera** - Change your active camera from a searchable list + +## Installation + +### Prerequisites + +- [Raycast](https://www.raycast.com/) installed on macOS +- [Cap](https://cap.so/) installed and running + +### Install 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 install the extension: + ```bash + npm run build + ``` +5. Import the extension in Raycast: + - Open Raycast + - Go to Extensions + - Click "+" and select "Import Extension" + - Select the `extensions/raycast` directory + +## Usage + +### Recording Control + +Open Raycast and search for: +- "Pause Recording" - Pauses your current Cap recording +- "Resume Recording" - Resumes a paused recording +- "Toggle Pause" - Toggles between pause/resume +- "Stop Recording" - Stops the current recording + +### Device Management + +Open Raycast and search for: +- "Switch Microphone" - Shows a list of available microphones +- "Switch Camera" - Shows a list of available cameras + +Select a device from the list to switch to it. + +## Deeplink Format + +The extension uses Cap's deeplink protocol to trigger actions: + +``` +cap-desktop://action?value= +``` + +### Available Actions + +**Recording Control:** +```json +{"pause_recording": {}} +{"resume_recording": {}} +{"toggle_pause_recording": {}} +{"stop_recording": {}} +``` + +**Device Management:** +```json +{"switch_microphone": {"mic_label": "Microphone Name"}} +{"switch_camera": {"camera": {"device_id": "camera-id"}}} +{"list_microphones": {}} +{"list_cameras": {}} +``` + +### Example + +To pause a recording programmatically: + +```bash +open "cap-desktop://action?value=%7B%22pause_recording%22%3A%7B%7D%7D" +``` + +Or in JavaScript: +```javascript +const action = { pause_recording: {} }; +const json = JSON.stringify(action); +const encoded = encodeURIComponent(json); +const deeplink = `cap-desktop://action?value=${encoded}`; +await open(deeplink); +``` + +## Development + +### Setup + +```bash +npm install +``` + +### Development Mode + +```bash +npm run dev +``` + +### Build + +```bash +npm run build +``` + +### Lint + +```bash +npm run lint +``` + +## Troubleshooting + +### Commands Not Working + +1. Make sure Cap is running +2. Check that Cap has the necessary permissions (microphone, camera, screen recording) +3. Try restarting Cap + +### Device Lists Empty + +1. Verify Cap has microphone/camera permissions in System Settings +2. Make sure devices are connected and recognized by your system +3. Try running Cap with elevated permissions + +### Deeplinks Not Triggering + +1. Verify the deeplink format is correct +2. Check that the JSON is properly URL-encoded +3. Ensure Cap is the default handler for `cap-desktop://` URLs + +## License + +MIT + +## Support + +For issues and feature requests, please visit the [Cap GitHub repository](https://github.com/CapSoftware/Cap). diff --git a/extensions/raycast/assets/.gitkeep b/extensions/raycast/assets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/extensions/raycast/package.json b/extensions/raycast/package.json new file mode 100644 index 0000000000..9fe4649b65 --- /dev/null +++ b/extensions/raycast/package.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap screen recordings from Raycast", + "icon": "cap-icon.png", + "author": "cap", + "license": "MIT", + "commands": [ + { + "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 between pause and resume", + "mode": "no-view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "description": "Stop the current recording", + "mode": "no-view" + }, + { + "name": "switch-microphone", + "title": "Switch Microphone", + "description": "Change the active microphone", + "mode": "view" + }, + { + "name": "switch-camera", + "title": "Switch Camera", + "description": "Change the active camera", + "mode": "view" + } + ], + "dependencies": { + "@raycast/api": "^1.48.0", + "@raycast/utils": "^1.5.2" + }, + "devDependencies": { + "@types/node": "^18.0.0", + "@types/react": "^18.0.0", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^8.0.0", + "typescript": "^5.0.0" + }, + "scripts": { + "build": "ray build -e dist", + "dev": "ray develop", + "lint": "ray lint" + } +} diff --git a/extensions/raycast/src/__tests__/deeplink.test.ts b/extensions/raycast/src/__tests__/deeplink.test.ts new file mode 100644 index 0000000000..708b50186d --- /dev/null +++ b/extensions/raycast/src/__tests__/deeplink.test.ts @@ -0,0 +1,132 @@ +import { buildDeeplink, DeeplinkAction } from "../utils/deeplink"; + +describe("Deeplink Builder", () => { + test("builds pause recording deeplink", () => { + const action: DeeplinkAction = { pause_recording: {} }; + const deeplink = buildDeeplink(action); + + expect(deeplink).toContain("cap-desktop://action?value="); + expect(deeplink).toContain("pause_recording"); + }); + + test("builds resume recording deeplink", () => { + const action: DeeplinkAction = { resume_recording: {} }; + const deeplink = buildDeeplink(action); + + expect(deeplink).toContain("cap-desktop://action?value="); + expect(deeplink).toContain("resume_recording"); + }); + + test("builds toggle pause deeplink", () => { + const action: DeeplinkAction = { toggle_pause_recording: {} }; + const deeplink = buildDeeplink(action); + + expect(deeplink).toContain("cap-desktop://action?value="); + expect(deeplink).toContain("toggle_pause_recording"); + }); + + test("builds stop recording deeplink", () => { + const action: DeeplinkAction = { stop_recording: {} }; + const deeplink = buildDeeplink(action); + + expect(deeplink).toContain("cap-desktop://action?value="); + expect(deeplink).toContain("stop_recording"); + }); + + test("builds switch microphone deeplink", () => { + const action: DeeplinkAction = { switch_microphone: { mic_label: "Test Mic" } }; + const deeplink = buildDeeplink(action); + + expect(deeplink).toContain("cap-desktop://action?value="); + expect(deeplink).toContain("switch_microphone"); + expect(deeplink).toContain("Test%20Mic"); + }); + + test("builds switch camera deeplink", () => { + const action: DeeplinkAction = { switch_camera: { camera: { device_id: "test-camera" } } }; + const deeplink = buildDeeplink(action); + + expect(deeplink).toContain("cap-desktop://action?value="); + expect(deeplink).toContain("switch_camera"); + expect(deeplink).toContain("test-camera"); + }); + + test("builds list microphones deeplink", () => { + const action: DeeplinkAction = { list_microphones: {} }; + const deeplink = buildDeeplink(action); + + expect(deeplink).toContain("cap-desktop://action?value="); + expect(deeplink).toContain("list_microphones"); + }); + + test("builds list cameras deeplink", () => { + const action: DeeplinkAction = { list_cameras: {} }; + const deeplink = buildDeeplink(action); + + expect(deeplink).toContain("cap-desktop://action?value="); + expect(deeplink).toContain("list_cameras"); + }); + + test("URL encodes special characters", () => { + const action: DeeplinkAction = { switch_microphone: { mic_label: "Test (Built-in)" } }; + const deeplink = buildDeeplink(action); + + expect(deeplink).not.toContain("("); + expect(deeplink).not.toContain(")"); + expect(deeplink).toContain("%28"); + expect(deeplink).toContain("%29"); + }); + + test("deeplink round-trip for pause recording", () => { + const action: DeeplinkAction = { pause_recording: {} }; + const deeplink = buildDeeplink(action); + + const url = new URL(deeplink); + const value = url.searchParams.get("value"); + expect(value).toBeTruthy(); + + const decoded = decodeURIComponent(value!); + const parsed = JSON.parse(decoded); + + expect(parsed).toHaveProperty("pause_recording"); + }); + + test("deeplink round-trip for switch microphone", () => { + const action: DeeplinkAction = { switch_microphone: { mic_label: "Test Mic" } }; + const deeplink = buildDeeplink(action); + + const url = new URL(deeplink); + const value = url.searchParams.get("value"); + expect(value).toBeTruthy(); + + const decoded = decodeURIComponent(value!); + const parsed = JSON.parse(decoded); + + expect(parsed).toHaveProperty("switch_microphone"); + expect(parsed.switch_microphone.mic_label).toBe("Test Mic"); + }); + + test("maintains snake_case in JSON", () => { + const action: DeeplinkAction = { toggle_pause_recording: {} }; + const deeplink = buildDeeplink(action); + + const url = new URL(deeplink); + const value = url.searchParams.get("value"); + const decoded = decodeURIComponent(value!); + + expect(decoded).toContain("toggle_pause_recording"); + expect(decoded).not.toContain("togglePauseRecording"); + }); + + test("handles empty objects correctly", () => { + const action: DeeplinkAction = { pause_recording: {} }; + const deeplink = buildDeeplink(action); + + const url = new URL(deeplink); + const value = url.searchParams.get("value"); + const decoded = decodeURIComponent(value!); + const parsed = JSON.parse(decoded); + + expect(parsed.pause_recording).toEqual({}); + }); +}); diff --git a/extensions/raycast/src/pause-recording.tsx b/extensions/raycast/src/pause-recording.tsx new file mode 100644 index 0000000000..076d17a5f4 --- /dev/null +++ b/extensions/raycast/src/pause-recording.tsx @@ -0,0 +1,19 @@ +import { showToast, Toast, open } from "@raycast/api"; +import { buildDeeplink } from "./utils/deeplink"; + +export default async function Command() { + try { + const deeplink = buildDeeplink({ pause_recording: {} }); + await open(deeplink); + 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..c9e14d7603 --- /dev/null +++ b/extensions/raycast/src/resume-recording.tsx @@ -0,0 +1,19 @@ +import { showToast, Toast, open } from "@raycast/api"; +import { buildDeeplink } from "./utils/deeplink"; + +export default async function Command() { + try { + const deeplink = buildDeeplink({ resume_recording: {} }); + await open(deeplink); + 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/stop-recording.tsx b/extensions/raycast/src/stop-recording.tsx new file mode 100644 index 0000000000..326846d715 --- /dev/null +++ b/extensions/raycast/src/stop-recording.tsx @@ -0,0 +1,19 @@ +import { showToast, Toast, open } from "@raycast/api"; +import { buildDeeplink } from "./utils/deeplink"; + +export default async function Command() { + try { + const deeplink = buildDeeplink({ stop_recording: {} }); + await open(deeplink); + 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..c57354d21f --- /dev/null +++ b/extensions/raycast/src/switch-camera.tsx @@ -0,0 +1,62 @@ +import { List, ActionPanel, Action, showToast, Toast, open } from "@raycast/api"; +import { useState, useEffect } from "react"; +import { getAvailableCameras } from "./utils/devices"; +import { buildDeeplink } from "./utils/deeplink"; + +export default function Command() { + const [cameras, setCameras] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getAvailableCameras() + .then(setCameras) + .catch((error) => { + showToast({ + style: Toast.Style.Failure, + title: "Failed to load cameras", + message: String(error), + }); + }) + .finally(() => setIsLoading(false)); + }, []); + + return ( + + {cameras.length === 0 && !isLoading && ( + + )} + {cameras.map((camera) => ( + + { + try { + const deeplink = buildDeeplink({ + switch_camera: { camera: { device_id: camera.id } }, + }); + await open(deeplink); + 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), + }); + } + }} + /> + + } + /> + ))} + + ); +} diff --git a/extensions/raycast/src/switch-microphone.tsx b/extensions/raycast/src/switch-microphone.tsx new file mode 100644 index 0000000000..1aa47a78e0 --- /dev/null +++ b/extensions/raycast/src/switch-microphone.tsx @@ -0,0 +1,61 @@ +import { List, ActionPanel, Action, showToast, Toast, open } from "@raycast/api"; +import { useState, useEffect } from "react"; +import { getAvailableMicrophones } from "./utils/devices"; +import { buildDeeplink } from "./utils/deeplink"; + +export default function Command() { + const [microphones, setMicrophones] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getAvailableMicrophones() + .then(setMicrophones) + .catch((error) => { + showToast({ + style: Toast.Style.Failure, + title: "Failed to load microphones", + message: String(error), + }); + }) + .finally(() => setIsLoading(false)); + }, []); + + return ( + + {microphones.length === 0 && !isLoading && ( + + )} + {microphones.map((mic) => ( + + { + try { + const deeplink = buildDeeplink({ + switch_microphone: { mic_label: mic }, + }); + await open(deeplink); + await showToast({ + style: Toast.Style.Success, + title: `Switched to ${mic}`, + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to switch microphone", + message: String(error), + }); + } + }} + /> + + } + /> + ))} + + ); +} diff --git a/extensions/raycast/src/toggle-pause.tsx b/extensions/raycast/src/toggle-pause.tsx new file mode 100644 index 0000000000..4a719f82aa --- /dev/null +++ b/extensions/raycast/src/toggle-pause.tsx @@ -0,0 +1,19 @@ +import { showToast, Toast, open } from "@raycast/api"; +import { buildDeeplink } from "./utils/deeplink"; + +export default async function Command() { + try { + const deeplink = buildDeeplink({ toggle_pause_recording: {} }); + await open(deeplink); + await showToast({ + style: Toast.Style.Success, + title: "Recording Toggled", + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to toggle recording", + message: String(error), + }); + } +} diff --git a/extensions/raycast/src/utils/deeplink.ts b/extensions/raycast/src/utils/deeplink.ts new file mode 100644 index 0000000000..3eb9455023 --- /dev/null +++ b/extensions/raycast/src/utils/deeplink.ts @@ -0,0 +1,15 @@ +export type DeeplinkAction = + | { pause_recording: Record } + | { resume_recording: Record } + | { toggle_pause_recording: Record } + | { stop_recording: Record } + | { switch_microphone: { mic_label: string } } + | { switch_camera: { camera: { device_id: string } } } + | { list_microphones: Record } + | { list_cameras: Record }; + +export function buildDeeplink(action: DeeplinkAction): string { + const json = JSON.stringify(action); + const encoded = encodeURIComponent(json); + return `cap-desktop://action?value=${encoded}`; +} diff --git a/extensions/raycast/src/utils/devices.ts b/extensions/raycast/src/utils/devices.ts new file mode 100644 index 0000000000..49b27f5fc8 --- /dev/null +++ b/extensions/raycast/src/utils/devices.ts @@ -0,0 +1,46 @@ +import { exec } from "child_process"; +import { promisify } from "util"; +import { buildDeeplink } from "./deeplink"; + +const execAsync = promisify(exec); + +interface MicrophoneInfo { + label: string; + is_default: boolean; +} + +interface CameraInfo { + id: string; + name: string; + is_default: boolean; +} + +export async function getAvailableMicrophones(): Promise { + try { + const deeplink = buildDeeplink({ list_microphones: {} }); + const { stdout } = await execAsync(`open "${deeplink}"`); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + const mics: MicrophoneInfo[] = JSON.parse(stdout.trim() || "[]"); + return mics.map((mic) => mic.label); + } catch (error) { + console.error("Failed to get microphones:", error); + return []; + } +} + +export async function getAvailableCameras(): Promise> { + try { + const deeplink = buildDeeplink({ list_cameras: {} }); + const { stdout } = await execAsync(`open "${deeplink}"`); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + const cameras: CameraInfo[] = JSON.parse(stdout.trim() || "[]"); + return cameras.map((camera) => ({ id: camera.id, name: camera.name })); + } catch (error) { + console.error("Failed to get cameras:", error); + return []; + } +} diff --git a/extensions/raycast/tsconfig.json b/extensions/raycast/tsconfig.json new file mode 100644 index 0000000000..ab4a4343b2 --- /dev/null +++ b/extensions/raycast/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "jsx": "react", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +}