diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index dbd90f667f..327d5cba10 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -25,6 +25,7 @@ pub enum DeepLinkAction { capture_system_audio: bool, mode: RecordingMode, }, + StartDefaultRecording, StopRecording, OpenEditor { project_path: PathBuf, @@ -32,6 +33,16 @@ pub enum DeepLinkAction { OpenSettings { page: Option, }, + PauseRecording, + ResumeRecording, + SetMicrophone { + label: Option, + }, + SetCamera { + id: Option, + }, + CycleMicrophone, + CycleCamera, } pub fn handle(app_handle: &AppHandle, urls: Vec) { @@ -143,6 +154,30 @@ impl DeepLinkAction { .await .map(|_| ()) } + DeepLinkAction::StartDefaultRecording => { + let permissions = crate::permissions::do_permissions_check(false); + if !permissions.screen_recording.permitted() { + return Err("Screen recording permission denied".to_string()); + } + + let displays = cap_recording::screen_capture::list_displays(); + if let Some((display, _)) = displays.first() { + let state = app.state::>(); + + let inputs = StartRecordingInputs { + mode: RecordingMode::Instant, + capture_target: ScreenCaptureTarget::Display { id: display.id }, + capture_system_audio: false, + organization_id: None, + }; + + crate::recording::start_recording(app.clone(), state, inputs) + .await + .map(|_| ()) + } else { + Err("No displays found".to_string()) + } + } DeepLinkAction::StopRecording => { crate::recording::stop_recording(app.clone(), app.state()).await } @@ -152,6 +187,43 @@ impl DeepLinkAction { DeepLinkAction::OpenSettings { page } => { crate::show_window(app.clone(), ShowCapWindow::Settings { page }).await } + DeepLinkAction::PauseRecording => { + crate::recording::pause_recording(app.clone(), app.state()).await + } + DeepLinkAction::ResumeRecording => { + crate::recording::resume_recording(app.clone(), app.state()).await + } + crate::recording::pause_recording(app.clone(), app.state()).await + } + DeepLinkAction::ResumeRecording => { + crate::recording::resume_recording(app.clone(), app.state()).await + } + DeepLinkAction::SetMicrophone { label } => { + let permissions = crate::permissions::do_permissions_check(false); + if !permissions.microphone.permitted() { + return Err("Microphone permission denied".to_string()); + } + + let state = app.state::>(); + crate::set_mic_input(state, label).await + } + DeepLinkAction::SetCamera { id } => { + let permissions = crate::permissions::do_permissions_check(false); + if !permissions.camera.permitted() { + return Err("Camera permission denied".to_string()); + } + + let state = app.state::>(); + crate::set_camera_input(app.clone(), state, id).await + } + DeepLinkAction::CycleMicrophone => { + let state = app.state::>(); + crate::cycle_mic_input(state).await + } + DeepLinkAction::CycleCamera => { + let state = app.state::>(); + crate::cycle_camera_input(app.clone(), state).await + } } } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 90803f8abe..6b7f88deb2 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -588,6 +588,90 @@ async fn set_camera_input( Ok(()) } +#[tauri::command] +#[specta::specta] +#[instrument(skip(state))] +pub async fn cycle_mic_input(state: MutableState<'_, App>) -> Result<(), String> { + if !permissions::do_permissions_check(false) + .microphone + .permitted() + { + return Ok(()); + } + + let current_label = { + let app = state.read().await; + app.selected_mic_label.clone() + }; + + let mut mic_list = MicrophoneFeed::list().keys().cloned().collect::>(); + mic_list.sort_unstable(); + + if mic_list.is_empty() { + return Ok(()); + } + + let next_label = match current_label { + Some(label) => { + let index = mic_list.iter().position(|l| l == &label); + match index { + Some(i) => mic_list.get((i + 1) % mic_list.len()).cloned(), + None => mic_list.first().cloned(), + } + } + None => mic_list.first().cloned(), + }; + + set_mic_input(state, next_label).await +} + +#[tauri::command] +#[specta::specta] +#[instrument(skip(app_handle, state))] +pub async fn cycle_camera_input( + app_handle: AppHandle, + state: MutableState<'_, App>, +) -> Result<(), String> { + if !permissions::do_permissions_check(false).camera.permitted() { + return Err("Camera permission denied".to_string()); + } + if !permissions::do_permissions_check(false).camera.permitted() { + return Err("Camera permission denied".to_string()); + } + } + + let current_id = { + let app = state.read().await; + app.selected_camera_id.clone() + }; + + let camera_list = cap_camera::list_cameras().collect::>(); + + if camera_list.is_empty() { + return Ok(()); + } + + let current_index = match current_id { + Some(id) => camera_list.iter().position(|c| match &id { + DeviceOrModelID::DeviceID(dev_id) => c.device_id() == dev_id, + DeviceOrModelID::ModelID(mod_id) => c.model_id().is_some_and(|m| m == mod_id), + }), + None => None, + }; + + let next_camera = match current_index { + Some(i) => camera_list.get((i + 1) % camera_list.len()), + None => camera_list.first(), + }; + + let next_id = next_camera.map(|c| DeviceOrModelID::DeviceID(c.device_id().to_string())); + let next_id = next_camera.map(|c| match c.model_id() { + Some(model_id) => DeviceOrModelID::ModelID(model_id.to_string()), + None => DeviceOrModelID::DeviceID(c.device_id().to_string()), + }); + set_camera_input(app_handle, state, next_id).await +} + fn spawn_mic_error_handler(app_handle: AppHandle, error_rx: flume::Receiver) { tokio::spawn(async move { let state = app_handle.state::>(); diff --git a/apps/raycast-extension/README.md b/apps/raycast-extension/README.md new file mode 100644 index 0000000000..91068f4bc5 --- /dev/null +++ b/apps/raycast-extension/README.md @@ -0,0 +1,20 @@ +# Cap Control Raycast Extension + +Control the Cap desktop app from Raycast. + +## Commands + +- **Start Recording**: Starts a new recording (defaults to first display). +- **Stop Recording**: Stops the current recording. +- **Pause Recording**: Pauses the current recording. +- **Resume Recording**: Resumes the current recording. +- **Switch Camera**: Cycles through available cameras. +- **Switch Microphone**: Cycles through available microphones. + +## Installation + +1. `cd apps/raycast-extension` +2. `pnpm install` +3. `pnpm build` +3. `npm run build` +4. Import into Raycast via "Import Extension". diff --git a/apps/raycast-extension/assets/command-icon.png b/apps/raycast-extension/assets/command-icon.png new file mode 100644 index 0000000000..86ba70e21f Binary files /dev/null and b/apps/raycast-extension/assets/command-icon.png differ diff --git a/apps/raycast-extension/package.json b/apps/raycast-extension/package.json new file mode 100644 index 0000000000..d5375de1c0 --- /dev/null +++ b/apps/raycast-extension/package.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap-control", + "version": "0.0.0", + "private": true, + "title": "Cap Control", + "description": "Control Cap desktop app", + "icon": "command-icon.png", + "author": "CapSoftware", + "categories": [ + "Productivity", + "Media" + ], + "license": "MIT", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "description": "Start a new recording", + "mode": "no-view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "description": "Stop current recording", + "mode": "no-view" + }, + { + "name": "pause-recording", + "title": "Pause Recording", + "description": "Pause current recording", + "mode": "no-view" + }, + { + "name": "resume-recording", + "title": "Resume Recording", + "description": "Resume current recording", + "mode": "no-view" + }, + { + "name": "switch-camera", + "title": "Switch Camera", + "description": "Cycle through available cameras", + "mode": "no-view" + }, + { + "name": "switch-microphone", + "title": "Switch Microphone", + "description": "Cycle through available microphones", + "mode": "no-view" + } + ], + "dependencies": { + "@raycast/api": "^1.69.0", + "@raycast/utils": "^1.13.0" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.6", + "@types/node": "20.8.10", + "@types/react": "18.2.27", + "eslint": "^8.51.0", + "prettier": "^3.0.3", + "typescript": "^5.2.2" + }, + "scripts": { + "build": "ray build -e dist", + "dev": "ray develop", + "fix-lint": "ray lint --fix", + "lint": "ray lint", + "prepublishOnly": "echo \"\\n\\nIt seems like you are trying to publish the Raycast extension to npm.\\n\\nIf you did intend to publish it to npm, remove the \\`prepublishOnly\\` script and rerun \\`npm publish\\` again.\\nIf you wanted to publish it to the Raycast Store instead, use \\`npm run publish\\` instead.\\n\\n\" && exit 1", + "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..7612cb8b58 --- /dev/null +++ b/apps/raycast-extension/src/pause-recording.tsx @@ -0,0 +1,5 @@ +import { sendCapCommand } from "./utils"; + +export default async function Command() { + await sendCapCommand("pause_recording", "Recording Paused"); +} diff --git a/apps/raycast-extension/src/resume-recording.tsx b/apps/raycast-extension/src/resume-recording.tsx new file mode 100644 index 0000000000..09e65b2460 --- /dev/null +++ b/apps/raycast-extension/src/resume-recording.tsx @@ -0,0 +1,5 @@ +import { sendCapCommand } from "./utils"; + +export default async function Command() { + await sendCapCommand("resume_recording", "Recording Resumed"); +} diff --git a/apps/raycast-extension/src/start-recording.tsx b/apps/raycast-extension/src/start-recording.tsx new file mode 100644 index 0000000000..4052f45e1d --- /dev/null +++ b/apps/raycast-extension/src/start-recording.tsx @@ -0,0 +1,5 @@ +import { sendCapCommand } from "./utils"; + +export default async function Command() { + await sendCapCommand("start_default_recording", "Recording Started"); +} diff --git a/apps/raycast-extension/src/stop-recording.tsx b/apps/raycast-extension/src/stop-recording.tsx new file mode 100644 index 0000000000..bf661f938c --- /dev/null +++ b/apps/raycast-extension/src/stop-recording.tsx @@ -0,0 +1,5 @@ +import { sendCapCommand } from "./utils"; + +export default async function Command() { + await sendCapCommand("stop_recording", "Recording Stopped"); +} diff --git a/apps/raycast-extension/src/switch-camera.tsx b/apps/raycast-extension/src/switch-camera.tsx new file mode 100644 index 0000000000..aa2863f500 --- /dev/null +++ b/apps/raycast-extension/src/switch-camera.tsx @@ -0,0 +1,5 @@ +import { sendCapCommand } from "./utils"; + +export default async function Command() { + await sendCapCommand("cycle_camera", "Switching Camera..."); +} diff --git a/apps/raycast-extension/src/switch-microphone.tsx b/apps/raycast-extension/src/switch-microphone.tsx new file mode 100644 index 0000000000..b41d5b7ea1 --- /dev/null +++ b/apps/raycast-extension/src/switch-microphone.tsx @@ -0,0 +1,5 @@ +import { sendCapCommand } from "./utils"; + +export default async function Command() { + await sendCapCommand("cycle_microphone", "Switching Microphone..."); +} diff --git a/apps/raycast-extension/src/utils.ts b/apps/raycast-extension/src/utils.ts new file mode 100644 index 0000000000..adc05fcb54 --- /dev/null +++ b/apps/raycast-extension/src/utils.ts @@ -0,0 +1,15 @@ +import { closeMainWindow, open, showHUD } from "@raycast/api"; + +type CapAction = string | Record; + +export async function sendCapCommand(action: CapAction, hudMessage: string): Promise { + try { + await closeMainWindow(); + const url = `cap-desktop://action?value=${encodeURIComponent(JSON.stringify(action))}`; + await open(url); + await showHUD(hudMessage); + } catch (error) { + console.error(error); + await showHUD("Failed to connect to Cap"); + } +} diff --git a/apps/raycast-extension/tsconfig.json b/apps/raycast-extension/tsconfig.json new file mode 100644 index 0000000000..bf207e6ca1 --- /dev/null +++ b/apps/raycast-extension/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": [ + "ES2023" + ], + "module": "commonjs", + "target": "ES2023", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "jsx": "react", + "moduleResolution": "Node", + "resolveJsonModule": true + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file