Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions apps/desktop-gpui/src/deeplink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Recording starts after Stop

StartDefaultRecording waits for target lookup and microphone checks before creating a RecordingStartPermit. If StopRecording arrives 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
This is a comment left during a code review.
Path: apps/desktop-gpui/src/deeplink.rs
Line: 474

Comment:
**Recording starts after Stop**

`StartDefaultRecording` waits for target lookup and microphone checks before creating a `RecordingStartPermit`. If `StopRecording` arrives 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:**
- [GPUI desktop client](https://app.greptile.com/-/switch-tenant?tenant=cap&next=%2Fcustom-context%2Fknowledge-base%2Fcapsoftware%2Fcap%2F-%2Fdocs%2Fgpui-desktop-client.md)
- [Desktop application and recording experience](https://app.greptile.com/-/switch-tenant?tenant=cap&next=%2Fcustom-context%2Fknowledge-base%2Fcapsoftware%2Fcap%2F-%2Fdocs%2Fdesktop-application.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Ok(())
}
DeepLinkAction::StopRecording => {
let main = cx.global::<app_windows::AppWindows>().main;
let _ = main.update(cx, |view, _, cx| {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Prompt To Fix With AI
This 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(
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop-gpui/src/hotkeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mode>, 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::<app_windows::AppWindows>().main;
Expand Down
15 changes: 15 additions & 0 deletions extensions/raycast/README.md
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.
Binary file added extensions/raycast/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
80 changes: 80 additions & 0 deletions extensions/raycast/package.json
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"
}
}
14 changes: 14 additions & 0 deletions extensions/raycast/src/open-dashboard.ts
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.");
}
}
13 changes: 13 additions & 0 deletions extensions/raycast/src/open-settings.ts
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.");
}
}
28 changes: 28 additions & 0 deletions extensions/raycast/src/start-recording.ts
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.",
);
}
}
13 changes: 13 additions & 0 deletions extensions/raycast/src/stop-recording.ts
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.");
}
}
102 changes: 102 additions & 0 deletions extensions/raycast/src/utils.ts
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Prompt To Fix With AI
This 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());
}
Loading