diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a117028487..00838abd15 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -1,12 +1,14 @@ use cap_recording::{ RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget, }; +use scap_targets::Display; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Url}; use tracing::trace; +use tauri_specta::Event; -use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; +use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow, recording_settings::RecordingSettingsStore, NewNotification}; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -19,13 +21,25 @@ pub enum CaptureMode { #[serde(rename_all = "snake_case")] pub enum DeepLinkAction { StartRecording { - capture_mode: CaptureMode, + #[serde(default)] + capture_mode: Option, + #[serde(default)] camera: Option, + #[serde(default)] mic_label: Option, - capture_system_audio: bool, - mode: RecordingMode, + #[serde(default)] + capture_system_audio: Option, + #[serde(default)] + mode: Option, }, StopRecording, + PauseRecording, + ResumeRecording, + TogglePauseRecording, + MuteRecording, + UnmuteRecording, + ToggleMuteRecording, + TakeScreenshot, OpenEditor { project_path: PathBuf, }, @@ -49,7 +63,6 @@ pub fn handle(app_handle: &AppHandle, urls: Vec) { ActionParseFromUrlError::Invalid => { eprintln!("Invalid deep link format \"{}\"", &url) } - // Likely login action, not handled here. ActionParseFromUrlError::NotAction => {} }) .ok() @@ -65,6 +78,13 @@ pub fn handle(app_handle: &AppHandle, urls: Vec) { for action in actions { if let Err(e) = action.execute(&app_handle).await { eprintln!("Failed to handle deep link action: {e}"); + NewNotification { + title: "Action Failed".to_string(), + body: e, + is_error: true, + } + .emit(&app_handle) + .ok(); } } }); @@ -88,20 +108,38 @@ impl TryFrom<&Url> for DeepLinkAction { .map_err(|_| ActionParseFromUrlError::Invalid); } - match url.domain() { - Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction), - _ => Err(ActionParseFromUrlError::Invalid), - }?; - - let params = url - .query_pairs() - .collect::>(); - let json_value = params - .get("value") - .ok_or(ActionParseFromUrlError::Invalid)?; - let action: Self = serde_json::from_str(json_value) - .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?; - Ok(action) + let host = url.host_str().unwrap_or(""); + + if host == "action" { + let params = url + .query_pairs() + .collect::>(); + let json_value = params + .get("value") + .ok_or(ActionParseFromUrlError::Invalid)?; + let action: Self = serde_json::from_str(json_value) + .map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?; + return Ok(action); + } + + match host { + "start-recording" => Ok(Self::StartRecording { + capture_mode: None, + camera: None, + mic_label: None, + capture_system_audio: None, + mode: None, + }), + "stop-recording" => Ok(Self::StopRecording), + "pause-recording" => Ok(Self::PauseRecording), + "resume-recording" => Ok(Self::ResumeRecording), + "toggle-pause-recording" => Ok(Self::TogglePauseRecording), + "mute-recording" => Ok(Self::MuteRecording), + "unmute-recording" => Ok(Self::UnmuteRecording), + "toggle-mute-recording" => Ok(Self::ToggleMuteRecording), + "take-screenshot" => Ok(Self::TakeScreenshot), + _ => Err(ActionParseFromUrlError::NotAction), + } } } @@ -117,35 +155,149 @@ impl DeepLinkAction { } => { let state = app.state::>(); + let settings = RecordingSettingsStore::get(app).ok().flatten().unwrap_or_default(); + + let camera = camera.or(settings.camera_id); + let mic_label = mic_label.or(settings.mic_name); + let capture_system_audio = capture_system_audio.unwrap_or(settings.system_audio); + let mode = mode.or(settings.mode).unwrap_or(RecordingMode::Instant); + crate::set_camera_input(app.clone(), state.clone(), camera, None).await?; crate::set_mic_input(state.clone(), mic_label).await?; - let capture_target: ScreenCaptureTarget = match capture_mode { - CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays() - .into_iter() - .find(|(s, _)| s.name == name) - .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id }) - .ok_or(format!("No screen with name \"{}\"", &name))?, - CaptureMode::Window(name) => cap_recording::screen_capture::list_windows() - .into_iter() - .find(|(w, _)| w.name == name) - .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) - .ok_or(format!("No window with name \"{}\"", &name))?, + let capture_target: ScreenCaptureTarget = if let Some(capture_mode) = capture_mode { + match capture_mode { + CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays() + .into_iter() + .find(|(s, _)| s.name == name) + .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id }) + .ok_or(format!("No screen with name \"{}\"", &name))?, + CaptureMode::Window(name) => cap_recording::screen_capture::list_windows() + .into_iter() + .find(|(w, _)| w.name == name) + .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) + .ok_or(format!("No window with name \"{}\"", &name))?, + } + } else { + settings.target.unwrap_or_else(|| { + ScreenCaptureTarget::Display { + id: Display::get_containing_cursor().unwrap_or_else(Display::primary).id(), + } + }) }; let inputs = StartRecordingInputs { mode, capture_target, capture_system_audio, - organization_id: None, + organization_id: settings.organization_id, }; crate::recording::start_recording(app.clone(), state, inputs) .await - .map(|_| ()) + .map(|_| ())?; + + NewNotification { + title: "Recording Started".to_string(), + body: "Recording has begun.".to_string(), + is_error: false, + } + .emit(app) + .ok(); + Ok(()) } DeepLinkAction::StopRecording => { - crate::recording::stop_recording(app.clone(), app.state()).await + crate::recording::stop_recording(app.clone(), app.state()).await?; + NewNotification { + title: "Recording Stopped".to_string(), + body: "Recording has been saved.".to_string(), + is_error: false, + } + .emit(app) + .ok(); + Ok(()) + } + DeepLinkAction::PauseRecording => { + crate::recording::pause_recording(app.clone(), app.state()).await?; + NewNotification { + title: "Recording Paused".to_string(), + body: "Recording is now paused.".to_string(), + is_error: false, + } + .emit(app) + .ok(); + Ok(()) + } + DeepLinkAction::ResumeRecording => { + crate::recording::resume_recording(app.clone(), app.state()).await?; + NewNotification { + title: "Recording Resumed".to_string(), + body: "Recording has been resumed.".to_string(), + is_error: false, + } + .emit(app) + .ok(); + Ok(()) + } + DeepLinkAction::TogglePauseRecording => { + crate::recording::toggle_pause_recording(app.clone(), app.state()).await?; + Ok(()) + } + DeepLinkAction::MuteRecording => { + let state = app.state::>(); + let app_state = state.read().await; + app_state.mic_feed.tell(cap_recording::feeds::microphone::Mute).await.map_err(|e| e.to_string())?; + NewNotification { + title: "Microphone Muted".to_string(), + body: "Microphone is now muted.".to_string(), + is_error: false, + } + .emit(app) + .ok(); + Ok(()) + } + DeepLinkAction::UnmuteRecording => { + let state = app.state::>(); + let app_state = state.read().await; + app_state.mic_feed.tell(cap_recording::feeds::microphone::Unmute).await.map_err(|e| e.to_string())?; + NewNotification { + title: "Microphone Unmuted".to_string(), + body: "Microphone is now active.".to_string(), + is_error: false, + } + .emit(app) + .ok(); + Ok(()) + } + DeepLinkAction::ToggleMuteRecording => { + let state = app.state::>(); + let app_state = state.read().await; + let muted = app_state.mic_feed.ask(cap_recording::feeds::microphone::ToggleMute).await.map_err(|e| e.to_string())?; + NewNotification { + title: if muted { "Microphone Muted" } else { "Microphone Unmuted" }.to_string(), + body: if muted { "Microphone is now muted." } else { "Microphone is now active." }.to_string(), + is_error: false, + } + .emit(app) + .ok(); + Ok(()) + } + DeepLinkAction::TakeScreenshot => { + let settings = RecordingSettingsStore::get(app).ok().flatten().unwrap_or_default(); + let target = settings.target.unwrap_or_else(|| { + ScreenCaptureTarget::Display { + id: Display::get_containing_cursor().unwrap_or_else(Display::primary).id(), + } + }); + crate::recording::take_screenshot(app.clone(), target).await.map(|_| ())?; + NewNotification { + title: "Screenshot Taken".to_string(), + body: "Screenshot has been saved.".to_string(), + is_error: false, + } + .emit(app) + .ok(); + Ok(()) } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) @@ -156,3 +308,55 @@ impl DeepLinkAction { } } } + +#[cfg(test)] +mod tests { + use super::*; + use tauri::Url; + + #[test] + fn test_parse_simple_actions() { + let urls = [ + ("cap://start-recording", "start-recording"), + ("cap://stop-recording", "stop-recording"), + ("cap://pause-recording", "pause-recording"), + ("cap://resume-recording", "resume-recording"), + ("cap://toggle-pause-recording", "toggle-pause-recording"), + ("cap://take-screenshot", "take-screenshot"), + ("cap://mute-recording", "mute-recording"), + ("cap://unmute-recording", "unmute-recording"), + ("cap://toggle-mute-recording", "toggle-mute-recording"), + ]; + + for (url_str, _expected_host) in urls { + let url = Url::parse(url_str).unwrap(); + let action = DeepLinkAction::try_from(&url); + assert!(action.is_ok(), "Failed to parse action for {}", url_str); + } + } + + #[test] + fn test_parse_complex_action() { + let json = r#"{"start_recording":{"mic_label":"Blue Yeti","capture_system_audio":true}}"#; + // Hardcoded URL-encoded value of the JSON string above + let encoded_json = "%7B%22start_recording%22%3A%7B%22mic_label%22%3A%22Blue%20Yeti%22%2C%22capture_system_audio%22%3Atrue%7D%7D"; + let url_str = format!("cap://action?value={}", encoded_json); + let url = Url::parse(&url_str).unwrap(); + let action = DeepLinkAction::try_from(&url).unwrap(); + + match action { + DeepLinkAction::StartRecording { mic_label, capture_system_audio, .. } => { + assert_eq!(mic_label, Some("Blue Yeti".to_string())); + assert_eq!(capture_system_audio, Some(true)); + } + _ => panic!("Expected StartRecording action"), + } + } + + #[test] + fn test_invalid_action() { + let url = Url::parse("cap://invalid-action").unwrap(); + let action = DeepLinkAction::try_from(&url); + assert!(matches!(action, Err(ActionParseFromUrlError::NotAction))); + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 691c2f0995..80aba7fd80 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -30,7 +30,7 @@ "updater": { "active": false, "pubkey": "" }, "deep-link": { "desktop": { - "schemes": ["cap-desktop"] + "schemes": ["cap-desktop", "cap"] } } }, diff --git a/crates/recording/src/feeds/microphone.rs b/crates/recording/src/feeds/microphone.rs index 29a8230909..3f03d1e923 100644 --- a/crates/recording/src/feeds/microphone.rs +++ b/crates/recording/src/feeds/microphone.rs @@ -41,6 +41,7 @@ pub struct MicrophoneFeed { state: State, senders: Vec>, error_sender: flume::Sender, + muted: bool, } enum State { @@ -123,6 +124,7 @@ impl MicrophoneFeed { }), senders: Vec::new(), error_sender, + muted: false, } } @@ -473,6 +475,12 @@ pub struct AddSender(pub flume::Sender); pub struct Lock; +pub struct Mute; + +pub struct Unmute; + +pub struct ToggleMute; + // Private Events struct InputConnected { @@ -735,6 +743,10 @@ impl Message for MicrophoneFeed { msg: MicrophoneSamples, _: &mut Context, ) -> Self::Reply { + if self.muted { + return; + } + let mut to_remove = vec![]; for (i, sender) in self.senders.iter().enumerate() { @@ -753,6 +765,31 @@ impl Message for MicrophoneFeed { } } +impl Message for MicrophoneFeed { + type Reply = (); + + async fn handle(&mut self, _: Mute, _: &mut Context) -> Self::Reply { + self.muted = true; + } +} + +impl Message for MicrophoneFeed { + type Reply = (); + + async fn handle(&mut self, _: Unmute, _: &mut Context) -> Self::Reply { + self.muted = false; + } +} + +impl Message for MicrophoneFeed { + type Reply = bool; + + async fn handle(&mut self, _: ToggleMute, _: &mut Context) -> Self::Reply { + self.muted = !self.muted; + self.muted + } +} + #[derive(Clone, Debug, thiserror::Error)] pub enum LockFeedError { #[error(transparent)] diff --git a/extensions/raycast/README.md b/extensions/raycast/README.md new file mode 100644 index 0000000000..9b31e3cb5a --- /dev/null +++ b/extensions/raycast/README.md @@ -0,0 +1,40 @@ +# Cap Raycast Extension + +Control [Cap](https://cap.so) directly from Raycast. + +## Features + +- **Start Recording**: Quickly start a new screen recording. +- **Stop Recording**: Finish and save your current recording. +- **Pause/Resume**: Temporarily pause or resume your recording. +- **Toggle Pause**: A single command to toggle between pause and resume. +- **Take Screenshot**: Capture your screen instantly. +- **Mute/Unmute**: Control your microphone during recording. +- **Toggle Mute**: A single command to toggle microphone state. + +## Installation + +1. Make sure you have the [Cap Desktop app](https://cap.so/download) installed. +2. Open this extension in Raycast. + +## Usage + +You can find all commands by searching for "Cap" in Raycast. + +- `Start Recording` +- `Stop Recording` +- `Pause Recording` +- `Resume Recording` +- `Toggle Pause Recording` +- `Take Screenshot` +- `Mute Recording` +- `Unmute Recording` +- `Toggle Mute Recording` + +## Development + +To run the extension locally: + +1. `cd extensions/raycast` +2. `pnpm install` +3. `pnpm dev` diff --git a/extensions/raycast/icon.png b/extensions/raycast/icon.png new file mode 100644 index 0000000000..b1ac1ef7d8 Binary files /dev/null and b/extensions/raycast/icon.png differ diff --git a/extensions/raycast/package.json b/extensions/raycast/package.json new file mode 100644 index 0000000000..4491e16490 --- /dev/null +++ b/extensions/raycast/package.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap screen recording directly from Raycast.", + "icon": "icon.png", + "author": "CapSoftware", + "license": "MIT", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "description": "Start a new screen recording in Cap.", + "mode": "no-view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "description": "Stop the current screen recording in Cap.", + "mode": "no-view" + }, + { + "name": "pause-recording", + "title": "Pause Recording", + "description": "Pause the current screen recording in Cap.", + "mode": "no-view" + }, + { + "name": "resume-recording", + "title": "Resume Recording", + "description": "Resume the current screen recording in Cap.", + "mode": "no-view" + }, + { + "name": "toggle-pause-recording", + "title": "Toggle Pause Recording", + "description": "Toggle pause/resume the current screen recording in Cap.", + "mode": "no-view" + }, + { + "name": "take-screenshot", + "title": "Take Screenshot", + "description": "Take a screenshot in Cap.", + "mode": "no-view" + }, + { + "name": "mute-recording", + "title": "Mute Recording", + "description": "Mute the microphone during recording in Cap.", + "mode": "no-view" + }, + { + "name": "unmute-recording", + "title": "Unmute Recording", + "description": "Unmute the microphone during recording in Cap.", + "mode": "no-view" + }, + { + "name": "toggle-mute-recording", + "title": "Toggle Mute Recording", + "description": "Toggle mute/unmute the microphone during recording in Cap.", + "mode": "no-view" + } + ], + "dependencies": { + "@raycast/api": "^1.83.2" + }, + "devDependencies": { + "@raycast/utils": "^1.17.0", + "@types/node": "20.8.10", + "@types/react": "18.3.3", + "typescript": "^5.4.5" + }, + "scripts": { + "build": "ray build -e dist", + "dev": "ray develop" + } +} diff --git a/extensions/raycast/src/mute-recording.tsx b/extensions/raycast/src/mute-recording.tsx new file mode 100644 index 0000000000..f07ff1caba --- /dev/null +++ b/extensions/raycast/src/mute-recording.tsx @@ -0,0 +1,10 @@ +import { open, showHUD } from "@raycast/api"; + +export default async function Command() { + try { + await open("cap://mute-recording"); + await showHUD("Muting microphone..."); + } catch { + await showHUD("Failed to open Cap"); + } +} diff --git a/extensions/raycast/src/pause-recording.tsx b/extensions/raycast/src/pause-recording.tsx new file mode 100644 index 0000000000..d98e439703 --- /dev/null +++ b/extensions/raycast/src/pause-recording.tsx @@ -0,0 +1,10 @@ +import { open, showHUD } from "@raycast/api"; + +export default async function Command() { + try { + await open("cap://pause-recording"); + await showHUD("Pausing recording..."); + } catch { + await showHUD("Failed to open Cap"); + } +} diff --git a/extensions/raycast/src/resume-recording.tsx b/extensions/raycast/src/resume-recording.tsx new file mode 100644 index 0000000000..4238391d96 --- /dev/null +++ b/extensions/raycast/src/resume-recording.tsx @@ -0,0 +1,10 @@ +import { open, showHUD } from "@raycast/api"; + +export default async function Command() { + try { + await open("cap://resume-recording"); + await showHUD("Resuming recording..."); + } catch { + await showHUD("Failed to open Cap"); + } +} diff --git a/extensions/raycast/src/start-recording.tsx b/extensions/raycast/src/start-recording.tsx new file mode 100644 index 0000000000..32445ddac9 --- /dev/null +++ b/extensions/raycast/src/start-recording.tsx @@ -0,0 +1,10 @@ +import { open, showHUD } from "@raycast/api"; + +export default async function Command() { + try { + await open("cap://start-recording"); + await showHUD("Starting recording..."); + } catch { + await showHUD("Failed to open Cap"); + } +} diff --git a/extensions/raycast/src/stop-recording.tsx b/extensions/raycast/src/stop-recording.tsx new file mode 100644 index 0000000000..cc5401d72a --- /dev/null +++ b/extensions/raycast/src/stop-recording.tsx @@ -0,0 +1,10 @@ +import { open, showHUD } from "@raycast/api"; + +export default async function Command() { + try { + await open("cap://stop-recording"); + await showHUD("Stopping recording..."); + } catch { + await showHUD("Failed to open Cap"); + } +} diff --git a/extensions/raycast/src/take-screenshot.tsx b/extensions/raycast/src/take-screenshot.tsx new file mode 100644 index 0000000000..f007832b7d --- /dev/null +++ b/extensions/raycast/src/take-screenshot.tsx @@ -0,0 +1,10 @@ +import { open, showHUD } from "@raycast/api"; + +export default async function Command() { + try { + await open("cap://take-screenshot"); + await showHUD("Taking screenshot..."); + } catch { + await showHUD("Failed to open Cap"); + } +} diff --git a/extensions/raycast/src/toggle-mute-recording.tsx b/extensions/raycast/src/toggle-mute-recording.tsx new file mode 100644 index 0000000000..e47d2569d2 --- /dev/null +++ b/extensions/raycast/src/toggle-mute-recording.tsx @@ -0,0 +1,10 @@ +import { open, showHUD } from "@raycast/api"; + +export default async function Command() { + try { + await open("cap://toggle-mute-recording"); + await showHUD("Toggling mute..."); + } catch { + await showHUD("Failed to open Cap"); + } +} diff --git a/extensions/raycast/src/toggle-pause-recording.tsx b/extensions/raycast/src/toggle-pause-recording.tsx new file mode 100644 index 0000000000..58da381e74 --- /dev/null +++ b/extensions/raycast/src/toggle-pause-recording.tsx @@ -0,0 +1,10 @@ +import { open, showHUD } from "@raycast/api"; + +export default async function Command() { + try { + await open("cap://toggle-pause-recording"); + await showHUD("Toggling pause..."); + } catch { + await showHUD("Failed to open Cap"); + } +} diff --git a/extensions/raycast/src/unmute-recording.tsx b/extensions/raycast/src/unmute-recording.tsx new file mode 100644 index 0000000000..4a6feeccab --- /dev/null +++ b/extensions/raycast/src/unmute-recording.tsx @@ -0,0 +1,10 @@ +import { open, showHUD } from "@raycast/api"; + +export default async function Command() { + try { + await open("cap://unmute-recording"); + await showHUD("Unmuting microphone..."); + } catch { + await showHUD("Failed to open Cap"); + } +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 614993f77e..e6b195725a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,4 @@ packages: - "crates/tauri-plugin-*" - "infra" - "scripts/orgIdBackfill" + - "extensions/*"