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
95 changes: 94 additions & 1 deletion apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ use cap_recording::{
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Url};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use tauri_specta::Event;
use tracing::trace;

use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow};
use crate::{
App, ArcLock, RequestOpenRecordingPicker, RequestStartRecording,
recording::StartRecordingInputs, recording_settings::RecordingTargetMode,
windows::ShowCapWindow,
};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
Expand All @@ -18,6 +24,9 @@ pub enum CaptureMode {
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeepLinkAction {
StartRecordingWithSettings {
mode: RecordingMode,
},
StartRecording {
capture_mode: CaptureMode,
camera: Option<DeviceOrModelID>,
Expand All @@ -26,6 +35,18 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
TogglePauseRecording,
SetMicInput {
label: Option<String>,
},
SetCameraInput {
id: Option<DeviceOrModelID>,
},
OpenRecordingPicker {
target_mode: Option<RecordingTargetMode>,
},
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -106,8 +127,62 @@ impl TryFrom<&Url> for DeepLinkAction {
}

impl DeepLinkAction {
fn confirmation_message(&self) -> Option<&'static str> {
match self {
Self::StartRecordingWithSettings { .. } | Self::StartRecording { .. } => {
Some("A deep link is requesting permission to start a Cap recording.")
}
Self::StopRecording => {
Some("A deep link is requesting permission to stop the current Cap recording.")
}
Self::PauseRecording | Self::ResumeRecording | Self::TogglePauseRecording => {
Some("A deep link is requesting permission to control the current Cap recording.")
}
Self::SetMicInput { .. } => {
Some("A deep link is requesting permission to change Cap's microphone input.")
}
Self::SetCameraInput { .. } => {
Some("A deep link is requesting permission to change Cap's camera input.")
}
Self::OpenRecordingPicker { .. } => {
Some("A deep link is requesting permission to open Cap's recording picker.")
}
Self::OpenEditor { .. } | Self::OpenSettings { .. } => None,
}
}

fn confirm_if_sensitive(&self, app: &AppHandle) -> Result<(), String> {
let Some(message) = self.confirmation_message() else {
return Ok(());
};

let confirmed = app
.dialog()
.message(message)
.title("Allow Cap deep link?")
.kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCancelCustom(
"Allow".to_string(),
"Cancel".to_string(),
))
.blocking_show();

if confirmed {
Ok(())
} else {
Err("Deep link action cancelled".to_string())
}
}

pub async fn execute(self, app: &AppHandle) -> Result<(), String> {
self.confirm_if_sensitive(app)?;

match self {
DeepLinkAction::StartRecordingWithSettings { mode } => {
RequestStartRecording { mode }
.emit(app)
.map_err(|err| err.to_string())
}
DeepLinkAction::StartRecording {
capture_mode,
camera,
Expand Down Expand Up @@ -147,6 +222,24 @@ 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::SetMicInput { label } => crate::set_mic_input(app.state(), label).await,
DeepLinkAction::SetCameraInput { id } => {
crate::set_camera_input(app.clone(), app.state(), id, None).await
}
DeepLinkAction::OpenRecordingPicker { target_mode } => RequestOpenRecordingPicker {
target_mode,
}
.emit(app)
.map_err(|err| err.to_string()),
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand Down
21 changes: 21 additions & 0 deletions extensions/raycast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Cap Raycast Extension

Control the Cap desktop app from Raycast using Cap's `cap://action` deep links.

## Commands

- Start Studio Recording
- Start Instant Recording
- Record Display
- Record Window
- Record Area
- Pause Recording
- Resume Recording
- Toggle Recording Pause
- Stop Recording
- Set Microphone
- Clear Microphone
- Set Camera
- Clear Camera

The microphone command expects the exact Cap microphone label. The camera command accepts either a camera device ID or model ID.
Binary file added extensions/raycast/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.
106 changes: 106 additions & 0 deletions extensions/raycast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "cap",
"title": "Cap",
"description": "Control Cap recordings from Raycast.",
"icon": "extension-icon.png",
"author": "CapSoftware",
"categories": [
"Productivity",
"Developer Tools"
],
"license": "MIT",
"commands": [
{
"name": "start-studio-recording",
"title": "Start Studio Recording",
"description": "Start a Cap Studio recording with saved settings.",
"mode": "no-view"
},
{
"name": "start-instant-recording",
"title": "Start Instant Recording",
"description": "Start a Cap Instant recording with saved settings.",
"mode": "no-view"
},
{
"name": "record-display",
"title": "Record Display",
"description": "Open Cap's display recording picker.",
"mode": "no-view"
},
{
"name": "record-window",
"title": "Record Window",
"description": "Open Cap's window recording picker.",
"mode": "no-view"
},
{
"name": "record-area",
"title": "Record Area",
"description": "Open Cap's area recording picker.",
"mode": "no-view"
},
{
"name": "pause-recording",
"title": "Pause Recording",
"description": "Pause the current Cap recording.",
"mode": "no-view"
},
{
"name": "resume-recording",
"title": "Resume Recording",
"description": "Resume the current Cap recording.",
"mode": "no-view"
},
{
"name": "toggle-pause-recording",
"title": "Toggle Recording Pause",
"description": "Pause or resume the current Cap recording.",
"mode": "no-view"
},
{
"name": "stop-recording",
"title": "Stop Recording",
"description": "Stop the current Cap recording.",
"mode": "no-view"
},
{
"name": "set-microphone",
"title": "Set Microphone",
"description": "Switch Cap's selected microphone by label.",
"mode": "view"
},
{
"name": "clear-microphone",
"title": "Clear Microphone",
"description": "Disable Cap's selected microphone.",
"mode": "no-view"
},
{
"name": "set-camera",
"title": "Set Camera",
"description": "Switch Cap's selected camera by device or model ID.",
"mode": "view"
},
{
"name": "clear-camera",
"title": "Clear Camera",
"description": "Disable Cap's selected camera.",
"mode": "no-view"
}
],
"dependencies": {
"@raycast/api": "^1.104.17"
},
"devDependencies": {
"@types/node": "22.15.17",
"typescript": "^5.8.3"
},
"scripts": {
"build": "ray build",
"dev": "ray develop",
"lint": "ray lint",
"publish:raycast": "ray publish"
}
}
72 changes: 72 additions & 0 deletions extensions/raycast/raycast-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/// <reference types="@raycast/api">

/* 🚧 🚧 🚧
* This file is auto-generated from the extension's manifest.
* Do not modify manually. Instead, update the `package.json` file.
* 🚧 🚧 🚧 */

/* eslint-disable @typescript-eslint/ban-types */

type ExtensionPreferences = {}

/** Preferences accessible in all the extension's commands */
declare type Preferences = ExtensionPreferences

declare namespace Preferences {
/** Preferences accessible in the `start-studio-recording` command */
export type StartStudioRecording = ExtensionPreferences & {}
/** Preferences accessible in the `start-instant-recording` command */
export type StartInstantRecording = ExtensionPreferences & {}
/** Preferences accessible in the `record-display` command */
export type RecordDisplay = ExtensionPreferences & {}
/** Preferences accessible in the `record-window` command */
export type RecordWindow = ExtensionPreferences & {}
/** Preferences accessible in the `record-area` command */
export type RecordArea = ExtensionPreferences & {}
/** Preferences accessible in the `pause-recording` command */
export type PauseRecording = ExtensionPreferences & {}
/** Preferences accessible in the `resume-recording` command */
export type ResumeRecording = ExtensionPreferences & {}
/** Preferences accessible in the `toggle-pause-recording` command */
export type TogglePauseRecording = ExtensionPreferences & {}
/** Preferences accessible in the `stop-recording` command */
export type StopRecording = ExtensionPreferences & {}
/** Preferences accessible in the `set-microphone` command */
export type SetMicrophone = ExtensionPreferences & {}
/** Preferences accessible in the `clear-microphone` command */
export type ClearMicrophone = ExtensionPreferences & {}
/** Preferences accessible in the `set-camera` command */
export type SetCamera = ExtensionPreferences & {}
/** Preferences accessible in the `clear-camera` command */
export type ClearCamera = ExtensionPreferences & {}
}

declare namespace Arguments {
/** Arguments passed to the `start-studio-recording` command */
export type StartStudioRecording = {}
/** Arguments passed to the `start-instant-recording` command */
export type StartInstantRecording = {}
/** Arguments passed to the `record-display` command */
export type RecordDisplay = {}
/** Arguments passed to the `record-window` command */
export type RecordWindow = {}
/** Arguments passed to the `record-area` command */
export type RecordArea = {}
/** Arguments passed to the `pause-recording` command */
export type PauseRecording = {}
/** Arguments passed to the `resume-recording` command */
export type ResumeRecording = {}
/** Arguments passed to the `toggle-pause-recording` command */
export type TogglePauseRecording = {}
/** Arguments passed to the `stop-recording` command */
export type StopRecording = {}
/** Arguments passed to the `set-microphone` command */
export type SetMicrophone = {}
/** Arguments passed to the `clear-microphone` command */
export type ClearMicrophone = {}
/** Arguments passed to the `set-camera` command */
export type SetCamera = {}
/** Arguments passed to the `clear-camera` command */
export type ClearCamera = {}
}

56 changes: 56 additions & 0 deletions extensions/raycast/src/cap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { open, showToast, Toast } from "@raycast/api";

type RecordingMode = "studio" | "instant" | "screenshot";
type RecordingTargetMode = "display" | "window" | "area" | "camera";
type DeviceOrModelID =
| {
DeviceID: string;
}
| {
ModelID: string;
};
Comment on lines +5 to +11
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 The DeviceOrModelID field names are snake_case (device_id, model_id) but the Rust enum DeviceOrModelID serializes without any rename_all attribute, defaulting to PascalCase — {"DeviceID": "..."} and {"ModelID": "..."}. The existing TypeScript type in apps/desktop/src/utils/tauri.ts confirms this: { DeviceID: string } | { ModelID: ModelIDType }. As written, set-camera will send a payload that fails serde deserialization on the Rust side and the action will silently be dropped.

Suggested change
type DeviceOrModelID =
| {
device_id: string;
}
| {
model_id: string;
};
type DeviceOrModelID =
| {
DeviceID: string;
}
| {
ModelID: string;
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/raycast/src/cap.ts
Line: 5-11

Comment:
The `DeviceOrModelID` field names are snake_case (`device_id`, `model_id`) but the Rust enum `DeviceOrModelID` serializes without any `rename_all` attribute, defaulting to PascalCase — `{"DeviceID": "..."}` and `{"ModelID": "..."}`. The existing TypeScript type in `apps/desktop/src/utils/tauri.ts` confirms this: `{ DeviceID: string } | { ModelID: ModelIDType }`. As written, `set-camera` will send a payload that fails serde deserialization on the Rust side and the action will silently be dropped.

```suggestion
type DeviceOrModelID =
	| {
			DeviceID: string;
	  }
	| {
			ModelID: string;
	  };
```

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


type DeepLinkAction =
| {
start_recording_with_settings: {
mode: RecordingMode;
};
}
| "stop_recording"
| "pause_recording"
| "resume_recording"
| "toggle_pause_recording"
| {
open_recording_picker: {
target_mode: RecordingTargetMode | null;
};
}
| {
set_mic_input: {
label: string | null;
};
}
| {
set_camera_input: {
id: DeviceOrModelID | null;
};
};

export async function runCapAction(action: DeepLinkAction, title: string) {
const url = new URL("cap://action");
url.searchParams.set("value", JSON.stringify(action));

await open(url.toString());
await showToast({
style: Toast.Style.Success,
title,
});
}

export function deviceId(value: string): DeviceOrModelID {
return { DeviceID: value };
}

export function modelId(value: string): DeviceOrModelID {
return { ModelID: value };
}
Comment on lines +50 to +56
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 The deviceId and modelId helpers produce objects with snake_case keys (device_id, model_id), which won't match the expected PascalCase keys after fixing the type definition above.

Suggested change
export function deviceId(value: string): DeviceOrModelID {
return { device_id: value };
}
export function modelId(value: string): DeviceOrModelID {
return { model_id: value };
}
export function deviceId(value: string): DeviceOrModelID {
return { DeviceID: value };
}
export function modelId(value: string): DeviceOrModelID {
return { ModelID: value };
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/raycast/src/cap.ts
Line: 50-56

Comment:
The `deviceId` and `modelId` helpers produce objects with snake_case keys (`device_id`, `model_id`), which won't match the expected PascalCase keys after fixing the type definition above.

```suggestion
export function deviceId(value: string): DeviceOrModelID {
	return { DeviceID: value };
}

export function modelId(value: string): DeviceOrModelID {
	return { ModelID: value };
}
```

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

5 changes: 5 additions & 0 deletions extensions/raycast/src/clear-camera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { runCapAction } from "./cap";

export default async function Command() {
await runCapAction({ set_camera_input: { id: null } }, "Clearing camera");
}
Loading