diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a117028487..60900daac6 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -3,10 +3,11 @@ use cap_recording::{ }; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use tauri::{AppHandle, Manager, Url}; +use tauri::{AppHandle, Manager, Url, Runtime, Emitter}; use tracing::trace; -use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; +use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow, MutableState}; +use crate::recording::{InProgressRecording, RecordingEvent, RecordingState}; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -26,12 +27,27 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, + PauseRecording, + ResumeRecording, + TogglePauseRecording, + SwitchCamera { + camera_id: DeviceOrModelID, + }, + SwitchMic { + mic_label: String, + }, OpenEditor { project_path: PathBuf, }, OpenSettings { page: Option, }, + GetStatus, +} + +#[derive(Debug, Clone, Serialize)] +struct RecordingStatusPayload { + status: String, } pub fn handle(app_handle: &AppHandle, urls: Vec) { @@ -49,7 +65,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() @@ -70,6 +85,12 @@ pub fn handle(app_handle: &AppHandle, urls: Vec) { }); } +fn emit_status_change(app: &AppHandle, status: &str) { + let _ = app.emit_all("cap://recording-status", RecordingStatusPayload { + status: status.to_string(), + }); +} + pub enum ActionParseFromUrlError { ParseFailed(String), Invalid, @@ -115,7 +136,7 @@ impl DeepLinkAction { capture_system_audio, mode, } => { - let state = app.state::>(); + let state: MutableState<'_, App> = app.state(); crate::set_camera_input(app.clone(), state.clone(), camera, None).await?; crate::set_mic_input(state.clone(), mic_label).await?; @@ -142,10 +163,77 @@ impl DeepLinkAction { crate::recording::start_recording(app.clone(), state, inputs) .await - .map(|_| ()) + .map(|_| { + emit_status_change(app, "recording"); + }) } DeepLinkAction::StopRecording => { - crate::recording::stop_recording(app.clone(), app.state()).await + let state: MutableState<'_, App> = app.state(); + crate::recording::stop_recording(app.clone(), state).await.map(|_| { + emit_status_change(app, "idle"); + }) + } + DeepLinkAction::PauseRecording => { + let state: MutableState<'_, App> = app.state(); + let mut state_guard = state.write().await; + if let RecordingState::Active(recording) = &mut state_guard.recording_state { + recording.pause().await.map_err(|e| e.to_string())?; + RecordingEvent::Paused.emit(app).ok(); + emit_status_change(app, "paused"); + } + Ok(()) + } + DeepLinkAction::ResumeRecording => { + let state: MutableState<'_, App> = app.state(); + let mut state_guard = state.write().await; + if let RecordingState::Active(recording) = &mut state_guard.recording_state { + recording.resume().await.map_err(|e| e.to_string())?; + RecordingEvent::Resumed.emit(app).ok(); + emit_status_change(app, "recording"); + } + Ok(()) + } + DeepLinkAction::TogglePauseRecording => { + let state: MutableState<'_, App> = app.state(); + let mut state_guard = state.write().await; + if let RecordingState::Active(recording) = &mut state_guard.recording_state { + let is_paused = recording.is_paused().await.map_err(|e| e.to_string())?; + if is_paused { + recording.resume().await.map_err(|e| e.to_string())?; + RecordingEvent::Resumed.emit(app).ok(); + emit_status_change(app, "recording"); + } else { + recording.pause().await.map_err(|e| e.to_string())?; + RecordingEvent::Paused.emit(app).ok(); + emit_status_change(app, "paused"); + } + } + Ok(()) + } + DeepLinkAction::SwitchCamera { camera_id } => { + let state: MutableState<'_, App> = app.state(); + crate::set_camera_input(app.clone(), state, Some(camera_id), None).await + } + DeepLinkAction::SwitchMic { mic_label } => { + let state: MutableState<'_, App> = app.state(); + crate::set_mic_input(state, Some(mic_label)).await + } + DeepLinkAction::GetStatus => { + let state: MutableState<'_, App> = app.state(); + let state_guard = state.read().await; + let status = match &state_guard.recording_state { + RecordingState::None => "idle", + RecordingState::Pending { .. } => "pending", + RecordingState::Active(recording) => { + if recording.is_paused().await.unwrap_or(false) { + "paused" + } else { + "recording" + } + } + }; + emit_status_change(app, status); + Ok(()) } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) diff --git a/apps/raycast-extension/package.json b/apps/raycast-extension/package.json new file mode 100644 index 0000000000..48f2a281f3 --- /dev/null +++ b/apps/raycast-extension/package.json @@ -0,0 +1,38 @@ +{ + "name": "cap-raycast", + "title": "Cap", + "description": "Control Cap recording via Raycast", + "icon": "command-icon.png", + "author": "Angelebeats", + "categories": [ + "Productivity" + ], + "license": "MIT", + "commands": [ + { + "name": "index", + "title": "Control Recording", + "description": "Start, stop, pause, or resume Cap recording", + "mode": "menu-bar" + } + ], + "dependencies": { + "@raycast/api": "^1.72.0", + "@raycast/utils": "^1.15.0" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.8", + "@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", + "publish": "npx @raycast/api@latest publish" + } +} diff --git a/apps/raycast-extension/src/index.tsx b/apps/raycast-extension/src/index.tsx new file mode 100644 index 0000000000..0cfb61b8f9 --- /dev/null +++ b/apps/raycast-extension/src/index.tsx @@ -0,0 +1,52 @@ +import { MenuBarExtra, open } from "@raycast/api"; +import { useCachedState } from "@raycast/utils"; + +export default function Command() { + const [status, setStatus] = useCachedState<"idle" | "recording" | "paused">("recording-status", "idle"); + + const sendAction = async (action: string, value: object = {}) => { + const json = JSON.stringify({ [action]: value }); + const url = `cap://action?value=${encodeURIComponent(json)}`; + await open(url); + + // Optimistic update for immediate feedback + if (action === "stop_recording") setStatus("idle"); + if (action === "start_recording") setStatus("recording"); + if (action === "pause_recording") setStatus("paused"); + if (action === "resume_recording") setStatus("recording"); + }; + + return ( + + {status === "idle" ? ( + sendAction("start_recording", { + capture_mode: { screen: "Display 1" }, + mode: "video", + capture_system_audio: true + })} + /> + ) : ( + <> + sendAction("stop_recording")} + /> + sendAction(status === "paused" ? "resume_recording" : "pause_recording")} + /> + + )} + + sendAction("open_settings")} + /> + + ); +}