diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index dbd90f667f..440f0961d5 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -6,7 +6,55 @@ use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Url}; use tracing::trace; -use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; +use crate::{ + App, ArcLock, feeds::microphone::MicrophoneFeed, recording::StartRecordingInputs, + windows::ShowCapWindow, +}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DeepLinkStartRecordingResult { + pub success: bool, + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DeepLinkRecordingStatus { + pub is_recording: bool, + pub is_paused: bool, + pub recording_mode: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DeepLinkDevices { + pub cameras: Vec, + pub microphones: Vec, + pub screens: Vec, + pub windows: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DeepLinkCamera { + pub name: String, + pub id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DeepLinkScreen { + pub name: String, + pub id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DeepLinkWindow { + pub name: String, + pub owner_name: String, +} #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -26,6 +74,21 @@ pub enum DeepLinkAction { mode: RecordingMode, }, StopRecording, + PauseRecording, + ResumeRecording, + TogglePauseRecording, + RestartRecording, + TakeScreenshot { + capture_mode: CaptureMode, + }, + SetMicrophone { + label: Option, + }, + SetCamera { + id: Option, + }, + ListDevices, + GetStatus, OpenEditor { project_path: PathBuf, }, @@ -88,8 +151,9 @@ impl TryFrom<&Url> for DeepLinkAction { } match url.domain() { - Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction), - _ => Err(ActionParseFromUrlError::Invalid), + Some("action") => Ok(()), + Some(_) => Err(ActionParseFromUrlError::NotAction), + None => Err(ActionParseFromUrlError::Invalid), }?; let params = url @@ -104,6 +168,21 @@ impl TryFrom<&Url> for DeepLinkAction { } } +fn resolve_capture_target(capture_mode: &CaptureMode) -> Result { + 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_else(|| 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_else(|| format!("No window with name \"{}\"", name)), + } +} + impl DeepLinkAction { pub async fn execute(self, app: &AppHandle) -> Result<(), String> { match self { @@ -119,18 +198,7 @@ impl DeepLinkAction { crate::set_camera_input(app.clone(), state.clone(), camera).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 = resolve_capture_target(&capture_mode)?; let inputs = StartRecordingInputs { mode, @@ -139,13 +207,98 @@ impl DeepLinkAction { organization_id: None, }; - crate::recording::start_recording(app.clone(), state, inputs) - .await - .map(|_| ()) + let result = crate::recording::start_recording(app.clone(), state, inputs).await; + match result { + Ok(crate::recording::RecordingAction::Started) => { + write_deeplink_response(app, &DeepLinkStartRecordingResult { + success: true, + error: None, + }) + } + Ok(crate::recording::RecordingAction::InvalidAuthentication) => { + write_deeplink_response(app, &DeepLinkStartRecordingResult { + success: false, + error: Some("Please sign in to Cap to use instant recording".to_string()), + }) + } + Ok(crate::recording::RecordingAction::UpgradeRequired) => { + write_deeplink_response(app, &DeepLinkStartRecordingResult { + success: false, + error: Some("A Cap upgrade is required to use this feature".to_string()), + }) + } + Err(error) => { + let _ = write_deeplink_response(app, &DeepLinkStartRecordingResult { + success: false, + error: Some(error.clone()), + }); + Err(error) + } + } } 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::RestartRecording => { + crate::recording::restart_recording(app.clone(), app.state()) + .await + .map(|_| ()) + } + DeepLinkAction::TakeScreenshot { capture_mode } => { + let capture_target = resolve_capture_target(&capture_mode)?; + + crate::recording::take_screenshot(app.clone(), capture_target) + .await + .map(|_| ()) + } + DeepLinkAction::SetMicrophone { label } => { + let state = app.state::>(); + crate::set_mic_input(state, label).await + } + DeepLinkAction::SetCamera { id } => { + let state = app.state::>(); + crate::set_camera_input(app.clone(), state, id).await + } + DeepLinkAction::ListDevices => { + let devices = get_available_devices(); + write_deeplink_response(app, &devices) + } + DeepLinkAction::GetStatus => { + let state = app.state::>(); + let app_state = state.read().await; + let status = if let Some(recording) = app_state.current_recording() { + let is_paused = recording.is_paused().await.unwrap_or(false); + let mode = match recording { + crate::recording::InProgressRecording::Instant { .. } => { + Some("instant".to_string()) + } + crate::recording::InProgressRecording::Studio { .. } => { + Some("studio".to_string()) + } + }; + DeepLinkRecordingStatus { + is_recording: true, + is_paused, + recording_mode: mode, + } + } else { + DeepLinkRecordingStatus { + is_recording: false, + is_paused: false, + recording_mode: None, + } + }; + write_deeplink_response(app, &status) + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } @@ -155,3 +308,61 @@ impl DeepLinkAction { } } } + +fn write_deeplink_response(app: &AppHandle, data: &T) -> Result<(), String> { + let response_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {e}"))?; + + std::fs::create_dir_all(&response_dir) + .map_err(|e| format!("Failed to create app data dir: {e}"))?; + + let response_path = response_dir.join("deeplink-response.json"); + let temp_path = response_dir.join("deeplink-response.json.tmp"); + + let json = serde_json::to_string(data).map_err(|e| e.to_string())?; + + // Atomic write: write to temp file, then rename + std::fs::write(&temp_path, &json).map_err(|e| format!("Failed to write response file: {e}"))?; + let _ = std::fs::remove_file(&response_path); + std::fs::rename(&temp_path, &response_path) + .map_err(|e| format!("Failed to rename response file: {e}"))?; + + trace!("Wrote deeplink response to {:?}", response_path); + Ok(()) +} + +fn get_available_devices() -> DeepLinkDevices { + let cameras: Vec = cap_camera::list_cameras() + .map(|c| DeepLinkCamera { + name: c.display_name().to_string(), + id: c.device_id().to_string(), + }) + .collect(); + + let microphones: Vec = MicrophoneFeed::list().keys().cloned().collect(); + + let screens: Vec = cap_recording::screen_capture::list_displays() + .into_iter() + .map(|(s, _)| DeepLinkScreen { + name: s.name, + id: s.id.to_string(), + }) + .collect(); + + let windows: Vec = cap_recording::screen_capture::list_windows() + .into_iter() + .map(|(w, _)| DeepLinkWindow { + name: w.name, + owner_name: w.owner_name, + }) + .collect(); + + DeepLinkDevices { + cameras, + microphones, + screens, + windows, + } +} diff --git a/extensions/raycast/README.md b/extensions/raycast/README.md new file mode 100644 index 0000000000..bed80a6b71 --- /dev/null +++ b/extensions/raycast/README.md @@ -0,0 +1,112 @@ +# Cap Raycast Extension + +Control [Cap](https://cap.so) screen recording directly from Raycast. + +## Features + +- **Start Recording** - Start a new screen or window recording with instant or studio mode +- **Stop Recording** - Stop the current recording with context-aware confirmation +- **Pause Recording** - Pause the current recording +- **Resume Recording** - Resume a paused recording +- **Toggle Pause** - Toggle pause/resume on the current recording +- **Restart Recording** - Restart the current recording +- **Take Screenshot** - Capture a screenshot of a screen or window +- **Recording Status** - Check the current recording status with live elapsed timer +- **Switch Microphone** - Change the active microphone input +- **Switch Camera** - Change the active camera input +- **Open Settings** - Open Cap settings +- **Menu Bar** - Control recording from the macOS menu bar (optional) + +## Requirements + +- [Cap](https://cap.so) desktop app must be installed and running +- macOS + +## Installation + +1. Clone this repository +2. Navigate to the `extensions/raycast` directory +3. Run `npm install` +4. Run `npm run dev` to start development mode + +## Commands + +| Command | Description | Mode | +|---------|-------------|------| +| Start Recording | Choose screen/window and recording mode | View | +| Stop Recording | Stop with confirmation showing recording mode | No-view | +| Pause Recording | Pause current recording | No-view | +| Resume Recording | Resume paused recording | No-view | +| Toggle Pause | Toggle pause/resume state | No-view | +| Restart Recording | Restart current recording | No-view | +| Take Screenshot | Capture screen or window | View | +| Recording Status | Live status with timer and controls | View | +| Switch Microphone | Select audio input device | View | +| Switch Camera | Select video input device | View | +| Open Settings | Open Cap preferences | No-view | + +## Keyboard Shortcuts + +### Start Recording / Take Screenshot +- `⌘ Return` - Submit and start capture +- `⌘ T` - Toggle between Screen and Window capture type +- `⌘ M` - Toggle between Instant and Studio recording mode (Start Recording only) + +### Recording Status +- `⌘ R` - Refresh status +- `⌘ ⇧ P` - Pause/Resume recording +- `⌘ ⇧ R` - Restart recording +- `⌘ ⇧ Backspace` - Stop recording + +### Device Selection (Switch Camera/Microphone) +- `⌘ 1-9` - Quick select device by number +- `⌘ Return` - Select highlighted device + +## How It Works + +This extension uses Cap's deeplink API to control the app. Commands are sent via the `cap-desktop://` URL scheme. + +## Deeplink Format + +``` +cap-desktop://action?value= +``` + +### Available Actions + +| Action | Description | +|--------|-------------| +| `get_status` | Get current recording status | +| `list_devices` | List available cameras, microphones, screens, and windows | +| `start_recording` | Start a new recording | +| `stop_recording` | Stop the current recording | +| `pause_recording` | Pause the current recording | +| `resume_recording` | Resume a paused recording | +| `toggle_pause_recording` | Toggle pause state | +| `restart_recording` | Restart the current recording | +| `take_screenshot` | Take a screenshot | +| `set_microphone` | Switch microphone | +| `set_camera` | Switch camera | +| `open_settings` | Open Cap settings | +| `open_editor` | Open a project in the editor | + +## UX Features + +- **Recent Items**: Recently used screens/windows/cameras/microphones are remembered and shown at the top of lists +- **Empty States**: Helpful messages when Cap isn't running or no devices are found +- **Live Timer**: Recording Status shows elapsed time in real-time +- **HUD Feedback**: Quick visual confirmation instead of disruptive toasts +- **Color-coded Status**: Red = Recording, Yellow = Paused, Gray = Idle +- **Smart Defaults**: Auto-selects the first available target on load +- **Context-aware**: Stop Recording shows whether it's instant or studio mode + +## Development + +```bash +npm install +npm run dev +``` + +## License + +MIT diff --git a/extensions/raycast/assets/icon.png b/extensions/raycast/assets/icon.png new file mode 100644 index 0000000000..718226caf2 Binary files /dev/null and b/extensions/raycast/assets/icon.png differ diff --git a/extensions/raycast/package.json b/extensions/raycast/package.json new file mode 100644 index 0000000000..3308273173 --- /dev/null +++ b/extensions/raycast/package.json @@ -0,0 +1,166 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Control Cap screen recording from Raycast", + "icon": "icon.png", + "author": "cap", + "categories": [ + "Productivity", + "Applications" + ], + "license": "MIT", + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "subtitle": "Cap", + "description": "Start a new screen recording", + "mode": "view", + "keywords": [ + "record", + "capture", + "screen" + ] + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "subtitle": "Cap", + "description": "Stop the current recording", + "mode": "no-view", + "keywords": [ + "stop", + "end", + "finish" + ] + }, + { + "name": "pause-recording", + "title": "Pause Recording", + "subtitle": "Cap", + "description": "Pause the current recording", + "mode": "no-view", + "keywords": [ + "pause", + "hold" + ] + }, + { + "name": "resume-recording", + "title": "Resume Recording", + "subtitle": "Cap", + "description": "Resume a paused recording", + "mode": "no-view", + "keywords": [ + "resume", + "continue" + ] + }, + { + "name": "toggle-pause", + "title": "Toggle Pause", + "subtitle": "Cap", + "description": "Toggle pause/resume on the current recording", + "mode": "no-view", + "keywords": [ + "toggle", + "pause", + "resume" + ] + }, + { + "name": "take-screenshot", + "title": "Take Screenshot", + "subtitle": "Cap", + "description": "Take a screenshot of a screen or window", + "mode": "view", + "keywords": [ + "screenshot", + "capture", + "snap" + ] + }, + { + "name": "recording-status", + "title": "Recording Status", + "subtitle": "Cap", + "description": "Show the current recording status", + "mode": "view", + "keywords": [ + "status", + "state", + "info" + ] + }, + { + "name": "restart-recording", + "title": "Restart Recording", + "subtitle": "Cap", + "description": "Restart the current recording", + "mode": "no-view", + "keywords": [ + "restart", + "redo" + ] + }, + { + "name": "switch-microphone", + "title": "Switch Microphone", + "subtitle": "Cap", + "description": "Switch the active microphone input", + "mode": "view", + "keywords": [ + "microphone", + "mic", + "audio", + "input" + ] + }, + { + "name": "switch-camera", + "title": "Switch Camera", + "subtitle": "Cap", + "description": "Switch the active camera input", + "mode": "view", + "keywords": [ + "camera", + "webcam", + "video", + "input" + ] + }, + { + "name": "open-settings", + "title": "Open Settings", + "subtitle": "Cap", + "description": "Open Cap settings", + "mode": "no-view", + "keywords": [ + "settings", + "preferences", + "config" + ] + } + ], + "dependencies": { + "@raycast/api": "^1.87.0", + "@raycast/utils": "^1.17.0" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "22.10.2", + "@types/react": "19.0.2", + "eslint": "^9.16.0", + "prettier": "^3.4.2", + "typescript": "^5.7.2" + }, + "scripts": { + "build": "ray build --skip-types -e dist -o 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" + } +} diff --git a/extensions/raycast/raycast-env.d.ts b/extensions/raycast/raycast-env.d.ts new file mode 100644 index 0000000000..f762bd4dbe --- /dev/null +++ b/extensions/raycast/raycast-env.d.ts @@ -0,0 +1,64 @@ +/// + +/* 🚧 🚧 🚧 + * This file is auto-generated from the extension's manifest. + * Do not modify manually. Instead, update the `package.json` file. + * 🚧 🚧 🚧 */ + +/* eslint-disable @typescript-eslint/ban-types */ + +type ExtensionPreferences = {} + +/** Preferences accessible in all the extension's commands */ +declare type Preferences = ExtensionPreferences + +declare namespace Preferences { + /** Preferences accessible in the `start-recording` command */ + export type StartRecording = ExtensionPreferences & {} + /** Preferences accessible in the `stop-recording` command */ + export type StopRecording = ExtensionPreferences & {} + /** Preferences accessible in the `pause-recording` command */ + export type PauseRecording = ExtensionPreferences & {} + /** Preferences accessible in the `resume-recording` command */ + export type ResumeRecording = ExtensionPreferences & {} + /** Preferences accessible in the `toggle-pause` command */ + export type TogglePause = ExtensionPreferences & {} + /** Preferences accessible in the `take-screenshot` command */ + export type TakeScreenshot = ExtensionPreferences & {} + /** Preferences accessible in the `recording-status` command */ + export type RecordingStatus = ExtensionPreferences & {} + /** Preferences accessible in the `restart-recording` command */ + export type RestartRecording = ExtensionPreferences & {} + /** Preferences accessible in the `switch-microphone` command */ + export type SwitchMicrophone = ExtensionPreferences & {} + /** Preferences accessible in the `switch-camera` command */ + export type SwitchCamera = ExtensionPreferences & {} + /** Preferences accessible in the `open-settings` command */ + export type OpenSettings = ExtensionPreferences & {} +} + +declare namespace Arguments { + /** Arguments passed to the `start-recording` command */ + export type StartRecording = {} + /** Arguments passed to the `stop-recording` command */ + export type StopRecording = {} + /** Arguments passed to the `pause-recording` command */ + export type PauseRecording = {} + /** Arguments passed to the `resume-recording` command */ + export type ResumeRecording = {} + /** Arguments passed to the `toggle-pause` command */ + export type TogglePause = {} + /** Arguments passed to the `take-screenshot` command */ + export type TakeScreenshot = {} + /** Arguments passed to the `recording-status` command */ + export type RecordingStatus = {} + /** Arguments passed to the `restart-recording` command */ + export type RestartRecording = {} + /** Arguments passed to the `switch-microphone` command */ + export type SwitchMicrophone = {} + /** Arguments passed to the `switch-camera` command */ + export type SwitchCamera = {} + /** Arguments passed to the `open-settings` command */ + export type OpenSettings = {} +} + diff --git a/extensions/raycast/src/open-settings.tsx b/extensions/raycast/src/open-settings.tsx new file mode 100644 index 0000000000..e542dac759 --- /dev/null +++ b/extensions/raycast/src/open-settings.tsx @@ -0,0 +1,8 @@ +import { createOpenSettingsAction, executeCapAction } from "./utils"; + +export default async function Command() { + await executeCapAction(createOpenSettingsAction(), { + feedbackMessage: "Opening Cap settings...", + feedbackType: "hud", + }); +} diff --git a/extensions/raycast/src/pause-recording.tsx b/extensions/raycast/src/pause-recording.tsx new file mode 100644 index 0000000000..69c43cd730 --- /dev/null +++ b/extensions/raycast/src/pause-recording.tsx @@ -0,0 +1,8 @@ +import { createPauseRecordingAction, executeCapAction } from "./utils"; + +export default async function Command() { + await executeCapAction(createPauseRecordingAction(), { + feedbackMessage: "Pausing recording...", + feedbackType: "hud", + }); +} diff --git a/extensions/raycast/src/recording-menu-bar.tsx b/extensions/raycast/src/recording-menu-bar.tsx new file mode 100644 index 0000000000..992122360f --- /dev/null +++ b/extensions/raycast/src/recording-menu-bar.tsx @@ -0,0 +1,204 @@ +import { Color, Icon, MenuBarExtra, open, showHUD } from "@raycast/api"; +import { useEffect, useState } from "react"; +import { + capNotInstalled, + createGetStatusAction, + createPauseRecordingAction, + createRestartRecordingAction, + createResumeRecordingAction, + createStartRecordingAction, + createStopRecordingAction, + createTakeScreenshotAction, + executeCapAction, + executeCapActionWithResponse, + type RecordingStatus, +} from "./utils"; + +export default function Command() { + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isInstalled, setIsInstalled] = useState(null); + + async function fetchStatus() { + if (await capNotInstalled(false)) { + setIsInstalled(false); + setIsLoading(false); + return; + } + setIsInstalled(true); + + const result = await executeCapActionWithResponse( + createGetStatusAction(), + ); + setStatus(result); + setIsLoading(false); + } + + useEffect(() => { + fetchStatus(); + const interval = setInterval(fetchStatus, 3000); + return () => clearInterval(interval); + }, []); + + if (isInstalled === false) { + return ( + + + open("https://cap.so/download")} + /> + + + ); + } + + const isRecording = status?.is_recording ?? false; + const isPaused = status?.is_paused ?? false; + const recordingMode = status?.recording_mode; + + // Determine icon and title based on state + let icon = Icon.Video; + let tintColor: Color | undefined; + let title = "Cap"; + let tooltip = "Cap Recording Control"; + + if (isRecording) { + if (isPaused) { + icon = Icon.Pause; + tintColor = Color.Yellow; + title = "Paused"; + tooltip = `Recording paused (${recordingMode})`; + } else { + icon = Icon.Video; + tintColor = Color.Red; + title = "REC"; + tooltip = `Recording in progress (${recordingMode})`; + } + } + + return ( + + {isRecording ? ( + <> + + + {recordingMode && ( + + )} + + + { + if (isPaused) { + await executeCapAction(createResumeRecordingAction(), { + closeWindow: false, + }); + await showHUD("▶️ Resumed"); + } else { + await executeCapAction(createPauseRecordingAction(), { + closeWindow: false, + }); + await showHUD("⏸️ Paused"); + } + setTimeout(fetchStatus, 500); + }} + /> + { + await executeCapAction(createRestartRecordingAction(), { + closeWindow: false, + }); + await showHUD("🔄 Restarted"); + setTimeout(fetchStatus, 500); + }} + /> + + + { + await executeCapAction(createStopRecordingAction(), { + closeWindow: false, + }); + await showHUD("⏹️ Stopped"); + setTimeout(fetchStatus, 500); + }} + /> + + + ) : ( + <> + + { + await executeCapAction( + createStartRecordingAction({ screen: "Primary" }, "instant"), + { closeWindow: false }, + ); + await showHUD("🎬 Instant recording started"); + setTimeout(fetchStatus, 500); + }} + /> + { + await executeCapAction( + createStartRecordingAction({ screen: "Primary" }, "studio"), + { closeWindow: false }, + ); + await showHUD("🎬 Studio recording started"); + setTimeout(fetchStatus, 500); + }} + /> + + + { + await executeCapAction( + createTakeScreenshotAction({ screen: "Primary" }), + { closeWindow: false }, + ); + await showHUD("📸 Screenshot taken"); + }} + /> + + + )} + + open("cap-desktop://")} + /> + + + ); +} diff --git a/extensions/raycast/src/recording-status.tsx b/extensions/raycast/src/recording-status.tsx new file mode 100644 index 0000000000..89b8d5d666 --- /dev/null +++ b/extensions/raycast/src/recording-status.tsx @@ -0,0 +1,265 @@ +import { + Action, + ActionPanel, + Color, + Detail, + Icon, + Keyboard, + showHUD, +} from "@raycast/api"; +import { useEffect, useState } from "react"; +import { + capNotInstalled, + createGetStatusAction, + createRestartRecordingAction, + createStartRecordingAction, + createStopRecordingAction, + createTogglePauseAction, + executeCapAction, + executeCapActionWithResponse, + type RecordingStatus, +} from "./utils"; + +function formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + if (mins < 60) { + return `${mins}:${secs.toString().padStart(2, "0")}`; + } + const hours = Math.floor(mins / 60); + const remainingMins = mins % 60; + return `${hours}:${remainingMins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; +} + +export default function Command() { + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [recordingStartTime, setRecordingStartTime] = useState( + null, + ); + + async function fetchStatus() { + setIsLoading(true); + setError(null); + + if (await capNotInstalled(false)) { + setError("Cap is not installed"); + setIsLoading(false); + return; + } + + const result = await executeCapActionWithResponse( + createGetStatusAction(), + ); + + if (result) { + setStatus(result); + // Start tracking elapsed time when recording starts + if (result.is_recording && !result.is_paused) { + if (!recordingStartTime) { + setRecordingStartTime(Date.now()); + } + } else { + setRecordingStartTime(null); + setElapsedSeconds(0); + } + } else { + setError("Could not get status from Cap. Make sure the app is running."); + } + setIsLoading(false); + } + + // Auto-refresh and elapsed time tracking + useEffect(() => { + fetchStatus(); + + const refreshInterval = setInterval(() => { + fetchStatus(); + }, 3000); + + return () => clearInterval(refreshInterval); + }, []); + + // Update elapsed time every second when recording + useEffect(() => { + if (!recordingStartTime) return; + + const timerInterval = setInterval(() => { + const elapsed = Math.floor((Date.now() - recordingStartTime) / 1000); + setElapsedSeconds(elapsed); + }, 1000); + + return () => clearInterval(timerInterval); + }, [recordingStartTime]); + + const statusConfig = status?.is_recording + ? status.is_paused + ? { + value: "Paused", + color: Color.Yellow, + icon: Icon.Pause, + tooltip: "Recording is paused", + } + : { + value: "Recording", + color: Color.Red, + icon: Icon.Video, + tooltip: "Currently recording", + } + : { + value: "Idle", + color: Color.SecondaryText, + icon: Icon.CircleDisabled, + tooltip: "No active recording", + }; + + const modeText = status?.recording_mode + ? status.recording_mode.charAt(0).toUpperCase() + + status.recording_mode.slice(1) + : undefined; + + // Build markdown with status info + let markdown = "# Cap Recording Status\n\n"; + if (error) { + markdown = `# Error\n\n${error}`; + } else if (status?.is_recording) { + const durationText = status.is_paused + ? "⏸ Paused" + : `⏱ ${formatDuration(elapsedSeconds)}`; + markdown += `## ${statusConfig.icon === Icon.Video ? "🔴" : "⏸️"} ${statusConfig.value}\n\n`; + markdown += `**Duration:** ${durationText}\n\n`; + markdown += `**Mode:** ${modeText ?? "Unknown"}\n\n`; + markdown += status.is_paused + ? "_Recording is paused. Press Resume to continue._" + : "_Recording in progress. Use the controls below to manage._"; + } else { + markdown += "## No Active Recording\n\n"; + markdown += "Start a new recording from the actions below."; + } + + const metadata = + !error && status ? ( + + + + + {status.is_recording && ( + + )} + {modeText && } + + + + ) : undefined; + + return ( + + + {!error && status?.is_recording && ( + + { + await executeCapAction(createTogglePauseAction(), { + closeWindow: false, + }); + await showHUD(status?.is_paused ? "▶️ Resumed" : "⏸️ Paused"); + setTimeout(fetchStatus, 500); + }} + /> + { + await executeCapAction(createRestartRecordingAction(), { + closeWindow: false, + }); + await showHUD("🔄 Restarted recording"); + setRecordingStartTime(Date.now()); + setElapsedSeconds(0); + setTimeout(fetchStatus, 500); + }} + /> + { + await executeCapAction(createStopRecordingAction(), { + closeWindow: false, + }); + await showHUD("⏹️ Recording stopped"); + setRecordingStartTime(null); + setElapsedSeconds(0); + setTimeout(fetchStatus, 500); + }} + /> + + )} + {!error && !status?.is_recording && ( + + { + await executeCapAction( + createStartRecordingAction( + { screen: "Primary" }, + "instant", + ), + { closeWindow: false }, + ); + await showHUD("🎬 Started instant recording"); + setTimeout(fetchStatus, 500); + }} + /> + { + await executeCapAction( + createStartRecordingAction({ screen: "Primary" }, "studio"), + { closeWindow: false }, + ); + await showHUD("🎬 Started studio recording"); + setTimeout(fetchStatus, 500); + }} + /> + + )} + + } + /> + ); +} diff --git a/extensions/raycast/src/restart-recording.tsx b/extensions/raycast/src/restart-recording.tsx new file mode 100644 index 0000000000..61a15f5b5d --- /dev/null +++ b/extensions/raycast/src/restart-recording.tsx @@ -0,0 +1,8 @@ +import { createRestartRecordingAction, executeCapAction } from "./utils"; + +export default async function Command() { + await executeCapAction(createRestartRecordingAction(), { + feedbackMessage: "Restarting recording...", + feedbackType: "hud", + }); +} diff --git a/extensions/raycast/src/resume-recording.tsx b/extensions/raycast/src/resume-recording.tsx new file mode 100644 index 0000000000..ede17e36d3 --- /dev/null +++ b/extensions/raycast/src/resume-recording.tsx @@ -0,0 +1,8 @@ +import { createResumeRecordingAction, executeCapAction } from "./utils"; + +export default async function Command() { + await executeCapAction(createResumeRecordingAction(), { + feedbackMessage: "Resuming recording...", + feedbackType: "hud", + }); +} diff --git a/extensions/raycast/src/start-recording.tsx b/extensions/raycast/src/start-recording.tsx new file mode 100644 index 0000000000..3579a05a22 --- /dev/null +++ b/extensions/raycast/src/start-recording.tsx @@ -0,0 +1,301 @@ +import { + Action, + ActionPanel, + Detail, + Form, + Icon, + LocalStorage, + showToast, + Toast, +} from "@raycast/api"; +import { useEffect, useMemo, useState } from "react"; +import { + CAP_URL_SCHEME, + capNotInstalled, + createListDevicesAction, + createStartRecordingAction, + type DeepLinkDevices, + executeCapAction, + executeCapActionWithResponse, + type RecordingMode, +} from "./utils"; + +type CaptureType = "screen" | "window"; +type RecentTarget = { type: CaptureType; name: string; timestamp: number }; + +const RECENT_TARGETS_KEY = "recent-capture-targets"; +const MAX_RECENT_TARGETS = 5; + +function EmptyState({ + capNotRunning, + onOpenCap, +}: { + capNotRunning: boolean; + onOpenCap: () => void; +}) { + const markdown = capNotRunning + ? "## Cap Not Running\n\nPlease open Cap to start recording.\n\nIf you don't have Cap installed, you can download it from the website." + : "## No Capture Targets Found\n\nCould not find any screens or windows to capture.\n\nMake sure screen recording permissions are granted."; + + return ( + + {capNotRunning ? ( + <> + + + + ) : ( + {}} + /> + )} + + } + /> + ); +} + +export default function Command() { + const [captureType, setCaptureType] = useState("screen"); + const [selectedTarget, setSelectedTarget] = useState(""); + const [recordingMode, setRecordingMode] = useState("instant"); + const [devices, setDevices] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [capNotRunning, setCapNotRunning] = useState(false); + const [recentTargets, setRecentTargets] = useState([]); + + useEffect(() => { + async function loadRecentTargets() { + const stored = await LocalStorage.getItem(RECENT_TARGETS_KEY); + if (stored) { + setRecentTargets(JSON.parse(stored)); + } + } + loadRecentTargets(); + }, []); + + useEffect(() => { + async function loadDevices() { + const notInstalled = await capNotInstalled(); + if (notInstalled) { + setCapNotRunning(true); + setIsLoading(false); + return; + } + + const result = await executeCapActionWithResponse( + createListDevicesAction(), + ); + + if (result && (result.screens.length > 0 || result.windows.length > 0)) { + setDevices(result); + const relevantRecent = recentTargets.find( + (r) => r.type === captureType, + ); + if (relevantRecent) { + const exists = + captureType === "screen" + ? result.screens.some((s) => s.name === relevantRecent.name) + : result.windows.some((w) => w.name === relevantRecent.name); + if (exists) { + setSelectedTarget(relevantRecent.name); + } else if (result.screens.length > 0) { + setSelectedTarget(result.screens[0].name); + } + } else if (result.screens.length > 0) { + setSelectedTarget(result.screens[0].name); + } + } else { + setCapNotRunning(true); + } + setIsLoading(false); + } + + loadDevices(); + }, [captureType, recentTargets]); + + async function saveRecentTarget(type: CaptureType, name: string) { + const updated: RecentTarget[] = [ + { type, name, timestamp: Date.now() }, + ...recentTargets.filter((t) => !(t.type === type && t.name === name)), + ].slice(0, MAX_RECENT_TARGETS); + setRecentTargets(updated); + await LocalStorage.setItem(RECENT_TARGETS_KEY, JSON.stringify(updated)); + } + + async function handleSubmit() { + if (await capNotInstalled()) { + return; + } + + if (!selectedTarget) { + showToast({ + style: Toast.Style.Failure, + title: "Please select a target", + }); + return; + } + + const captureMode = + captureType === "screen" + ? { screen: selectedTarget } + : { window: selectedTarget }; + + await saveRecentTarget(captureType, selectedTarget); + + await executeCapAction( + createStartRecordingAction(captureMode, recordingMode), + { + feedbackMessage: `🎬 ${recordingMode === "instant" ? "Instant" : "Studio"} recording started`, + feedbackType: "hud", + }, + ); + } + + function handleOpenCap() { + import("node:child_process").then(({ execFileSync }) => { + execFileSync("open", [CAP_URL_SCHEME]); + }); + } + + if (!isLoading && capNotRunning) { + return ( + + ); + } + + const allTargets = useMemo(() => { + if (captureType === "screen") { + return (devices?.screens ?? []).map((s) => ({ + name: s.name, + value: s.name, + })); + } + return (devices?.windows ?? []).map((w) => ({ + name: w.owner_name ? `${w.owner_name} — ${w.name}` : w.name, + value: w.name, + })); + }, [captureType, devices]); + + // Build dropdown items with sections + const recentForCurrentType = recentTargets.filter( + (r) => r.type === captureType && allTargets.some((t) => t.value === r.name), + ); + + return ( +
+ + + { + const newType = captureType === "screen" ? "window" : "screen"; + setCaptureType(newType); + const newTargets = + newType === "screen" + ? (devices?.screens ?? []) + : (devices?.windows ?? []); + if (newTargets.length > 0) { + setSelectedTarget(newTargets[0].name); + } + }} + /> + + setRecordingMode((m) => + m === "instant" ? "studio" : "instant", + ) + } + /> + + + } + > + { + setCaptureType(v as CaptureType); + const newTargets = + v === "screen" + ? (devices?.screens ?? []) + : (devices?.windows ?? []); + if (newTargets.length > 0) { + setSelectedTarget(newTargets[0].name); + } else { + setSelectedTarget(""); + } + }} + > + + + + + {recentForCurrentType.length > 0 && ( + + {recentForCurrentType.map((r) => ( + + ))} + + )} + + {allTargets.map((t) => ( + + ))} + + + + setRecordingMode(v as RecordingMode)} + > + + + + + ); +} diff --git a/extensions/raycast/src/stop-recording.tsx b/extensions/raycast/src/stop-recording.tsx new file mode 100644 index 0000000000..035d5c25ac --- /dev/null +++ b/extensions/raycast/src/stop-recording.tsx @@ -0,0 +1,68 @@ +import { Alert, confirmAlert, Icon, open } from "@raycast/api"; +import { + capNotInstalled, + createGetStatusAction, + createStopRecordingAction, + executeCapAction, + executeCapActionWithResponse, + type RecordingStatus, +} from "./utils"; + +export default async function Command() { + if (await capNotInstalled()) { + return; + } + + const status = await executeCapActionWithResponse( + createGetStatusAction(), + ); + + const isRecording = status?.is_recording ?? false; + const isPaused = status?.is_paused ?? false; + const recordingMode = status?.recording_mode; + + if (!isRecording) { + // If not recording, show alert with option to open Cap + await confirmAlert({ + title: "No Active Recording", + message: "There is no recording in progress to stop.", + icon: Icon.Stop, + primaryAction: { + title: "Open Cap", + onAction: () => open("cap-desktop://"), + }, + }); + return; + } + + // Build context message for active recording + let contextMessage = "The current recording will be stopped and "; + if (recordingMode === "instant") { + contextMessage += "processed for instant sharing."; + } else if (recordingMode === "studio") { + contextMessage += "opened in the editor for post-processing."; + } else { + contextMessage += "saved."; + } + + if (isPaused) { + contextMessage += "\n\n(Recording is currently paused)"; + } + + const confirmed = await confirmAlert({ + title: "Stop Recording?", + message: contextMessage, + icon: Icon.Stop, + primaryAction: { + title: "Stop Recording", + style: Alert.ActionStyle.Destructive, + }, + }); + + if (!confirmed) return; + + await executeCapAction(createStopRecordingAction(), { + feedbackMessage: "Recording stopped", + feedbackType: "hud", + }); +} diff --git a/extensions/raycast/src/switch-camera.tsx b/extensions/raycast/src/switch-camera.tsx new file mode 100644 index 0000000000..806e00ce71 --- /dev/null +++ b/extensions/raycast/src/switch-camera.tsx @@ -0,0 +1,183 @@ +import { + Action, + ActionPanel, + Color, + Detail, + Icon, + List, + LocalStorage, +} from "@raycast/api"; +import { useEffect, useState } from "react"; +import { + CAP_URL_SCHEME, + capNotInstalled, + createListDevicesAction, + createSetCameraAction, + type DeepLinkCamera, + type DeepLinkDevices, + executeCapAction, + executeCapActionWithResponse, +} from "./utils"; + +const RECENT_CAMERA_KEY = "recent-camera"; + +function EmptyState({ + capNotRunning, + onOpenCap, +}: { + capNotRunning: boolean; + onOpenCap: () => void; +}) { + const markdown = capNotRunning + ? "## Cap Not Running\n\nPlease open Cap to switch cameras." + : "## No Cameras Found\n\nCould not find any cameras connected to your system."; + + return ( + + {capNotRunning ? ( + + ) : ( + {}} + /> + )} + + } + /> + ); +} + +export default function Command() { + const [cameras, setCameras] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [capNotRunning, setCapNotRunning] = useState(false); + const [recentCamera, setRecentCamera] = useState(null); + + useEffect(() => { + async function loadRecent() { + const stored = await LocalStorage.getItem(RECENT_CAMERA_KEY); + setRecentCamera(stored ?? null); + } + loadRecent(); + }, []); + + useEffect(() => { + async function loadDevices() { + const notInstalled = await capNotInstalled(); + if (notInstalled) { + setCapNotRunning(true); + setIsLoading(false); + return; + } + + const result = await executeCapActionWithResponse( + createListDevicesAction(), + ); + + if (result && result.cameras.length > 0) { + setCameras(result.cameras); + } else if (!result || result.cameras.length === 0) { + setCapNotRunning(true); + } + setIsLoading(false); + } + + loadDevices(); + }, []); + + async function handleSelectCamera( + cameraId: string | null, + cameraName: string | null, + ) { + if (cameraId) { + await LocalStorage.setItem(RECENT_CAMERA_KEY, cameraId); + } + await executeCapAction( + createSetCameraAction(cameraId ? { DeviceID: cameraId } : null), + { + feedbackMessage: cameraName ? `📹 ${cameraName}` : "📹 Camera off", + feedbackType: "hud", + }, + ); + } + + function handleOpenCap() { + import("node:child_process").then(({ execFileSync }) => { + execFileSync("open", [CAP_URL_SCHEME]); + }); + } + + if (!isLoading && capNotRunning) { + return ( + + ); + } + + return ( + + + + handleSelectCamera(null, null)} + /> + + } + /> + + {cameras.length > 0 && ( + + {cameras.map((camera) => { + const isRecent = camera.id === recentCamera; + return ( + + + handleSelectCamera(camera.id, camera.name) + } + /> + + } + /> + ); + })} + + )} + + ); +} diff --git a/extensions/raycast/src/switch-microphone.tsx b/extensions/raycast/src/switch-microphone.tsx new file mode 100644 index 0000000000..db73633658 --- /dev/null +++ b/extensions/raycast/src/switch-microphone.tsx @@ -0,0 +1,180 @@ +import { + Action, + ActionPanel, + Color, + Detail, + Icon, + List, + LocalStorage, +} from "@raycast/api"; +import { useEffect, useState } from "react"; +import { + CAP_URL_SCHEME, + capNotInstalled, + createListDevicesAction, + createSetMicrophoneAction, + type DeepLinkDevices, + executeCapAction, + executeCapActionWithResponse, +} from "./utils"; + +const RECENT_MIC_KEY = "recent-microphone"; + +function EmptyState({ + capNotRunning, + onOpenCap, +}: { + capNotRunning: boolean; + onOpenCap: () => void; +}) { + const markdown = capNotRunning + ? "## Cap Not Running\n\nPlease open Cap to switch microphones." + : "## No Microphones Found\n\nCould not find any microphones connected to your system."; + + return ( + + {capNotRunning ? ( + + ) : ( + {}} + /> + )} + + } + /> + ); +} + +export default function Command() { + const [microphones, setMicrophones] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [capNotRunning, setCapNotRunning] = useState(false); + const [recentMic, setRecentMic] = useState(null); + + useEffect(() => { + async function loadRecent() { + const stored = await LocalStorage.getItem(RECENT_MIC_KEY); + setRecentMic(stored ?? null); + } + loadRecent(); + }, []); + + useEffect(() => { + async function loadDevices() { + const notInstalled = await capNotInstalled(); + if (notInstalled) { + setCapNotRunning(true); + setIsLoading(false); + return; + } + + const result = await executeCapActionWithResponse( + createListDevicesAction(), + ); + + if (result && result.microphones.length > 0) { + setMicrophones(result.microphones); + } else if (!result || result.microphones.length === 0) { + setCapNotRunning(true); + } + setIsLoading(false); + } + + loadDevices(); + }, []); + + async function handleSelectMicrophone( + micLabel: string | null, + micName: string | null, + ) { + if (micLabel) { + await LocalStorage.setItem(RECENT_MIC_KEY, micLabel); + } + await executeCapAction(createSetMicrophoneAction(micLabel), { + feedbackMessage: micName ? `🎤 ${micName}` : "🎤 Microphone off", + feedbackType: "hud", + }); + } + + function handleOpenCap() { + import("node:child_process").then(({ execFileSync }) => { + execFileSync("open", [CAP_URL_SCHEME]); + }); + } + + if (!isLoading && capNotRunning) { + return ( + + ); + } + + return ( + + + + handleSelectMicrophone(null, null)} + /> + + } + /> + + {microphones.length > 0 && ( + + {microphones.map((mic) => { + const isRecent = mic === recentMic; + return ( + + handleSelectMicrophone(mic, mic)} + /> + + } + /> + ); + })} + + )} + + ); +} diff --git a/extensions/raycast/src/take-screenshot.tsx b/extensions/raycast/src/take-screenshot.tsx new file mode 100644 index 0000000000..c73585bb3e --- /dev/null +++ b/extensions/raycast/src/take-screenshot.tsx @@ -0,0 +1,287 @@ +import { + Action, + ActionPanel, + Detail, + Form, + Icon, + LocalStorage, + showToast, + Toast, +} from "@raycast/api"; +import { useEffect, useMemo, useState } from "react"; +import { + CAP_URL_SCHEME, + capNotInstalled, + createListDevicesAction, + createTakeScreenshotAction, + type DeepLinkDevices, + type DeepLinkScreen, + type DeepLinkWindow, + executeCapAction, + executeCapActionWithResponse, +} from "./utils"; + +type CaptureType = "screen" | "window"; +type RecentTarget = { type: CaptureType; name: string; timestamp: number }; + +const RECENT_SCREENSHOT_TARGETS_KEY = "recent-screenshot-targets"; +const MAX_RECENT_TARGETS = 5; + +function EmptyState({ + capNotRunning, + onOpenCap, +}: { + capNotRunning: boolean; + onOpenCap: () => void; +}) { + const markdown = capNotRunning + ? "## Cap Not Running\n\nPlease open Cap to take screenshots.\n\nIf you don't have Cap installed, you can download it from the website." + : "## No Capture Targets Found\n\nCould not find any screens or windows to capture.\n\nMake sure screen recording permissions are granted."; + + return ( + + {capNotRunning ? ( + <> + + + + ) : ( + {}} + /> + )} + + } + /> + ); +} + +export default function Command() { + const [captureType, setCaptureType] = useState("screen"); + const [selectedTarget, setSelectedTarget] = useState(""); + const [devices, setDevices] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [capNotRunning, setCapNotRunning] = useState(false); + const [recentTargets, setRecentTargets] = useState([]); + + useEffect(() => { + async function loadRecentTargets() { + const stored = await LocalStorage.getItem( + RECENT_SCREENSHOT_TARGETS_KEY, + ); + if (stored) { + setRecentTargets(JSON.parse(stored)); + } + } + loadRecentTargets(); + }, []); + + useEffect(() => { + async function loadDevices() { + const notInstalled = await capNotInstalled(); + if (notInstalled) { + setCapNotRunning(true); + setIsLoading(false); + return; + } + + const result = await executeCapActionWithResponse( + createListDevicesAction(), + ); + + if (result && (result.screens.length > 0 || result.windows.length > 0)) { + setDevices(result); + const relevantRecent = recentTargets.find( + (r) => r.type === captureType, + ); + if (relevantRecent) { + const exists = + captureType === "screen" + ? result.screens.some( + (s: DeepLinkScreen) => s.name === relevantRecent.name, + ) + : result.windows.some( + (w: DeepLinkWindow) => w.name === relevantRecent.name, + ); + if (exists) { + setSelectedTarget(relevantRecent.name); + } else if (result.screens.length > 0) { + setSelectedTarget(result.screens[0].name); + } + } else if (result.screens.length > 0) { + setSelectedTarget(result.screens[0].name); + } + } else { + setCapNotRunning(true); + } + setIsLoading(false); + } + + loadDevices(); + }, [captureType, recentTargets]); + + async function saveRecentTarget(type: CaptureType, name: string) { + const updated: RecentTarget[] = [ + { type, name, timestamp: Date.now() }, + ...recentTargets.filter((t) => !(t.type === type && t.name === name)), + ].slice(0, MAX_RECENT_TARGETS); + setRecentTargets(updated); + await LocalStorage.setItem( + RECENT_SCREENSHOT_TARGETS_KEY, + JSON.stringify(updated), + ); + } + + async function handleSubmit() { + if (await capNotInstalled()) { + return; + } + + if (!selectedTarget) { + showToast({ + style: Toast.Style.Failure, + title: "Please select a target", + }); + return; + } + + const captureMode = + captureType === "screen" + ? { screen: selectedTarget } + : { window: selectedTarget }; + + await saveRecentTarget(captureType, selectedTarget); + + await executeCapAction(createTakeScreenshotAction(captureMode), { + feedbackMessage: "📸 Screenshot taken", + feedbackType: "hud", + }); + } + + function handleOpenCap() { + import("node:child_process").then(({ execFileSync }) => { + execFileSync("open", [CAP_URL_SCHEME]); + }); + } + + if (!isLoading && capNotRunning) { + return ( + + ); + } + + const allTargets = useMemo(() => { + if (captureType === "screen") { + return (devices?.screens ?? []).map((s) => ({ + name: s.name, + value: s.name, + })); + } + return (devices?.windows ?? []).map((w) => ({ + name: w.owner_name ? `${w.owner_name} — ${w.name}` : w.name, + value: w.name, + })); + }, [captureType, devices]); + + const recentForCurrentType = recentTargets.filter( + (r) => r.type === captureType && allTargets.some((t) => t.value === r.name), + ); + + return ( +
+ + + { + const newType = captureType === "screen" ? "window" : "screen"; + setCaptureType(newType); + const newTargets = + newType === "screen" + ? (devices?.screens ?? []) + : (devices?.windows ?? []); + if (newTargets.length > 0) { + setSelectedTarget(newTargets[0].name); + } + }} + /> + + + } + > + { + setCaptureType(v as CaptureType); + const newTargets = + v === "screen" + ? (devices?.screens ?? []) + : (devices?.windows ?? []); + if (newTargets.length > 0) { + setSelectedTarget(newTargets[0].name); + } else { + setSelectedTarget(""); + } + }} + > + + + + + {recentForCurrentType.length > 0 && ( + + {recentForCurrentType.map((r) => ( + + ))} + + )} + + {allTargets.map((t) => ( + + ))} + + + + ); +} diff --git a/extensions/raycast/src/toggle-pause.tsx b/extensions/raycast/src/toggle-pause.tsx new file mode 100644 index 0000000000..0f6b40bce1 --- /dev/null +++ b/extensions/raycast/src/toggle-pause.tsx @@ -0,0 +1,8 @@ +import { createTogglePauseAction, executeCapAction } from "./utils"; + +export default async function Command() { + await executeCapAction(createTogglePauseAction(), { + feedbackMessage: "Toggling pause...", + feedbackType: "hud", + }); +} diff --git a/extensions/raycast/src/utils.ts b/extensions/raycast/src/utils.ts new file mode 100644 index 0000000000..0055b5dc23 --- /dev/null +++ b/extensions/raycast/src/utils.ts @@ -0,0 +1,283 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + closeMainWindow, + getApplications, + Keyboard, + open, + showHUD, + showToast, + Toast, +} from "@raycast/api"; + +const CAP_BUNDLE_ID = "so.cap.desktop"; +const CAP_DEV_BUNDLE_ID = "so.cap.desktop.dev"; +export const CAP_URL_SCHEME = "cap-desktop"; + +// Response file paths for both production and dev builds +const RESPONSE_FILE_PATHS = [ + join( + homedir(), + "Library", + "Application Support", + CAP_DEV_BUNDLE_ID, + "deeplink-response.json", + ), + join( + homedir(), + "Library", + "Application Support", + CAP_BUNDLE_ID, + "deeplink-response.json", + ), +]; + +export interface DeepLinkAction { + [key: string]: unknown; +} + +export async function capNotInstalled(showErrorToast = true): Promise { + const apps = await getApplications(); + const installed = apps.some( + (app) => + app.bundleId === CAP_BUNDLE_ID || app.bundleId === CAP_DEV_BUNDLE_ID, + ); + + if (!installed && showErrorToast) { + showToast({ + style: Toast.Style.Failure, + title: "Cap is not installed!", + primaryAction: { + title: "Install Cap", + shortcut: Keyboard.Shortcut.Common.Open, + onAction: () => { + open("https://cap.so/download"); + }, + }, + }); + } + + return !installed; +} + +export async function executeCapAction( + action: DeepLinkAction, + options?: { + feedbackMessage?: string; + feedbackType?: "toast" | "hud"; + closeWindow?: boolean; + }, +): Promise { + if (await capNotInstalled()) { + return false; + } + + const jsonValue = JSON.stringify(action); + const encodedValue = encodeURIComponent(jsonValue); + const url = `${CAP_URL_SCHEME}://action?value=${encodedValue}`; + + if (options?.closeWindow !== false) { + await closeMainWindow({ clearRootSearch: true }); + } + await open(url); + + if (options?.feedbackMessage) { + if (!options.feedbackType || options.feedbackType === "toast") { + showToast({ style: Toast.Style.Success, title: options.feedbackMessage }); + } else { + showHUD(options.feedbackMessage); + } + } + + return true; +} + +function clearResponseFile(): void { + for (const path of RESPONSE_FILE_PATHS) { + try { + if (existsSync(path)) { + unlinkSync(path); + } + } catch {} + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function readResponseFile(timeoutMs = 3000): Promise { + const startTime = Date.now(); + const pollInterval = 100; + + while (Date.now() - startTime < timeoutMs) { + for (const path of RESPONSE_FILE_PATHS) { + try { + if (existsSync(path)) { + const content = readFileSync(path, "utf-8"); + if (content.trim()) { + return JSON.parse(content) as T; + } + } + } catch {} + } + await sleep(pollInterval); + } + + return null; +} + +export async function executeCapActionWithResponse( + action: DeepLinkAction, + timeoutMs = 6000, +): Promise { + if (await capNotInstalled()) { + return null; + } + + // Clear any previous response + clearResponseFile(); + + const jsonValue = JSON.stringify(action); + const encodedValue = encodeURIComponent(jsonValue); + const url = `${CAP_URL_SCHEME}://action?value=${encodedValue}`; + + try { + execFileSync("open", ["-g", url], { stdio: "ignore" }); + } catch { + return null; + } + + return await readResponseFile(timeoutMs); +} + +export interface RecordingStatus { + is_recording: boolean; + is_paused: boolean; + recording_mode: string | null; +} + +export interface DeepLinkCamera { + name: string; + id: string; +} + +export interface DeepLinkScreen { + name: string; + id: string; +} + +export interface DeepLinkWindow { + name: string; + owner_name: string; +} + +export interface DeepLinkDevices { + cameras: DeepLinkCamera[]; + microphones: string[]; + screens: DeepLinkScreen[]; + windows: DeepLinkWindow[]; +} + +export type RecordingMode = "instant" | "studio"; + +export interface CaptureMode { + screen?: string; + window?: string; +} + +export function createStartRecordingAction( + captureMode: CaptureMode, + mode: RecordingMode = "instant", + options?: { + camera?: { DeviceID: string } | { ModelID: string } | null; + mic_label?: string | null; + capture_system_audio?: boolean; + }, +): DeepLinkAction { + return { + start_recording: { + capture_mode: captureMode, + camera: options?.camera ?? null, + mic_label: options?.mic_label ?? null, + capture_system_audio: options?.capture_system_audio ?? false, + mode, + }, + }; +} + +export function createStopRecordingAction(): DeepLinkAction { + return { stop_recording: null }; +} + +export function createPauseRecordingAction(): DeepLinkAction { + return { pause_recording: null }; +} + +export function createResumeRecordingAction(): DeepLinkAction { + return { resume_recording: null }; +} + +export function createTogglePauseAction(): DeepLinkAction { + return { toggle_pause_recording: null }; +} + +export function createRestartRecordingAction(): DeepLinkAction { + return { restart_recording: null }; +} + +export function createTakeScreenshotAction( + captureMode: CaptureMode, +): DeepLinkAction { + return { + take_screenshot: { + capture_mode: captureMode, + }, + }; +} + +export function createSetMicrophoneAction( + label: string | null, +): DeepLinkAction { + return { + set_microphone: { + label, + }, + }; +} + +export function createSetCameraAction( + id: { DeviceID: string } | { ModelID: string } | null, +): DeepLinkAction { + return { + set_camera: { + id, + }, + }; +} + +export function createListDevicesAction(): DeepLinkAction { + return { list_devices: null }; +} + +export function createGetStatusAction(): DeepLinkAction { + return { get_status: null }; +} + +export function createOpenSettingsAction(page?: string): DeepLinkAction { + return { + open_settings: { + page: page ?? null, + }, + }; +} + +export function createOpenEditorAction(projectPath: string): DeepLinkAction { + return { + open_editor: { + project_path: projectPath, + }, + }; +} diff --git a/extensions/raycast/tsconfig.json b/extensions/raycast/tsconfig.json new file mode 100644 index 0000000000..9c6f4df9e8 --- /dev/null +++ b/extensions/raycast/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Raycast Extension", + "compilerOptions": { + "lib": ["ES2023"], + "module": "ES2022", + "target": "ES2022", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 614993f77e..01dc46975d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - "apps/*" - "packages/*" + - "extensions/*" - "crates/tauri-plugin-*" - "infra" - "scripts/orgIdBackfill"