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
184 changes: 171 additions & 13 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,48 @@ use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Url};
use tracing::trace;

use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow};
use crate::{
App, ArcLock, feeds::microphone::MicrophoneFeed, recording::StartRecordingInputs,
windows::ShowCapWindow,
};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct DeepLinkRecordingStatus {
pub is_recording: bool,
pub is_paused: bool,
pub recording_mode: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct DeepLinkDevices {
pub cameras: Vec<DeepLinkCamera>,
pub microphones: Vec<String>,
pub screens: Vec<DeepLinkScreen>,
pub windows: Vec<DeepLinkWindow>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct DeepLinkCamera {
pub name: String,
pub id: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct DeepLinkScreen {
pub name: String,
pub id: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct DeepLinkWindow {
pub name: String,
pub owner_name: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
Expand All @@ -26,6 +67,21 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
TogglePauseRecording,
RestartRecording,
TakeScreenshot {
capture_mode: CaptureMode,
},
SetMicrophone {
label: Option<String>,
},
SetCamera {
id: Option<DeviceOrModelID>,
},
ListDevices,
GetStatus,
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -104,6 +160,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)),
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 +190,7 @@ impl DeepLinkAction {
crate::set_camera_input(app.clone(), state.clone(), camera).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 +206,70 @@ 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::TogglePauseRecording => {
crate::recording::toggle_pause_recording(app.clone(), app.state()).await
}
DeepLinkAction::RestartRecording => {
crate::recording::restart_recording(app.clone(), app.state())
.await
.map(|_| ())
}
DeepLinkAction::TakeScreenshot { capture_mode } => {
let capture_target = resolve_capture_target(&capture_mode)?;

crate::recording::take_screenshot(app.clone(), capture_target)
.await
.map(|_| ())
}
DeepLinkAction::SetMicrophone { label } => {
let state = app.state::<ArcLock<App>>();
crate::set_mic_input(state, label).await
}
DeepLinkAction::SetCamera { id } => {
let state = app.state::<ArcLock<App>>();
crate::set_camera_input(app.clone(), state, id).await
}
DeepLinkAction::ListDevices => {
let devices = get_available_devices();
let json = serde_json::to_string(&devices).map_err(|e| e.to_string())?;
println!("CAP_DEEPLINK_RESPONSE:{}", json);
Copy link

Choose a reason for hiding this comment

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

list_devices/get_status currently print the JSON response to stdout. When invoked via a URL scheme there usually isn’t a stdout consumer, so these responses may never be observable from Raycast. If you need Raycast to read the response, consider a transport it can actually access (clipboard, file, notification, or callback URL).

Ok(())
Comment on lines +238 to +242
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: ListDevices and GetStatus actions print JSON to stdout, but there's no mechanism in the Raycast extension to capture this output. The deeplink protocol is fire-and-forget via URL scheme, so the extension cannot receive the response. Should these actions use IPC commands instead of deeplinks, or is there a planned mechanism for capturing stdout from the deeplink handler?

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 238:242

Comment:
**logic:** `ListDevices` and `GetStatus` actions print JSON to stdout, but there's no mechanism in the Raycast extension to capture this output. The deeplink protocol is fire-and-forget via URL scheme, so the extension cannot receive the response. Should these actions use IPC commands instead of deeplinks, or is there a planned mechanism for capturing stdout from the deeplink handler?

How can I resolve this? If you propose a fix, please make it concise.

}
DeepLinkAction::GetStatus => {
let state = app.state::<ArcLock<App>>();
let app_state = state.read().await;
let status = if let Some(recording) = app_state.current_recording() {
let is_paused = recording.is_paused().await.unwrap_or(false);
Copy link

Choose a reason for hiding this comment

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

unwrap_or(false) will silently hide failures from is_paused() and report an incorrect status. It might be better to surface the error through the deeplink response.

Suggested change
let is_paused = recording.is_paused().await.unwrap_or(false);
let is_paused = recording.is_paused().await.map_err(|e| e.to_string())?;

let mode = match recording {
crate::recording::InProgressRecording::Instant { .. } => {
Some("instant".to_string())
}
crate::recording::InProgressRecording::Studio { .. } => {
Some("studio".to_string())
}
};
DeepLinkRecordingStatus {
is_recording: true,
is_paused,
recording_mode: mode,
}
} else {
DeepLinkRecordingStatus {
is_recording: false,
is_paused: false,
recording_mode: None,
}
};
let json = serde_json::to_string(&status).map_err(|e| e.to_string())?;
println!("CAP_DEEPLINK_RESPONSE:{}", json);
Ok(())
}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand All @@ -155,3 +279,37 @@ impl DeepLinkAction {
}
}
}

fn get_available_devices() -> DeepLinkDevices {
let cameras: Vec<DeepLinkCamera> = cap_camera::list_cameras()
.map(|c| DeepLinkCamera {
name: c.display_name().to_string(),
id: c.device_id().to_string(),
})
.collect();

let microphones: Vec<String> = MicrophoneFeed::list().keys().cloned().collect();
Copy link

Choose a reason for hiding this comment

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

Microphone list ordering can be nondeterministic (map key iteration). Sorting makes the output stable.

Suggested change
let microphones: Vec<String> = MicrophoneFeed::list().keys().cloned().collect();
let mut microphones: Vec<String> = MicrophoneFeed::list().keys().cloned().collect();
microphones.sort();


let screens: Vec<DeepLinkScreen> = cap_recording::screen_capture::list_displays()
.into_iter()
.map(|(s, _)| DeepLinkScreen {
name: s.name,
id: s.id.to_string(),
})
.collect();

let windows: Vec<DeepLinkWindow> = cap_recording::screen_capture::list_windows()
.into_iter()
.map(|(w, _)| DeepLinkWindow {
name: w.name,
owner_name: w.owner_name,
})
.collect();

DeepLinkDevices {
cameras,
microphones,
screens,
windows,
}
}
54 changes: 54 additions & 0 deletions extensions/raycast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Cap Raycast Extension

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

## Features

- **Start Recording** - Start a new screen or window 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 on the current recording
- **Take Screenshot** - Capture a screenshot of a screen or window
- **Recording Status** - Check the current recording status
- **Open Settings** - Open Cap settings

## Requirements

- [Cap](https://cap.so) desktop app must be installed and running
- macOS

## Installation

1. Clone this repository
2. Navigate to the `extensions/raycast` directory
3. Run `npm install`
4. Run `npm run dev` to start development mode

## How It Works

This extension uses Cap's deeplink API to control the app. Commands are sent via the `cap-desktop://` URL scheme.

## Deeplink Format

```
cap-desktop://action?value=<URL-encoded JSON>
```

### Available Actions

| Action | Description |
|--------|-------------|
| `get_status` | Get current recording status |
| `list_devices` | List available cameras, microphones, screens, and windows |
| `start_recording` | Start a new recording |
| `stop_recording` | Stop the current recording |
| `pause_recording` | Pause the current recording |
| `resume_recording` | Resume a paused recording |
| `toggle_pause_recording` | Toggle pause state |
| `restart_recording` | Restart the current recording |
| `take_screenshot` | Take a screenshot |
| `set_microphone` | Switch microphone |
| `set_camera` | Switch camera |
| `open_settings` | Open Cap settings |
| `open_editor` | Open a project in the editor |
Binary file added extensions/raycast/assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
96 changes: 96 additions & 0 deletions extensions/raycast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "cap",
Copy link

Choose a reason for hiding this comment

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

Since pnpm-workspace.yaml includes extensions/*, this package ends up in the pnpm workspace. The repo root already has a package named cap, so this duplicate name will likely break workspace resolution.

Suggested change
"name": "cap",
"name": "cap-raycast",

"title": "Cap",
"description": "Control Cap screen recording from Raycast",
"icon": "icon.png",
"author": "cap",
"categories": ["Productivity", "Applications"],
"license": "MIT",
"commands": [
{
"name": "start-recording",
"title": "Start Recording",
"subtitle": "Cap",
"description": "Start a new screen recording",
"mode": "view",
"keywords": ["record", "capture", "screen"]
},
{
"name": "stop-recording",
"title": "Stop Recording",
"subtitle": "Cap",
"description": "Stop the current recording",
"mode": "no-view",
"keywords": ["stop", "end", "finish"]
},
{
"name": "pause-recording",
"title": "Pause Recording",
"subtitle": "Cap",
"description": "Pause the current recording",
"mode": "no-view",
"keywords": ["pause", "hold"]
},
{
"name": "resume-recording",
"title": "Resume Recording",
"subtitle": "Cap",
"description": "Resume a paused recording",
"mode": "no-view",
"keywords": ["resume", "continue"]
},
{
"name": "toggle-pause",
"title": "Toggle Pause",
"subtitle": "Cap",
"description": "Toggle pause/resume on the current recording",
"mode": "no-view",
"keywords": ["toggle", "pause", "resume"]
},
{
"name": "take-screenshot",
"title": "Take Screenshot",
"subtitle": "Cap",
"description": "Take a screenshot of a screen or window",
"mode": "view",
"keywords": ["screenshot", "capture", "snap"]
},
{
"name": "recording-status",
"title": "Recording Status",
"subtitle": "Cap",
"description": "Show the current recording status",
"mode": "no-view",
"keywords": ["status", "state"]
},
{
"name": "open-settings",
"title": "Open Settings",
"subtitle": "Cap",
"description": "Open Cap settings",
"mode": "no-view",
"keywords": ["settings", "preferences", "config"]
}
],
"dependencies": {
"@raycast/api": "^1.87.0",
"@raycast/utils": "^1.17.0"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.11",
"@types/node": "22.10.2",
"@types/react": "19.0.2",
"eslint": "^9.16.0",
"prettier": "^3.4.2",
"typescript": "^5.7.2"
},
"scripts": {
"build": "ray build --skip-types -e dist -o dist",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint",
"prepublishOnly": "echo \"\\n\\nIt seems like you are trying to publish the Raycast extension to npm.\\n\\nIf you did intend to publish it to npm, remove the \\`prepublishOnly\\` script and rerun \\`npm publish\\` again.\\nIf you wanted to publish it to the Raycast Store instead, use \\`npm run publish\\` instead.\\n\\n\" && exit 1",
"publish": "npx @raycast/api@latest publish"
}
}
Loading
Loading