Repository navigation
feat(raycast): add official Cap Raycast extension with saved user defaults (#28) #4149
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -124,6 +124,10 @@ pub enum DeepLinkAction { | |
| capture_system_audio: bool, | ||
| mode: cap_recording::RecordingMode, | ||
| }, | ||
| StartDefaultRecording { | ||
| #[serde(default)] | ||
| mode: Option<cap_recording::RecordingMode>, | ||
| }, | ||
| 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::<app_windows::AppWindows>().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()), | ||
|
Comment on lines
+903
to
+906
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Extract display selection into a testable helper and check primary selection, cursor selection, and the cursor fallback. Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/desktop-gpui/src/deeplink.rs
Line: 903-906
Comment:
**Tests miss display selection**
`parses_start_recording_action_url_default_and_cursor_screen` only checks that alias strings parse. `CaptureMode::Screen(String)` already accepts arbitrary strings, so this test would pass without the new alias-selection code. It cannot catch a change that records the wrong display.
Extract display selection into a testable helper and check primary selection, cursor selection, and the cursor fallback.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| 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( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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."); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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."); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.", | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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."); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| 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<string, unknown>).general_settings; | ||
| if (typeof general !== "object" || general === null) { | ||
| return undefined; | ||
| } | ||
| const serverUrl = (general as Record<string, unknown>).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`; | ||
|
Comment on lines
+93
to
+96
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Desktop settings already reduce pasted URLs to their HTTP or HTTPS origin. Parse the preference the same way before adding the dashboard path. Prompt To Fix With AIThis is a comment left during a code review.
Path: extensions/raycast/src/utils.ts
Line: 93-96
Comment:
**Dashboard URL keeps pasted paths**
`resolveDashboardUrl` appends `/dashboard` to the raw preference, including any query or fragment. For example, `https://host/dashboard?x=1` becomes `https://host/dashboard?x=1/dashboard`. Pasted paths can also send users to the wrong page.
Desktop settings already reduce pasted URLs to their HTTP or HTTPS origin. Parse the preference the same way before adding the dashboard path.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| } | ||
|
|
||
| export function getConfiguredDashboardUrl(): string { | ||
| const preferences = getPreferenceValues<ExtensionPreferences>(); | ||
| return resolveDashboardUrl(preferences.serverUrl, readStoredServerUrl()); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
StartDefaultRecordingwaits for target lookup and microphone checks before creating aRecordingStartPermit. IfStopRecordingarrives during that wait, there is no permit to cancel and the session stays idle. The waiting task can then finish and start recording after the user asked to stop.Create a cancellable start request before the first wait, and check it before showing prompts or starting capture.
Knowledge Base Used:
Prompt To Fix With AI