diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a117028487..0d184fa5a9 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -1,3 +1,15 @@ +// Fix for Issue #1540 - Deep Links & Raycast Support +// +// Extends the existing DeepLinkAction enum with: +// - PauseRecording +// - ResumeRecording +// - TogglePauseRecording +// - SwitchMicrophone { label } +// - SwitchCamera { id } +// +// All new actions use idiomatic Rust error handling with `?`. +// No .unwrap() calls anywhere in this file. + use cap_recording::{ RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget, }; @@ -8,16 +20,26 @@ use tracing::trace; use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] +// --------------------------------------------------------------------------- +// CaptureMode helper +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub enum CaptureMode { Screen(String), Window(String), } -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] +// --------------------------------------------------------------------------- +// The main action enum — all variants are (de)serializable from JSON so the +// URL parser (`TryFrom<&Url>`) can hydrate them from ?value=. +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", tag = "type")] pub enum DeepLinkAction { + /// Start a new recording session. StartRecording { capture_mode: CaptureMode, camera: Option, @@ -25,17 +47,46 @@ pub enum DeepLinkAction { capture_system_audio: bool, mode: RecordingMode, }, + + /// Stop the active recording session. StopRecording, + + /// Pause the active recording. Returns an error if no recording is active. + PauseRecording, + + /// Resume a paused recording. Returns an error if recording is not paused. + ResumeRecording, + + /// Toggle between paused and recording states. + TogglePauseRecording, + + /// Switch the active microphone. Pass `None` to mute/disable the mic. + SwitchMicrophone { + label: Option, + }, + + /// Switch the active camera. Pass `None` to disable the camera. + SwitchCamera { + id: Option, + }, + + /// Open the Cap editor for a given project path. OpenEditor { project_path: PathBuf, }, + + /// Navigate to a Settings page. OpenSettings { page: Option, }, } +// --------------------------------------------------------------------------- +// URL → Action parsing +// --------------------------------------------------------------------------- + pub fn handle(app_handle: &AppHandle, urls: Vec) { - trace!("Handling deep actions for: {:?}", &urls); + trace!("Handling deep link actions for: {:?}", &urls); let actions: Vec<_> = urls .into_iter() @@ -49,7 +100,7 @@ pub fn handle(app_handle: &AppHandle, urls: Vec) { ActionParseFromUrlError::Invalid => { eprintln!("Invalid deep link format \"{}\"", &url) } - // Likely login action, not handled here. + // Likely a login/auth action — handled elsewhere. ActionParseFromUrlError::NotAction => {} }) .ok() @@ -70,6 +121,10 @@ pub fn handle(app_handle: &AppHandle, urls: Vec) { }); } +// --------------------------------------------------------------------------- +// Parse error types +// --------------------------------------------------------------------------- + pub enum ActionParseFromUrlError { ParseFailed(String), Invalid, @@ -80,6 +135,7 @@ impl TryFrom<&Url> for DeepLinkAction { type Error = ActionParseFromUrlError; fn try_from(url: &Url) -> Result { + // On macOS, a .cap file opened from Finder arrives as a file:// URL. #[cfg(target_os = "macos")] if url.scheme() == "file" { return url @@ -88,26 +144,38 @@ impl TryFrom<&Url> for DeepLinkAction { .map_err(|_| ActionParseFromUrlError::Invalid); } + // All programmatic deep links use the "action" domain: + // cap-desktop://action?value= match url.domain() { - Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction), - _ => Err(ActionParseFromUrlError::Invalid), - }?; + Some(v) if v != "action" => return Err(ActionParseFromUrlError::NotAction), + _ => {} + }; 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) } } +// --------------------------------------------------------------------------- +// Action execution +// --------------------------------------------------------------------------- + impl DeepLinkAction { pub async fn execute(self, app: &AppHandle) -> Result<(), String> { match self { + // ---------------------------------------------------------------- + // Start Recording + // ---------------------------------------------------------------- DeepLinkAction::StartRecording { capture_mode, camera, @@ -125,12 +193,12 @@ impl DeepLinkAction { .into_iter() .find(|(s, _)| s.name == name) .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id }) - .ok_or(format!("No screen with name \"{}\"", &name))?, + .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(format!("No window with name \"{}\"", &name))?, + .ok_or_else(|| format!("No window with name \"{}\"", &name))?, }; let inputs = StartRecordingInputs { @@ -144,12 +212,124 @@ impl DeepLinkAction { .await .map(|_| ()) } + + // ---------------------------------------------------------------- + // Stop Recording + // ---------------------------------------------------------------- DeepLinkAction::StopRecording => { crate::recording::stop_recording(app.clone(), app.state()).await } + + // ---------------------------------------------------------------- + // Pause Recording + // ---------------------------------------------------------------- + DeepLinkAction::PauseRecording => { + let state = app.state::>(); + let app_lock = state.read().await; + + let recording = app_lock + .current_recording() + .ok_or_else(|| "No active recording to pause".to_string())?; + + recording + .pause() + .await + .map_err(|e| format!("Failed to pause recording: {e}"))?; + + crate::recording::RecordingEvent::Paused.emit(app).ok(); + Ok(()) + } + + // ---------------------------------------------------------------- + // Resume Recording + // ---------------------------------------------------------------- + DeepLinkAction::ResumeRecording => { + let state = app.state::>(); + let app_lock = state.read().await; + + let recording = app_lock + .current_recording() + .ok_or_else(|| "No active recording to resume".to_string())?; + + let is_paused = recording + .is_paused() + .await + .map_err(|e| format!("Failed to query pause state: {e}"))?; + + if !is_paused { + return Err("Recording is not currently paused".to_string()); + } + + recording + .resume() + .await + .map_err(|e| format!("Failed to resume recording: {e}"))?; + + crate::recording::RecordingEvent::Resumed.emit(app).ok(); + Ok(()) + } + + // ---------------------------------------------------------------- + // Toggle Pause / Resume + // ---------------------------------------------------------------- + DeepLinkAction::TogglePauseRecording => { + let state = app.state::>(); + let app_lock = state.read().await; + + let recording = app_lock + .current_recording() + .ok_or_else(|| "No active recording".to_string())?; + + let is_paused = recording + .is_paused() + .await + .map_err(|e| format!("Failed to query pause state: {e}"))?; + + if is_paused { + recording + .resume() + .await + .map_err(|e| format!("Failed to resume recording: {e}"))?; + + crate::recording::RecordingEvent::Resumed.emit(app).ok(); + Ok(()) + } else { + recording + .pause() + .await + .map_err(|e| format!("Failed to pause recording: {e}"))?; + + crate::recording::RecordingEvent::Paused.emit(app).ok(); + Ok(()) + } + } + + // ---------------------------------------------------------------- + // Switch Microphone + // ---------------------------------------------------------------- + DeepLinkAction::SwitchMicrophone { label } => { + let state = app.state::>(); + crate::set_mic_input(state, label).await + } + + // ---------------------------------------------------------------- + // Switch Camera + // ---------------------------------------------------------------- + DeepLinkAction::SwitchCamera { id } => { + let state = app.state::>(); + crate::set_camera_input(app.clone(), state, id, None).await + } + + // ---------------------------------------------------------------- + // Open Editor + // ---------------------------------------------------------------- DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } + + // ---------------------------------------------------------------- + // Open Settings + // ---------------------------------------------------------------- DeepLinkAction::OpenSettings { page } => { crate::show_window(app.clone(), ShowCapWindow::Settings { page }).await } diff --git a/extensions/raycast/README.md b/extensions/raycast/README.md new file mode 100644 index 0000000000..5a795fca1d --- /dev/null +++ b/extensions/raycast/README.md @@ -0,0 +1,71 @@ +# Cap — Raycast Extension + + +Control your [Cap](https://cap.so) screen recording sessions directly from Raycast — no mouse required. + +## Commands + +| Command | Description | +|---|---| +| **Cap: Recording Controls** | Start, stop, pause, resume, or toggle your recording session | +| **Cap: Switch Input Device** | Switch the active microphone or camera | + +## Requirements + +- **Cap for macOS** installed from [cap.so](https://cap.so) +- A **Cap API key** (only required for the device switcher — find it in Cap Settings → Developer) + +## How It Works + +Both commands build a `cap-desktop://action?value=` deep link and call `open()` to hand off control to the Cap desktop app. Cap handles all state transitions; this extension stays stateless. + +### URL Schema + +``` +cap-desktop://action?value= +``` + +| Action | JSON | +|---|---| +| Start Recording | `{"type":"startRecording","captureMode":{"screen":"Built-in Display"},...}` | +| Stop Recording | `{"type":"stopRecording"}` | +| Pause Recording | `{"type":"pauseRecording"}` | +| Resume Recording | `{"type":"resumeRecording"}` | +| Toggle Pause | `{"type":"togglePauseRecording"}` | +| Switch Microphone | `{"type":"switchMicrophone","label":"MacBook Pro Microphone"}` | +| Switch Camera | `{"type":"switchCamera","id":""}` | +| Disable Microphone | `{"type":"switchMicrophone","label":null}` | +| Disable Camera | `{"type":"switchCamera","id":null}` | + +## Setup + +### Cap: Recording Controls +No setup required. Just invoke the command and select an action. + +### Cap: Switch Input Device +1. Open the command in Raycast. +2. On first use you'll be prompted to enter your Cap API key. +3. The key is stored in Raycast's encrypted local storage — never sent anywhere except the Cap API. +4. Select a microphone or camera to switch. Cap will activate the chosen device immediately. + +## Security + +- API keys are stored in **Raycast's `LocalStorage`** (encrypted, sandboxed per extension). +- No credentials are hard-coded or logged. +- Deep links only communicate with the locally running Cap app. + +## Development + +```bash +cd extensions/raycast +npm install +npm run dev # Hot-reload development mode +npm run build # Production build +npm run lint # ESLint check +``` + +## Related + +- [Cap GitHub Repository](https://github.com/CapSoftware/Cap) +- [Issue #1540 — Bounty: Deeplinks support + Raycast Extension](https://github.com/CapSoftware/Cap/issues/1540) +- [Raycast Developer Documentation](https://developers.raycast.com) diff --git a/extensions/raycast/package.json b/extensions/raycast/package.json new file mode 100644 index 0000000000..5cd2a967a0 --- /dev/null +++ b/extensions/raycast/package.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap-recording", + "title": "Cap", + "description": "Control Cap screen recording via deep links. Start, stop, pause, resume, and switch input devices without leaving your keyboard.", + "icon": "cap-icon.png", + "author": "cap-community", + "license": "MIT", + "categories": ["Productivity", "Media"], + "commands": [ + { + "name": "cap-control", + "title": "Cap: Recording Controls", + "subtitle": "Cap", + "description": "Start, stop, pause, resume, or toggle your Cap recording session.", + "mode": "view" + }, + { + "name": "switch-device", + "title": "Cap: Switch Input Device", + "subtitle": "Cap", + "description": "Switch the active microphone or camera used by Cap.", + "mode": "view" + } + ], + "dependencies": { + "@raycast/api": "^1.79.0", + "@raycast/utils": "^1.17.0" + }, + "devDependencies": { + "@raycast/eslint-config": "^1.0.11", + "@types/node": "^22.0.0", + "@types/react": "^18.3.3", + "eslint": "^8.57.0", + "prettier": "^3.3.3", + "typescript": "^5.4.5" + }, + "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/extensions/raycast/src/cap-control.tsx b/extensions/raycast/src/cap-control.tsx new file mode 100644 index 0000000000..fa0287da25 --- /dev/null +++ b/extensions/raycast/src/cap-control.tsx @@ -0,0 +1,232 @@ +// Fix for Issue #1540 - Deep Links & Raycast Support +// +// Command: cap-control +// Purpose: Provides keyboard-driven recording controls for Cap desktop app. +// +// Actions available: +// - Start Recording (Studio or Instant mode) +// - Stop Recording +// - Pause Recording +// - Resume Recording +// - Toggle Pause / Resume +// +// All actions open a `cap-desktop://action?value=` deep link so Cap +// handles the actual state transition. This keeps the extension stateless. + +import { + Action, + ActionPanel, + Alert, + Color, + Icon, + List, + Toast, + confirmAlert, + open, + showToast, +} from "@raycast/api"; + +// --------------------------------------------------------------------------- +// Deep-link builder — the single source of truth for the URL schema. +// cap-desktop://action?value= +// --------------------------------------------------------------------------- + +function buildDeepLink(action: CapAction): string { + const json = JSON.stringify(action); + return `cap-desktop://action?value=${encodeURIComponent(json)}`; +} + +async function sendAction(action: CapAction, successMessage: string): Promise { + const url = buildDeepLink(action); + try { + await open(url); + await showToast({ + style: Toast.Style.Success, + title: "Cap", + message: successMessage, + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Cap — Action Failed", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +// --------------------------------------------------------------------------- +// Discriminated union for every supported action. +// Must match the `DeepLinkAction` enum in deeplink_actions.rs. +// --------------------------------------------------------------------------- + +type CapAction = + | { type: "startRecording"; captureMode: CaptureMode; camera: null; micLabel: null; captureSystemAudio: boolean; mode: "studio" | "instant" } + | { type: "stopRecording" } + | { type: "pauseRecording" } + | { type: "resumeRecording" } + | { type: "togglePauseRecording" } + | { type: "switchMicrophone"; label: string | null } + | { type: "switchCamera"; id: string | null }; + +type CaptureMode = { screen: string } | { window: string }; + +// --------------------------------------------------------------------------- +// Recording control items shown in the list. +// --------------------------------------------------------------------------- + +interface ControlItem { + id: string; + title: string; + subtitle: string; + icon: { source: Icon; tintColor: Color }; + keywords: string[]; + action: CapAction; + confirmTitle?: string; + confirmMessage?: string; + successMessage: string; +} + +const CONTROL_ITEMS: ControlItem[] = [ + { + id: "start-studio", + title: "Start Recording", + subtitle: "Studio mode — primary display", + icon: { source: Icon.Circle, tintColor: Color.Red }, + keywords: ["start", "record", "studio", "begin"], + action: { + type: "startRecording", + captureMode: { screen: "Built-in Display" }, + camera: null, + micLabel: null, + captureSystemAudio: false, + mode: "studio", + }, + successMessage: "Starting Cap recording…", + }, + { + id: "start-instant", + title: "Start Instant Recording", + subtitle: "Instant mode — primary display", + icon: { source: Icon.Bolt, tintColor: Color.Orange }, + keywords: ["start", "instant", "quick", "record"], + action: { + type: "startRecording", + captureMode: { screen: "Built-in Display" }, + camera: null, + micLabel: null, + captureSystemAudio: false, + mode: "instant", + }, + successMessage: "Starting Cap instant recording…", + }, + { + id: "stop", + title: "Stop Recording", + subtitle: "End the current session", + icon: { source: Icon.Stop, tintColor: Color.Red }, + keywords: ["stop", "end", "finish", "record"], + action: { type: "stopRecording" }, + confirmTitle: "Stop Recording?", + confirmMessage: "This will end your current Cap recording session.", + successMessage: "Stopping recording…", + }, + { + id: "pause", + title: "Pause Recording", + subtitle: "Temporarily pause the session", + icon: { source: Icon.Pause, tintColor: Color.Yellow }, + keywords: ["pause", "hold"], + action: { type: "pauseRecording" }, + successMessage: "Pausing recording…", + }, + { + id: "resume", + title: "Resume Recording", + subtitle: "Continue the paused session", + icon: { source: Icon.Play, tintColor: Color.Green }, + keywords: ["resume", "continue", "unpause", "play"], + action: { type: "resumeRecording" }, + successMessage: "Resuming recording…", + }, + { + id: "toggle", + title: "Toggle Pause / Resume", + subtitle: "Flip the current pause state", + icon: { source: Icon.ArrowClockwise, tintColor: Color.Blue }, + keywords: ["toggle", "pause", "resume", "flip"], + action: { type: "togglePauseRecording" }, + successMessage: "Toggling pause state…", + }, +]; + +// --------------------------------------------------------------------------- +// Main command component +// --------------------------------------------------------------------------- + +export default function CapControlCommand() { + async function handleItem(item: ControlItem) { + if (item.confirmTitle && item.confirmMessage) { + const confirmed = await confirmAlert({ + title: item.confirmTitle, + message: item.confirmMessage, + primaryAction: { + title: "Confirm", + style: Alert.ActionStyle.Destructive, + }, + }); + if (!confirmed) return; + } + await sendAction(item.action, item.successMessage); + } + + return ( + + + {CONTROL_ITEMS.map((item) => ( + + handleItem(item)} + /> + + + } + /> + ))} + + + + + + + } + /> + + + ); +} diff --git a/extensions/raycast/src/switch-device.tsx b/extensions/raycast/src/switch-device.tsx new file mode 100644 index 0000000000..7a8571c91e --- /dev/null +++ b/extensions/raycast/src/switch-device.tsx @@ -0,0 +1,416 @@ +// Fix for Issue #1540 - Deep Links & Raycast Support +// +// Command: switch-device +// Purpose: List available microphones and cameras, then switch Cap's active +// input device by sending a deep link to the desktop app. +// +// Security: +// - The Cap API key is stored in Raycast's LocalStorage, never hard-coded. +// - If no key is stored the user is prompted to enter one. +// - Device lists are fetched from the Cap API on demand. +// +// Deep links used: +// SwitchMicrophone: cap-desktop://action?value={"type":"switchMicrophone","label":""} +// SwitchCamera: cap-desktop://action?value={"type":"switchCamera","id":""} + +import { + Action, + ActionPanel, + Color, + Form, + Icon, + List, + LocalStorage, + Toast, + open, + showToast, + useNavigation, +} from "@raycast/api"; +import { useEffect, useState } from "react"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const API_KEY_STORAGE_KEY = "cap_api_key"; +const CAP_API_BASE = "https://api.cap.so/v1"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface MicDevice { + kind: "microphone"; + label: string; + deviceId: string; +} + +interface CameraDevice { + kind: "camera"; + label: string; + deviceId: string; + modelId?: string; +} + +type InputDevice = MicDevice | CameraDevice; + +// We match the Rust enum tag names exactly. +type SwitchMicrophoneAction = { type: "switchMicrophone"; label: string | null }; +type SwitchCameraAction = { type: "switchCamera"; id: string | null }; + +// --------------------------------------------------------------------------- +// Deep link builder (mirrors cap-control.tsx — kept local to avoid coupling) +// --------------------------------------------------------------------------- + +function buildDeepLink(action: SwitchMicrophoneAction | SwitchCameraAction): string { + return `cap-desktop://action?value=${encodeURIComponent(JSON.stringify(action))}`; +} + +async function sendSwitch(device: InputDevice): Promise { + const action: SwitchMicrophoneAction | SwitchCameraAction = + device.kind === "microphone" + ? { type: "switchMicrophone", label: device.label } + : { type: "switchCamera", id: device.deviceId }; + + const url = buildDeepLink(action); + + try { + await open(url); + await showToast({ + style: Toast.Style.Success, + title: `Cap — Switched ${device.kind === "microphone" ? "Microphone" : "Camera"}`, + message: device.label, + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Cap — Switch Failed", + message: error instanceof Error ? error.message : String(error), + }); + } +} + +// --------------------------------------------------------------------------- +// API helpers (fetches device lists from Cap's API using the stored key) +// --------------------------------------------------------------------------- + +async function getStoredApiKey(): Promise { + try { + return await LocalStorage.getItem(API_KEY_STORAGE_KEY); + } catch { + return undefined; + } +} + +async function fetchDevices(apiKey: string): Promise { + const headers = { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }; + + const [micsRes, camsRes] = await Promise.all([ + fetch(`${CAP_API_BASE}/devices/microphones`, { headers }), + fetch(`${CAP_API_BASE}/devices/cameras`, { headers }), + ]); + + if (!micsRes.ok || !camsRes.ok) { + throw new Error( + `API error: microphones=${micsRes.status} cameras=${camsRes.status}` + ); + } + + interface ApiMic { + label: string; + deviceId: string; + } + interface ApiCam { + label: string; + deviceId: string; + modelId?: string; + } + + const micsJson: { data: ApiMic[] } = await micsRes.json(); + const camsJson: { data: ApiCam[] } = await camsRes.json(); + + const mics: MicDevice[] = (micsJson.data ?? []).map((m) => ({ + kind: "microphone" as const, + label: m.label, + deviceId: m.deviceId, + })); + + const cams: CameraDevice[] = (camsJson.data ?? []).map((c) => ({ + kind: "camera" as const, + label: c.label, + deviceId: c.deviceId, + modelId: c.modelId, + })); + + return [...mics, ...cams]; +} + +// --------------------------------------------------------------------------- +// API Key setup form — shown when no key is stored +// --------------------------------------------------------------------------- + +interface ApiKeyFormProps { + onSaved: (key: string) => void; +} + +function ApiKeyForm({ onSaved }: ApiKeyFormProps) { + const [apiKey, setApiKey] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + async function handleSubmit() { + const trimmed = apiKey.trim(); + if (!trimmed) { + await showToast({ style: Toast.Style.Failure, title: "API key cannot be empty" }); + return; + } + setIsLoading(true); + try { + await LocalStorage.setItem(API_KEY_STORAGE_KEY, trimmed); + await showToast({ style: Toast.Style.Success, title: "API key saved" }); + onSaved(trimmed); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to save API key", + message: error instanceof Error ? error.message : String(error), + }); + } finally { + setIsLoading(false); + } + } + + return ( +
+ + + } + > + + + + ); +} + +// --------------------------------------------------------------------------- +// Main command component +// --------------------------------------------------------------------------- + +export default function SwitchDeviceCommand() { + const { push } = useNavigation(); + const [isLoading, setIsLoading] = useState(true); + const [devices, setDevices] = useState([]); + const [errorMessage, setErrorMessage] = useState(null); + + async function loadDevices(key?: string) { + setIsLoading(true); + setErrorMessage(null); + try { + const storedKey = key ?? (await getStoredApiKey()); + + if (!storedKey) { + // Navigate to the API key form — when saved it calls loadDevices again. + push( loadDevices(k)} />); + return; + } + + const fetched = await fetchDevices(storedKey); + setDevices(fetched); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + setErrorMessage(msg); + await showToast({ + style: Toast.Style.Failure, + title: "Cap — Failed to load devices", + message: msg, + }); + } finally { + setIsLoading(false); + } + } + + useEffect(() => { + loadDevices(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const microphones = devices.filter((d): d is MicDevice => d.kind === "microphone"); + const cameras = devices.filter((d): d is CameraDevice => d.kind === "camera"); + + async function handleDisableMic() { + const action: SwitchMicrophoneAction = { type: "switchMicrophone", label: null }; + await open(buildDeepLink(action)); + await showToast({ style: Toast.Style.Success, title: "Cap — Microphone disabled" }); + } + + async function handleDisableCam() { + const action: SwitchCameraAction = { type: "switchCamera", id: null }; + await open(buildDeepLink(action)); + await showToast({ style: Toast.Style.Success, title: "Cap — Camera disabled" }); + } + + return ( + + {errorMessage && ( + + loadDevices()} /> + { + await LocalStorage.removeItem(API_KEY_STORAGE_KEY); + await loadDevices(); + }} + /> + + } + /> + )} + + {/* ------------------------------------------------------------------ */} + {/* Microphones */} + {/* ------------------------------------------------------------------ */} + + {microphones.map((mic) => ( + + sendSwitch(mic)} + /> + + + } + /> + ))} + + + + + } + /> + + + {/* ------------------------------------------------------------------ */} + {/* Cameras */} + {/* ------------------------------------------------------------------ */} + + {cameras.map((cam) => ( + + sendSwitch(cam)} + /> + + + } + /> + ))} + + + + + } + /> + + + {/* ------------------------------------------------------------------ */} + {/* Settings */} + {/* ------------------------------------------------------------------ */} + + + loadDevices()} /> + + } + /> + + { + await LocalStorage.removeItem(API_KEY_STORAGE_KEY); + await loadDevices(); + }} + /> + + } + /> + + + ); +} diff --git a/extensions/raycast/tsconfig.json b/extensions/raycast/tsconfig.json new file mode 100644 index 0000000000..76fc9e32d9 --- /dev/null +++ b/extensions/raycast/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "include": ["src"], + "compilerOptions": { + "lib": ["ES2022"], + "module": "CommonJS", + "target": "ES2022", + "jsx": "react-jsx", + "strict": true, + "noImplicitAny": true, + "useUnknownInCatchVariables": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + } +}