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
97 changes: 97 additions & 0 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ pub enum CaptureMode {
Window(String),
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CameraInfo {
pub device_id: String,
pub display_name: String,
pub model_id: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MicrophoneInfo {
pub label: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeepLinkAction {
Expand All @@ -26,6 +40,18 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
TogglePauseRecording,
SwitchCamera {
device_id: Option<String>,
},
SwitchMicrophone {
device_label: Option<String>,
},
ListCameras,
ListMicrophones,
GetRecordingStatus,
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -146,6 +172,77 @@ 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::SwitchCamera { device_id } => {
Copy link

Choose a reason for hiding this comment

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

Right now switch_camera.device_id must match cap_camera::CameraInfo.device_id() (unique ID). The Raycast extension/README are passing a display name, which won't match. One option is to accept either unique ID or display name here.

Suggested change
DeepLinkAction::SwitchCamera { device_id } => {
DeepLinkAction::SwitchCamera { device_id } => {
let state = app.state::<ArcLock<App>>();
let camera_id = match device_id {
None => None,
Some(id) => {
let matched = cap_camera::list_cameras()
.find(|c| c.device_id() == id || c.display_name() == id)
.map(|c| c.device_id().to_string())
.ok_or_else(|| format!("No camera with id or name \"{id}\""))?;
Some(DeviceOrModelID::DeviceID(matched))
}
};
crate::set_camera_input(app.clone(), state, camera_id, Some(true)).await
}

Copy link
Author

Choose a reason for hiding this comment

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

Fixed! SwitchCamera now accepts either device_id or display_name - matches against both.

let state = app.state::<ArcLock<App>>();

let camera_id = match device_id {
None => None,
Some(id) => {
let matched = cap_camera::list_cameras()
.find(|c| c.device_id() == id || c.display_name() == id)
.map(|c| c.device_id().to_string())
.ok_or_else(|| format!("No camera with id or name \"{}\"", id))?;
Some(DeviceOrModelID::DeviceID(matched))
}
};

crate::set_camera_input(app.clone(), state, camera_id, Some(true)).await
}
DeepLinkAction::SwitchMicrophone { device_label } => {
let state = app.state::<ArcLock<App>>();
crate::set_mic_input(state, device_label).await
}
DeepLinkAction::ListCameras => {
let cameras: Vec<CameraInfo> = cap_camera::list_cameras()
.map(|c| CameraInfo {
device_id: c.device_id().to_string(),
display_name: c.display_name().to_string(),
model_id: c.model_id().map(|m| m.to_string()),
})
.collect();
let json = serde_json::to_string(&cameras).map_err(|e| e.to_string())?;
trace!("ListCameras response: {}", json);
Ok(())
}
Comment on lines +204 to +215
Copy link
Contributor

Choose a reason for hiding this comment

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

[P1] ListCameras/ListMicrophones/GetRecordingStatus build JSON but then discard it (only trace! it and return Ok(())). Deeplinks don't have a response channel, so external tooling (including this Raycast extension) can't actually consume these lists/status as implemented.

If these actions are intended for external tooling, they likely need to emit an event / write to a known location / expose a Tauri command instead of only logging.

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

Comment:
[P1] `ListCameras`/`ListMicrophones`/`GetRecordingStatus` build JSON but then discard it (only `trace!` it and return `Ok(())`). Deeplinks don't have a response channel, so external tooling (including this Raycast extension) can't actually consume these lists/status as implemented.

If these actions are intended for external tooling, they likely need to emit an event / write to a known location / expose a Tauri command instead of only logging.

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

Copy link
Author

Choose a reason for hiding this comment

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

Acknowledged - this is a limitation of deeplinks (fire-and-forget, no response channel). These actions are primarily for debugging/logging. The Raycast extension queries devices directly via system_profiler instead.

DeepLinkAction::ListMicrophones => {
let mics: Vec<MicrophoneInfo> =
cap_recording::feeds::microphone::MicrophoneFeed::list()
.keys()
.map(|label| MicrophoneInfo {
label: label.clone(),
})
.collect();
let json = serde_json::to_string(&mics).map_err(|e| e.to_string())?;
trace!("ListMicrophones response: {}", json);
Ok(())
}
DeepLinkAction::GetRecordingStatus => {
let state = app.state::<ArcLock<App>>();
Copy link

Choose a reason for hiding this comment

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

This calls current_recording() twice and unwrap_or(false) hides errors from is_paused(). I'd compute once and propagate the error (or include it in the trace).

Suggested change
let state = app.state::<ArcLock<App>>();
DeepLinkAction::GetRecordingStatus => {
let state = app.state::<ArcLock<App>>();
let app_state = state.read().await;
let (is_recording, is_paused) = match app_state.current_recording() {
Some(recording) => {
let is_paused = recording.is_paused().await.map_err(|e| e.to_string())?;
(true, is_paused)
}
None => (false, false),
};
trace!(
"GetRecordingStatus: is_recording={}, is_paused={}",
is_recording,
is_paused
);
Ok(())
}

Copy link
Author

Choose a reason for hiding this comment

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

Fixed! Now calling current_recording() once and properly propagating is_paused() errors.

let app_state = state.read().await;

let (is_recording, is_paused) = match app_state.current_recording() {
Some(recording) => {
let paused = recording.is_paused().await.map_err(|e| e.to_string())?;
(true, paused)
}
None => (false, false),
};

trace!(
"GetRecordingStatus: is_recording={}, is_paused={}",
is_recording, is_paused
);
Ok(())
}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand Down
84 changes: 84 additions & 0 deletions apps/raycast-extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Cap Raycast Extension

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

## Features

- **Start Recording** - Pick a screen or window to start recording
- **Stop Recording** - Stop the current recording
- **Toggle Pause** - Pause or resume the current recording
- **Switch Camera** - Switch to a different camera or disable camera
- **Switch Microphone** - Switch to a different microphone or disable microphone
- **Open Cap** - Launch the Cap application

## Requirements

- [Cap](https://cap.so) must be installed on your Mac
- macOS 13.0 or later

## How It Works

This extension uses Cap's deeplink protocol to control the application:

```
cap://action?value={"action_type": {...}}
```

### Available Deeplink Actions

| Action | Description |
|--------|-------------|
| `start_recording` | Start a new recording with specified capture mode |
| `stop_recording` | Stop the current recording |
| `pause_recording` | Pause the current recording |
| `resume_recording` | Resume a paused recording |
| `toggle_pause_recording` | Toggle pause/resume state |
| `switch_camera` | Switch to a different camera |
| `switch_microphone` | Switch to a different microphone |

### Example Deeplinks

**Start recording a screen:**
```bash
open "cap://action?value=%7B%22start_recording%22%3A%7B%22capture_mode%22%3A%7B%22screen%22%3A%22Built-in%20Retina%20Display%22%7D%2C%22capture_system_audio%22%3Afalse%2C%22mode%22%3A%22instant%22%7D%7D"
```

**Stop recording:**
```bash
open "cap://action?value=%22stop_recording%22"
```

**Toggle pause:**
```bash
open "cap://action?value=%22toggle_pause_recording%22"
```

**Switch camera:**
```bash
open "cap://action?value=%7B%22switch_camera%22%3A%7B%22device_id%22%3A%22FaceTime%20HD%20Camera%22%7D%7D"
```

**Disable camera:**
```bash
open "cap://action?value=%7B%22switch_camera%22%3A%7B%22device_id%22%3Anull%7D%7D"
```

## Development

```bash
# Install dependencies
npm install

# Start development
npm run dev

# Build
npm run build

# Lint
npm run lint
```

## License

MIT
Binary file added apps/raycast-extension/assets/extension-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 74 additions & 0 deletions apps/raycast-extension/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "cap",
"title": "Cap",
"description": "Control Cap screen recorder - start, stop, pause recordings and switch devices",
"icon": "extension-icon.png",
"author": "cap",
"categories": ["Media", "Productivity"],
"license": "MIT",
"commands": [
{
"name": "start-recording",
"title": "Start Recording",
"subtitle": "Cap",
"description": "Start a new screen recording with Cap",
"mode": "view"
},
{
"name": "stop-recording",
"title": "Stop Recording",
"subtitle": "Cap",
"description": "Stop the current recording",
"mode": "no-view"
},
{
"name": "toggle-pause",
"title": "Toggle Pause",
"subtitle": "Cap",
"description": "Pause or resume the current recording",
"mode": "no-view"
},
{
"name": "switch-camera",
"title": "Switch Camera",
"subtitle": "Cap",
"description": "Switch to a different camera",
"mode": "view"
},
{
"name": "switch-microphone",
"title": "Switch Microphone",
"subtitle": "Cap",
"description": "Switch to a different microphone",
"mode": "view"
},
{
"name": "open-cap",
"title": "Open Cap",
"subtitle": "Cap",
"description": "Open Cap application",
"mode": "no-view"
}
],
"dependencies": {
"@raycast/api": "^1.87.3",
"@raycast/utils": "^1.19.1"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.11",
"@types/node": "22.10.6",
"@types/react": "18.3.18",
"eslint": "^8.57.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
},
"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"
}
}
15 changes: 15 additions & 0 deletions apps/raycast-extension/src/open-cap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { showHUD, showToast, Toast } from "@raycast/api";
import { openCap } from "./utils/cap";

export default async function OpenCap() {
try {
await openCap();
await showHUD("Opening Cap...");
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to open Cap",
message: String(error),
});
}
}
Loading