diff --git a/apps/desktop-gpui/src/deeplink.rs b/apps/desktop-gpui/src/deeplink.rs index dd338d1b2fd..3dccea4f510 100644 --- a/apps/desktop-gpui/src/deeplink.rs +++ b/apps/desktop-gpui/src/deeplink.rs @@ -124,6 +124,10 @@ pub enum DeepLinkAction { capture_system_audio: bool, mode: cap_recording::RecordingMode, }, + StartDefaultRecording { + #[serde(default)] + mode: Option, + }, StopRecording, #[cfg(debug_assertions)] PauseRecording, @@ -334,6 +338,18 @@ impl DeepLinkAction { // The name -> target resolution, verbatim from // `deeplink_actions.rs:200-231`. let capture_target: ScreenCaptureTarget = match capture_mode { + CaptureMode::Screen(name) if name == "default" || name == "primary" => { + ScreenCaptureTarget::Display { + id: scap_targets::Display::primary().id(), + } + } + CaptureMode::Screen(name) if name == "cursor" => { + ScreenCaptureTarget::Display { + id: scap_targets::Display::get_containing_cursor() + .unwrap_or_else(scap_targets::Display::primary) + .id(), + } + } CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays() .into_iter() .find(|(s, _)| s.name == name) @@ -442,6 +458,22 @@ impl DeepLinkAction { }); Ok(()) } + DeepLinkAction::StartDefaultRecording { mode } => { + let mode = match mode { + Some(cap_recording::RecordingMode::Studio) => { + Some(crate::main_window::Mode::Studio) + } + Some(cap_recording::RecordingMode::Instant) => { + Some(crate::main_window::Mode::Instant) + } + Some(cap_recording::RecordingMode::Screenshot) => { + return Err("Use take_screenshot for screenshots".to_string()); + } + None => None, + }; + crate::hotkeys::start_default_recording(mode, cx); + Ok(()) + } DeepLinkAction::StopRecording => { let main = cx.global::().main; let _ = main.update(cx, |view, _, cx| { @@ -831,6 +863,56 @@ mod tests { ); } + #[test] + fn parses_start_default_recording_action_urls() { + let default_url = Url::parse( + "cap-desktop://action?value=%7B%22start_default_recording%22%3A%7B%7D%7D", + ) + .unwrap(); + assert_eq!( + DeepLinkAction::try_from(&default_url), + Ok(DeepLinkAction::StartDefaultRecording { mode: None }) + ); + + let studio_url = Url::parse( + "cap-desktop://action?value=%7B%22start_default_recording%22%3A%7B%22mode%22%3A%22studio%22%7D%7D", + ) + .unwrap(); + assert_eq!( + DeepLinkAction::try_from(&studio_url), + Ok(DeepLinkAction::StartDefaultRecording { + mode: Some(cap_recording::RecordingMode::Studio), + }) + ); + } + + #[test] + fn parses_start_recording_action_url_default_and_cursor_screen() { + for screen_alias in ["default", "primary", "cursor"] { + let value = serde_json::json!({ + "start_recording": { + "capture_mode": { "screen": screen_alias }, + "camera": null, + "mic_label": null, + "capture_system_audio": true, + "mode": "instant" + } + }) + .to_string(); + let url = Url::parse_with_params("cap-desktop://action", &[("value", value)]).unwrap(); + assert_eq!( + DeepLinkAction::try_from(&url), + Ok(DeepLinkAction::StartRecording { + capture_mode: CaptureMode::Screen(screen_alias.to_string()), + camera: None, + mic_label: None, + capture_system_audio: true, + mode: cap_recording::RecordingMode::Instant, + }) + ); + } + } + #[test] fn parses_start_recording_action_url() { let url = Url::parse( diff --git a/apps/desktop-gpui/src/hotkeys.rs b/apps/desktop-gpui/src/hotkeys.rs index 7527717ae4a..bf7fd953540 100644 --- a/apps/desktop-gpui/src/hotkeys.rs +++ b/apps/desktop-gpui/src/hotkeys.rs @@ -1073,6 +1073,14 @@ fn microphone_available(name: &str) -> bool { /// where it was. Deliberate: this app's start path reads the window's mode, /// and a recording visibly in Studio mode with the pill saying Instant is the /// worse mismatch. +pub(crate) fn start_default_recording(mode: Option, cx: &mut App) { + let resolved_mode = match mode.unwrap_or_else(Mode::from_store) { + Mode::Studio => Mode::Studio, + Mode::Instant | Mode::Screenshot => Mode::Instant, + }; + start_from_hotkey(resolved_mode, cx); +} + fn start_from_hotkey(mode: Mode, cx: &mut App) { app_windows::set_recording_mode(mode, cx); let main = cx.global::().main; diff --git a/extensions/raycast/README.md b/extensions/raycast/README.md new file mode 100644 index 00000000000..eafc9229301 --- /dev/null +++ b/extensions/raycast/README.md @@ -0,0 +1,15 @@ +# Cap Raycast Extension + +Official Raycast extension for [Cap](https://cap.so) — the open-source Loom alternative. + +## Commands + +- **Start Recording:** Start a recording session using your saved Cap desktop defaults (target, camera, microphone, system audio, and mode), with an optional argument to override Instant or Studio mode. +- **Stop Recording:** Stop the active Cap recording session (automatically copies the shareable video link to the clipboard in Instant mode). +- **Open Dashboard:** Open your Cap web dashboard (respects self-hosted `serverUrl` configured in Cap desktop or Raycast preferences). +- **Open Cap Settings:** Open the Cap desktop settings window. + +## Prerequisites + +- [Cap Desktop App](https://cap.so/download) installed and running. +- [Raycast](https://raycast.com) installed. diff --git a/extensions/raycast/icon.png b/extensions/raycast/icon.png new file mode 100644 index 00000000000..718226caf20 Binary files /dev/null and b/extensions/raycast/icon.png differ diff --git a/extensions/raycast/package.json b/extensions/raycast/package.json new file mode 100644 index 00000000000..fe407ef5947 --- /dev/null +++ b/extensions/raycast/package.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "cap", + "title": "Cap", + "description": "Start and stop screen recordings with Cap and open your dashboard.", + "icon": "icon.png", + "author": "CapSoftware", + "categories": ["Productivity", "Media"], + "license": "MIT", + "preferences": [ + { + "name": "serverUrl", + "type": "textfield", + "required": false, + "title": "Cap Server URL", + "description": "Custom self-hosted Cap server URL (defaults to your Cap desktop setting or https://cap.so).", + "placeholder": "https://cap.so" + } + ], + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "description": "Start recording with your saved Cap default settings", + "mode": "no-view", + "arguments": [ + { + "name": "mode", + "type": "dropdown", + "placeholder": "Recording Mode", + "required": false, + "data": [ + { + "title": "Default (Saved in Cap)", + "value": "default" + }, + { + "title": "Instant Mode", + "value": "instant" + }, + { + "title": "Studio Mode", + "value": "studio" + } + ] + } + ] + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "description": "Stop the active Cap recording and copy the shareable link", + "mode": "no-view" + }, + { + "name": "open-dashboard", + "title": "Open Dashboard", + "description": "Open your Cap web dashboard", + "mode": "no-view" + }, + { + "name": "open-settings", + "title": "Open Cap Settings", + "description": "Open Cap desktop settings", + "mode": "no-view" + } + ], + "dependencies": { + "@raycast/api": "^1.88.0" + }, + "devDependencies": { + "@types/node": "20.8.10", + "@types/react": "18.2.27", + "typescript": "^5.4.5" + }, + "scripts": { + "build": "ray build -e dist", + "dev": "ray develop" + } +} diff --git a/extensions/raycast/src/open-dashboard.ts b/extensions/raycast/src/open-dashboard.ts new file mode 100644 index 00000000000..7add93c0a14 --- /dev/null +++ b/extensions/raycast/src/open-dashboard.ts @@ -0,0 +1,14 @@ +import { closeMainWindow, open, showHUD } from "@raycast/api"; +import { getConfiguredDashboardUrl } from "./utils"; + +export default async function Command() { + await closeMainWindow(); + + const dashboardUrl = getConfiguredDashboardUrl(); + try { + await open(dashboardUrl); + await showHUD("🌐 Opening Cap Dashboard..."); + } catch { + await showHUD("âš ī¸ Failed to open Cap Dashboard."); + } +} diff --git a/extensions/raycast/src/open-settings.ts b/extensions/raycast/src/open-settings.ts new file mode 100644 index 00000000000..51e087d161a --- /dev/null +++ b/extensions/raycast/src/open-settings.ts @@ -0,0 +1,13 @@ +import { closeMainWindow, showHUD } from "@raycast/api"; +import { triggerCapAction } from "./utils"; + +export default async function Command() { + await closeMainWindow(); + + try { + await triggerCapAction({ open_settings: { page: null } }); + await showHUD("âš™ī¸ Opening Cap Settings..."); + } catch { + await showHUD("âš ī¸ Failed to open Cap Settings."); + } +} diff --git a/extensions/raycast/src/start-recording.ts b/extensions/raycast/src/start-recording.ts new file mode 100644 index 00000000000..99c0357797e --- /dev/null +++ b/extensions/raycast/src/start-recording.ts @@ -0,0 +1,28 @@ +import { closeMainWindow, type LaunchProps, showHUD } from "@raycast/api"; +import { + buildStartDefaultRecordingAction, + type RecordingModeOverride, + triggerCapAction, +} from "./utils"; + +interface StartRecordingArguments { + mode?: RecordingModeOverride; +} + +export default async function Command( + props: LaunchProps<{ arguments: StartRecordingArguments }>, +) { + await closeMainWindow(); + + const mode = props.arguments?.mode; + const action = buildStartDefaultRecordingAction(mode); + + try { + await triggerCapAction(action); + await showHUD("đŸŽĨ Starting Cap recording..."); + } catch { + await showHUD( + "âš ī¸ Failed to trigger Cap recording. Make sure Cap is installed and running.", + ); + } +} diff --git a/extensions/raycast/src/stop-recording.ts b/extensions/raycast/src/stop-recording.ts new file mode 100644 index 00000000000..71ff9ab1caf --- /dev/null +++ b/extensions/raycast/src/stop-recording.ts @@ -0,0 +1,13 @@ +import { closeMainWindow, showHUD } from "@raycast/api"; +import { triggerCapAction } from "./utils"; + +export default async function Command() { + await closeMainWindow(); + + try { + await triggerCapAction("stop_recording"); + await showHUD("âšī¸ Stopping Cap recording (link copied in Instant mode)..."); + } catch { + await showHUD("âš ī¸ Failed to stop Cap recording."); + } +} diff --git a/extensions/raycast/src/utils.ts b/extensions/raycast/src/utils.ts new file mode 100644 index 00000000000..3d39dbbb108 --- /dev/null +++ b/extensions/raycast/src/utils.ts @@ -0,0 +1,102 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { getPreferenceValues, open } from "@raycast/api"; + +export type RecordingModeOverride = "default" | "instant" | "studio"; + +interface ExtensionPreferences { + serverUrl?: string; +} + +export function buildDeepLink( + actionPayload: unknown, + scheme: "cap-desktop" | "cap" = "cap-desktop", +): string { + const value = JSON.stringify(actionPayload); + return `${scheme}://action?value=${encodeURIComponent(value)}`; +} + +export function buildStartDefaultRecordingAction(mode?: RecordingModeOverride): { + start_default_recording: { mode?: "instant" | "studio" }; +} { + if (mode === "instant" || mode === "studio") { + return { start_default_recording: { mode } }; + } + return { start_default_recording: {} }; +} + +export async function triggerCapAction(actionPayload: unknown): Promise { + try { + await open(buildDeepLink(actionPayload, "cap-desktop")); + } catch { + await open(buildDeepLink(actionPayload, "cap")); + } +} + +export function resolveCapStorePath(): string { + const overridePath = process.env.CAP_GPUI_TAURI_STORE?.trim(); + if (overridePath) { + return overridePath; + } + const appDataOverride = process.env.CAP_GPUI_APP_DATA_DIR?.trim(); + if (appDataOverride) { + return join(appDataOverride, "store"); + } + if (process.platform === "darwin") { + return join( + homedir(), + "Library", + "Application Support", + "so.cap.desktop", + "store", + ); + } + if (process.platform === "win32") { + const appData = process.env.APPDATA || homedir(); + return join(appData, "so.cap.desktop", "store"); + } + const xdgData = + process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"); + return join(xdgData, "so.cap.desktop", "store"); +} + +export function readStoredServerUrl( + storePath = resolveCapStorePath(), +): string | undefined { + try { + const raw = readFileSync(storePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== "object" || parsed === null) { + return undefined; + } + const general = (parsed as Record).general_settings; + if (typeof general !== "object" || general === null) { + return undefined; + } + const serverUrl = (general as Record).serverUrl; + if (typeof serverUrl === "string" && serverUrl.trim().length > 0) { + return serverUrl.trim(); + } + } catch { + return undefined; + } + return undefined; +} + +export function resolveDashboardUrl( + preferenceServerUrl?: string, + storedServerUrl?: string, +): string { + const candidate = + preferenceServerUrl?.trim() || storedServerUrl?.trim() || "https://cap.so"; + const normalized = candidate.replace(/\/+$/, ""); + return normalized.endsWith("/dashboard") + ? normalized + : `${normalized}/dashboard`; +} + +export function getConfiguredDashboardUrl(): string { + const preferences = getPreferenceValues(); + return resolveDashboardUrl(preferences.serverUrl, readStoredServerUrl()); +} diff --git a/extensions/raycast/tsconfig.json b/extensions/raycast/tsconfig.json new file mode 100644 index 00000000000..b1d2ad2c3ac --- /dev/null +++ b/extensions/raycast/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "lib": ["ES2023"], + "module": "commonjs", + "target": "ES2022", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"] +}