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
43 changes: 43 additions & 0 deletions apps/desktop/src-tauri/DEEPLINKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Cap Desktop Deep Links

Cap registers the `cap-desktop://` URL scheme via `tauri-plugin-deep-link` and exposes a `cap-desktop://action` endpoint that consumes a JSON-encoded `DeepLinkAction` from the `value` query parameter.

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

The full set of supported actions lives in `apps/desktop/src-tauri/src/deeplink_actions.rs`.

## Recording control

| Action | JSON payload | Notes |
| --- | --- | --- |
| Start recording | `{"start_recording": { "capture_mode": { "screen": "Display 1" } \| { "window": "Safari — example.com" }, "camera": null \| {"DeviceID": "..."} \| {"ModelID": "..."}, "mic_label": null \| "Built-in Microphone", "capture_system_audio": false, "mode": "studio" \| "instant" }}` | Same wire shape as `commands.startRecording`. `camera` and `mic_label` may be `null` to use the current selection. |
| Stop recording | `"stop_recording"` | No-op if nothing is recording. |
| Pause recording | `"pause_recording"` | No-op if no recording or already paused. |
| Resume recording | `"resume_recording"` | No-op if no recording or not paused. |
| Switch camera | `{"switch_camera": {"camera": null \| {"DeviceID": "..."} \| {"ModelID": "..."}}}` | Set `camera` to `null` to clear the camera selection. Works while recording is active or idle. |
| Switch microphone | `{"switch_microphone": {"mic_label": null \| "Built-in Microphone"}}` | Set `mic_label` to `null` to clear the microphone selection. |

## Editor / settings

| Action | JSON payload | Notes |
| --- | --- | --- |
| Open editor | `{"open_editor": {"project_path": "/path/to/project.cap"}}` | macOS also accepts a bare `file://` URL for the same effect. |
| Open settings | `{"open_settings": {"page": null \| "general" \| "recordings" \| ...}}` | `page` selects which settings pane to focus. |

## Calling from the command line

macOS:

```sh
open 'cap-desktop://action?value=%22pause_recording%22'
```

Windows:

```powershell
start 'cap-desktop://action?value=%22pause_recording%22'
```

A Raycast extension that wraps the no-view commands lives in `apps/raycast`.
126 changes: 125 additions & 1 deletion apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
SwitchCamera {
camera: Option<DeviceOrModelID>,
},
SwitchMicrophone {
mic_label: Option<String>,
},
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -89,7 +97,8 @@ impl TryFrom<&Url> for DeepLinkAction {
}

match url.domain() {
Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction),
Some("action") => Ok(()),
Some(_) => Err(ActionParseFromUrlError::NotAction),
_ => Err(ActionParseFromUrlError::Invalid),
}?;

Expand Down Expand Up @@ -147,6 +156,20 @@ 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::SwitchCamera { camera } => {
let state = app.state::<ArcLock<App>>();
crate::set_camera_input(app.clone(), state, camera, None).await
}
DeepLinkAction::SwitchMicrophone { 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 All @@ -156,3 +179,104 @@ impl DeepLinkAction {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

fn parse(value: &str) -> Result<DeepLinkAction, ActionParseFromUrlError> {
let url = Url::parse_with_params("cap-desktop://action", &[("value", value)])
.expect("test URL must parse");
DeepLinkAction::try_from(&url)
}

#[test]
fn parses_stop_recording() {
let action = parse(r#""stop_recording""#).expect("parses");
assert!(matches!(action, DeepLinkAction::StopRecording));
}

#[test]
fn parses_pause_recording() {
let action = parse(r#""pause_recording""#).expect("parses");
assert!(matches!(action, DeepLinkAction::PauseRecording));
}

#[test]
fn parses_resume_recording() {
let action = parse(r#""resume_recording""#).expect("parses");
assert!(matches!(action, DeepLinkAction::ResumeRecording));
}

#[test]
fn parses_switch_camera_with_label() {
let value = r#"{"switch_camera":{"camera":{"ModelID":"FaceTime HD Camera"}}}"#;
match parse(value).expect("parses") {
DeepLinkAction::SwitchCamera { camera } => {
assert!(camera.is_some());
}
other => panic!("expected SwitchCamera, got {other:?}"),
}
}

#[test]
fn parses_switch_camera_to_none() {
let value = r#"{"switch_camera":{"camera":null}}"#;
match parse(value).expect("parses") {
DeepLinkAction::SwitchCamera { camera } => {
assert!(camera.is_none());
}
other => panic!("expected SwitchCamera, got {other:?}"),
}
}

#[test]
fn parses_switch_microphone_with_label() {
let value = r#"{"switch_microphone":{"mic_label":"MacBook Pro Microphone"}}"#;
match parse(value).expect("parses") {
DeepLinkAction::SwitchMicrophone { mic_label } => {
assert_eq!(mic_label.as_deref(), Some("MacBook Pro Microphone"));
}
other => panic!("expected SwitchMicrophone, got {other:?}"),
}
}

#[test]
fn parses_switch_microphone_to_none() {
let value = r#"{"switch_microphone":{"mic_label":null}}"#;
match parse(value).expect("parses") {
DeepLinkAction::SwitchMicrophone { mic_label } => {
assert!(mic_label.is_none());
}
other => panic!("expected SwitchMicrophone, got {other:?}"),
}
}

#[test]
fn rejects_non_action_domain() {
let url = Url::parse("cap-desktop://login?token=x").expect("test URL must parse");
assert!(matches!(
DeepLinkAction::try_from(&url),
Err(ActionParseFromUrlError::NotAction)
));
}

#[test]
fn rejects_missing_value() {
let url = Url::parse("cap-desktop://action?other=x").expect("test URL must parse");
assert!(matches!(
DeepLinkAction::try_from(&url),
Err(ActionParseFromUrlError::Invalid)
));
}

#[test]
fn rejects_malformed_json_value() {
let url =
Url::parse("cap-desktop://action?value=%7Bnot-json").expect("test URL must parse");
assert!(matches!(
DeepLinkAction::try_from(&url),
Err(ActionParseFromUrlError::ParseFailed(_))
));
}
}
23 changes: 23 additions & 0 deletions apps/raycast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Cap Recorder for Raycast

Drive Cap's recording controls from Raycast via the `cap-desktop://` URL scheme. Five commands are bundled:

- **Stop Cap Recording** — stops the active recording.
- **Pause Cap Recording** — pauses the active recording.
- **Resume Cap Recording** — resumes a paused recording.
- **Switch Cap Microphone** — switches the active microphone by label. Leave the argument blank to clear the selection.
- **Switch Cap Camera** — switches the active camera. An 8+ character hex/dash identifier is interpreted as a `DeviceID`; anything else is treated as a `ModelID`. Leave blank to clear.

The extension is a thin Raycast wrapper: each command builds a `cap-desktop://action?value=<json>` URL and opens it via `@raycast/api`'s `open(...)`. The desktop app's `DeepLinkAction::handle` parses the action and dispatches it. See [`apps/desktop/src-tauri/DEEPLINKS.md`](../desktop/src-tauri/DEEPLINKS.md) for the full action wire format and additional payloads that the app accepts (start recording, open editor, open settings).

## Development

```sh
cd apps/raycast
pnpm install
pnpm dev
```

`pnpm dev` launches Raycast in development mode against this extension. `pnpm build` produces a Raycast-store-shaped bundle; `pnpm lint` runs Raycast's lint pass.

Cap must be running for the deep links to land — the macOS app registers the `cap-desktop` scheme on first launch.
Binary file added apps/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.
81 changes: 81 additions & 0 deletions apps/raycast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
{
"name": "@cap/raycast",
"version": "0.0.1",
"private": true,
"type": "module",
"description": "Control Cap recording from Raycast via cap-desktop:// deep links.",
"scripts": {
"build": "ray build",
"dev": "ray develop",
"lint": "ray lint"
},
"dependencies": {
"@raycast/api": "^1.83.0",
"@raycast/utils": "^1.17.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19.1.9",
"react": "^19",
"typescript": "^5"
},
"raycast": {
"schemaVersion": 1,
"title": "Cap Recorder",
"description": "Control Cap recording (start, stop, pause, resume, switch camera/microphone) without leaving the keyboard.",
"icon": "icon.png",
"author": "cap",
"categories": [
"Productivity",
"Developer Tools"
],
"commands": [
{
"name": "stop-recording",
"title": "Stop Cap Recording",
"description": "Stop the active Cap recording.",
"mode": "no-view"
},
{
"name": "pause-recording",
"title": "Pause Cap Recording",
"description": "Pause the active Cap recording.",
"mode": "no-view"
},
{
"name": "resume-recording",
"title": "Resume Cap Recording",
"description": "Resume a paused Cap recording.",
"mode": "no-view"
},
{
"name": "switch-microphone",
"title": "Switch Cap Microphone",
"description": "Switch the active Cap microphone by label.",
"mode": "no-view",
"arguments": [
{
"name": "label",
"placeholder": "Microphone label (empty to clear)",
"type": "text",
"required": false
}
]
},
{
"name": "switch-camera",
"title": "Switch Cap Camera",
"description": "Switch the active Cap camera by model or device ID.",
"mode": "no-view",
"arguments": [
{
"name": "identifier",
"placeholder": "Camera model or device ID (empty to clear)",
"type": "text",
"required": false
}
]
}
]
}
}
36 changes: 36 additions & 0 deletions apps/raycast/src/deeplink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { closeMainWindow, open, showHUD, showToast, Toast } from "@raycast/api";

type ActionPayload =
| "stop_recording"
| "pause_recording"
| "resume_recording"
| {
switch_camera: {
camera: { ModelID: string } | { DeviceID: string } | null;
};
}
| { switch_microphone: { mic_label: string | null } };

export function buildActionUrl(payload: ActionPayload): string {
const value = JSON.stringify(payload);
const url = new URL("cap-desktop://action");
url.searchParams.set("value", value);
return url.toString();
}

export async function fireAction(
payload: ActionPayload,
successMessage: string,
): Promise<void> {
try {
await open(buildActionUrl(payload));
await closeMainWindow();
await showHUD(successMessage);
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Failed to reach Cap",
message: error instanceof Error ? error.message : String(error),
});
}
}
5 changes: 5 additions & 0 deletions apps/raycast/src/pause-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { fireAction } from "./deeplink";

export default async function PauseRecording(): Promise<void> {
await fireAction("pause_recording", "Cap recording paused");
}
5 changes: 5 additions & 0 deletions apps/raycast/src/resume-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { fireAction } from "./deeplink";

export default async function ResumeRecording(): Promise<void> {
await fireAction("resume_recording", "Cap recording resumed");
}
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 { fireAction } from "./deeplink";

export default async function StopRecording(): Promise<void> {
await fireAction("stop_recording", "Cap recording stopped");
}
23 changes: 23 additions & 0 deletions apps/raycast/src/switch-camera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { LaunchProps } from "@raycast/api";
import { fireAction } from "./deeplink";

interface Arguments {
identifier?: string;
}

const DEVICE_ID_PATTERN =
/^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$/i;

export default async function SwitchCamera(
props: LaunchProps<{ arguments: Arguments }>,
): Promise<void> {
const trimmed = props.arguments.identifier?.trim();
if (!trimmed || trimmed.length === 0) {
await fireAction({ switch_camera: { camera: null } }, "Cap camera cleared");
return;
}
const camera = DEVICE_ID_PATTERN.test(trimmed)
? { DeviceID: trimmed }
: { ModelID: trimmed };
await fireAction({ switch_camera: { camera } }, `Cap camera → ${trimmed}`);
}
17 changes: 17 additions & 0 deletions apps/raycast/src/switch-microphone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { LaunchProps } from "@raycast/api";
import { fireAction } from "./deeplink";

interface Arguments {
label?: string;
}

export default async function SwitchMicrophone(
props: LaunchProps<{ arguments: Arguments }>,
): Promise<void> {
const trimmed = props.arguments.label?.trim();
const mic_label = trimmed && trimmed.length > 0 ? trimmed : null;
const message = mic_label
? `Cap microphone → ${mic_label}`
: "Cap microphone cleared";
await fireAction({ switch_microphone: { mic_label } }, message);
}
Loading