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
64 changes: 52 additions & 12 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
TogglePause,
TakeScreenshot {
capture_mode: CaptureMode,
},
SetCamera {
camera: Option<DeviceOrModelID>,
},
SetMicrophone {
mic_label: Option<String>,
},
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -104,6 +116,21 @@ impl TryFrom<&Url> for DeepLinkAction {
}
}

fn resolve_capture_target(capture_mode: CaptureMode) -> Result<ScreenCaptureTarget, String> {
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)),
Copy link

Choose a reason for hiding this comment

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

If clients send an empty screen name (Raycast pref “leave empty to use main display”), we currently error. Might be worth treating empty (and maybe "Main Display" for compatibility) as “pick a default display” so deeplinks work out of the box.

Suggested change
.ok_or_else(|| format!("No screen with name \"{}\"", &name)),
CaptureMode::Screen(name) => {
let match_name = !name.is_empty() && name != "Main Display";
let mut displays = cap_recording::screen_capture::list_displays().into_iter();
let display = if match_name {
displays.find(|(s, _)| s.name == name)
} else {
displays.next()
};
display
.map(|(s, _)| ScreenCaptureTarget::Display { id: s.id })
.ok_or_else(|| {
if match_name {
format!("No screen with name \"{}\"", &name)
} else {
"No displays found".to_string()
}
})
},

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 {
Expand All @@ -119,18 +146,7 @@ impl DeepLinkAction {
crate::set_camera_input(app.clone(), state.clone(), camera, None).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,
Expand All @@ -146,6 +162,30 @@ impl DeepLinkAction {
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::TogglePause => {
crate::recording::toggle_pause_recording(app.clone(), app.state()).await
}
DeepLinkAction::TakeScreenshot { capture_mode } => {
let capture_target = resolve_capture_target(capture_mode)?;

crate::recording::take_screenshot(app.clone(), capture_target)
.await
.map(|_| ())
}
DeepLinkAction::SetCamera { camera } => {
let state = app.state::<ArcLock<App>>();
crate::set_camera_input(app.clone(), state, camera, None).await
}
DeepLinkAction::SetMicrophone { mic_label } => {
let state = app.state::<ArcLock<App>>();
crate::set_mic_input(state, mic_label).await
}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand Down
44 changes: 44 additions & 0 deletions apps/raycast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Cap Raycast Extension

Control [Cap](https://cap.so) screen recording directly from [Raycast](https://raycast.com).

## Commands

| Command | Description |
|---------|-------------|
| Start Instant Recording | Start an instant screen recording |
| Start Studio Recording | Start a studio screen recording |
| Stop Recording | Stop the current recording |
| Toggle Pause Recording | Pause or resume the current recording |
| Take Screenshot | Take a screenshot |
| Open Settings | Open Cap settings |

## How It Works

This extension communicates with the Cap desktop app via deeplinks (`cap-desktop://action?value=...`). The Cap app must be running for commands to work.

## Supported Deeplink Actions

The following deeplink actions are available:

- `start_recording` — Start a recording (instant or studio mode)
- `stop_recording` — Stop the current recording
- `pause_recording` — Pause the current recording
- `resume_recording` — Resume a paused recording
- `toggle_pause` — Toggle pause/resume
- `take_screenshot` — Capture a screenshot
- `set_camera` — Switch camera input
- `set_microphone` — Switch microphone input
- `open_editor` — Open the editor for a project
- `open_settings` — Open the settings window

### Deeplink Format

```
cap-desktop://action?value=<url-encoded-json>
```

Example:
```
cap-desktop://action?value=%7B%22stop_recording%22%3A%7B%7D%7D
```
Binary file added apps/raycast/assets/cap-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
84 changes: 84 additions & 0 deletions apps/raycast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "cap",
"title": "Cap",
"description": "Control Cap screen recording from Raycast",
"icon": "cap-icon.png",
"author": "cap",
"categories": ["Productivity", "Applications"],
"license": "MIT",
"preferences": [
{
"name": "displayName",
"title": "Display Name",
"description": "Name of the display to capture (leave empty to use the main display)",
"type": "textfield",
"required": false,
"default": ""
}
],
"commands": [
{
"name": "start-instant-recording",
"title": "Start Instant Recording",
"subtitle": "Cap",
"description": "Start an instant screen recording with Cap",
"mode": "no-view"
},
{
"name": "start-studio-recording",
"title": "Start Studio Recording",
"subtitle": "Cap",
"description": "Start a studio screen recording with Cap",
"mode": "no-view"
},
{
"name": "stop-recording",
"title": "Stop Recording",
"subtitle": "Cap",
"description": "Stop the current Cap recording",
"mode": "no-view"
},
{
"name": "toggle-pause",
"title": "Toggle Pause Recording",
"subtitle": "Cap",
"description": "Pause or resume the current Cap recording",
"mode": "no-view"
},
{
"name": "take-screenshot",
"title": "Take Screenshot",
"subtitle": "Cap",
"description": "Take a screenshot with Cap",
"mode": "no-view"
},
{
"name": "open-settings",
"title": "Open Settings",
"subtitle": "Cap",
"description": "Open Cap settings",
"mode": "no-view"
}
],
"dependencies": {
"@raycast/api": "^1.93.2",
"@raycast/utils": "^1.19.1"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.14",
"@types/node": "22.13.14",
"@types/react": "19.0.12",
"eslint": "^9.23.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
},
"scripts": {
"build": "ray build",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint",
"prepublishOnly": "echo \"Error: no publish\" && exit 1",
"publish": "npx @raycast/api@latest publish"
}
}
8 changes: 8 additions & 0 deletions apps/raycast/src/open-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { executeDeepLink } from "./utils";

export default async function command() {
await executeDeepLink(
{ open_settings: { page: null } },
"Opening Cap settings...",
);
}
16 changes: 16 additions & 0 deletions apps/raycast/src/start-instant-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { executeDeepLink, getDisplayName } from "./utils";

export default async function command() {
await executeDeepLink(
{
start_recording: {
capture_mode: { screen: getDisplayName() },
camera: null,
mic_label: null,
capture_system_audio: false,
mode: "instant",
},
},
"Starting instant recording...",
);
}
16 changes: 16 additions & 0 deletions apps/raycast/src/start-studio-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { executeDeepLink, getDisplayName } from "./utils";

export default async function command() {
await executeDeepLink(
{
start_recording: {
capture_mode: { screen: getDisplayName() },
camera: null,
mic_label: null,
capture_system_audio: false,
mode: "studio",
},
},
"Starting studio recording...",
);
}
5 changes: 5 additions & 0 deletions apps/raycast/src/stop-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { executeDeepLink } from "./utils";

export default async function command() {
await executeDeepLink({ stop_recording: {} }, "Stopping recording...");
}
12 changes: 12 additions & 0 deletions apps/raycast/src/take-screenshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { executeDeepLink, getDisplayName } from "./utils";

export default async function command() {
await executeDeepLink(
{
take_screenshot: {
capture_mode: { screen: getDisplayName() },
},
},
"Taking screenshot...",
);
}
5 changes: 5 additions & 0 deletions apps/raycast/src/toggle-pause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { executeDeepLink } from "./utils";

export default async function command() {
await executeDeepLink({ toggle_pause: {} }, "Toggling pause...");
}
35 changes: 35 additions & 0 deletions apps/raycast/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { getPreferenceValues, open, showToast, Toast } from "@raycast/api";

const DEEPLINK_SCHEME = "cap-desktop";

interface Preferences {
displayName: string;
}

export function getDisplayName(): string {
const { displayName } = getPreferenceValues<Preferences>();
return displayName || "Main Display";
Copy link

Choose a reason for hiding this comment

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

package.json says leaving the preference empty should use the main display, but getDisplayName() defaults to the literal "Main Display". If you want empty to mean “default display”, I’d make the preference optional and return a trimmed string (possibly empty) here.

Suggested change
return displayName || "Main Display";
interface Preferences {
displayName?: string;
}
export function getDisplayName(): string {
const { displayName } = getPreferenceValues<Preferences>();
return displayName?.trim() ?? "";
}

}

export function buildDeepLink(action: Record<string, unknown>): string {
const json = JSON.stringify(action);
return `${DEEPLINK_SCHEME}://action?value=${encodeURIComponent(json)}`;
}

export async function executeDeepLink(
action: Record<string, unknown>,
successMessage: string,
): Promise<void> {
const url = buildDeepLink(action);

try {
await open(url);
await showToast({ style: Toast.Style.Success, title: successMessage });
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to communicate with Cap",
message: String(error),
});
}
}
19 changes: 19 additions & 0 deletions apps/raycast/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"declaration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",
"lib": ["ES2023"],
"module": "Node16",
"moduleResolution": "Node16",
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
},
"include": ["src/**/*", "env.d.ts"]
}