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
52 changes: 51 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,18 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
TogglePauseRecording,
RestartRecording,
TakeScreenshot,
SwitchMicrophone {
mic_label: String,
},
SwitchCamera {
camera: DeviceOrModelID,
},
RefreshRaycastDeviceCache,
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -88,7 +100,7 @@ impl TryFrom<&Url> for DeepLinkAction {
.map_err(|_| ActionParseFromUrlError::Invalid);
}

match url.domain() {
match url.host_str() {
Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction),
_ => Err(ActionParseFromUrlError::Invalid),
}?;
Expand Down Expand Up @@ -147,6 +159,44 @@ 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(|_| ())
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MEDIUM] Unauthenticated deeplink action can capture screenshots

New cap-desktop deeplink action directly invokes take_screenshot without caller validation.

Fix: Gate screenshot deeplinks with confirmation, nonce/auth, or trusted IPC before capturing the screen.

}
DeepLinkAction::TakeScreenshot => {
let displays = cap_recording::sources::screen_capture::list_displays();
let target = displays
.into_iter()
.next()
.map(|(d, _)| ScreenCaptureTarget::Display { id: d.id })
.ok_or("No display found")?;
crate::recording::take_screenshot(app.clone(), target)
.await
.map(|_| ())
}
DeepLinkAction::SwitchMicrophone { mic_label } => {
crate::set_mic_input(app.state(), Some(mic_label)).await
}
DeepLinkAction::SwitchCamera { camera } => {
crate::set_camera_input(app.clone(), app.state(), Some(camera), None).await
}
DeepLinkAction::RefreshRaycastDeviceCache => {
// Refresh by re-initializing the mic feed
let state: tauri::State<'_, ArcLock<App>> = app.state();
let mut app_state = state.write().await;
app_state.restart_mic_feed().await.map_err(|e| e.to_string())?;
Ok(())
}
Comment on lines +193 to +199
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.

P2 RefreshRaycastDeviceCache only refreshes microphone feed, not cameras

The handler calls restart_mic_feed() only. Camera device enumeration is left untouched, so Raycast's "List Devices" / "Switch Camera" flows won't benefit from this refresh for cameras. If the intent is a full device-list refresh, the camera feed should also be restarted here.

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-199

Comment:
**`RefreshRaycastDeviceCache` only refreshes microphone feed, not cameras**

The handler calls `restart_mic_feed()` only. Camera device enumeration is left untouched, so Raycast's "List Devices" / "Switch Camera" flows won't benefit from this refresh for cameras. If the intent is a full device-list refresh, the camera feed should also be restarted here.

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

DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand Down
3 changes: 3 additions & 0 deletions apps/raycast/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist/
node_modules/
*.log
20 changes: 20 additions & 0 deletions apps/raycast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Cap Raycast Extension

Control Cap screen recording directly from Raycast.

## Commands

- **Start Recording** — Start a new screen recording
- **Stop Recording** — Stop the current recording
- **Pause Recording** — Pause the current recording
- **Resume Recording** — Resume a paused recording
- **Toggle Pause Recording** — Toggle between pause and resume
- **Restart Recording** — Restart the current recording
- **Take Screenshot** — Capture a screenshot with Cap
- **List Available Devices** — View connected cameras and microphones
- **Switch Camera** — Select a different camera input
- **Switch Microphone** — Select a different microphone input

## How it Works

The extension communicates with the Cap desktop app via custom `cap-desktop://` deeplinks.
92 changes: 92 additions & 0 deletions apps/raycast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
{
"name": "cap",
"title": "Cap",
"description": "Control Cap screen recording directly from Raycast",
"icon": "icon.png",
"author": "Cap",
"categories": ["Productivity", "Developer Tools"],
"license": "MIT",
"commands": [
{
"name": "start-recording",
"title": "Start Recording",
"subtitle": "Cap",
"description": "Start a new screen recording with Cap",
"mode": "no-view"
},
{
"name": "stop-recording",
"title": "Stop Recording",
"subtitle": "Cap",
"description": "Stop the current recording",
"mode": "no-view"
},
{
"name": "pause-recording",
"title": "Pause Recording",
"subtitle": "Cap",
"description": "Pause the current recording",
"mode": "no-view"
},
{
"name": "resume-recording",
"title": "Resume Recording",
"subtitle": "Cap",
"description": "Resume a paused recording",
"mode": "no-view"
},
{
"name": "toggle-pause-recording",
"title": "Toggle Pause Recording",
"subtitle": "Cap",
"description": "Toggle pause/resume on the current recording",
"mode": "no-view"
},
{
"name": "restart-recording",
"title": "Restart Recording",
"subtitle": "Cap",
"description": "Restart the current recording",
"mode": "no-view"
},
{
"name": "take-screenshot",
"title": "Take Screenshot",
"subtitle": "Cap",
"description": "Capture a screenshot using Cap",
"mode": "no-view"
},
{
"name": "list-devices",
"title": "List Available Devices",
"subtitle": "Cap",
"description": "Show available cameras and microphones",
"mode": "view"
},
{
"name": "switch-camera",
"title": "Switch Camera",
"subtitle": "Cap",
"description": "Switch the camera input for recording",
"mode": "view"
},
{
"name": "switch-microphone",
"title": "Switch Microphone",
"subtitle": "Cap",
"description": "Switch the microphone input for recording",
"mode": "view"
}
],
"dependencies": {
"@raycast/api": "^1.79.0"
},
"devDependencies": {
"@types/node": "20.14.9",
"typescript": "^5.5.3"
},
"scripts": {
"build": "ray build",
"dev": "ray develop"
}
}
95 changes: 95 additions & 0 deletions apps/raycast/src/list-devices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { ActionPanel, Action, List, closeMainWindow, open } from "@raycast/api";
import { useEffect, useState } from "react";

interface Device {
name: string;
type: "camera" | "microphone";
}

export default function Command() {
const [devices, setDevices] = useState<Device[]>([]);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
async function init() {
// Refresh device cache via deeplink
await open(
`cap-desktop://action?value=${encodeURIComponent(JSON.stringify("refresh_raycast_device_cache"))}`
);

// Populate with known device types — actual list comes from the Cap app
setDevices([
{ name: "Built-in Camera", type: "camera" },
{ name: "Built-in Microphone", type: "microphone" },
]);
setIsLoading(false);
}

init();
}, []);

return (
<List isLoading={isLoading}>
<List.Section title="Cameras">
{devices
.filter((d) => d.type === "camera")
.map((device) => (
<List.Item
key={device.name}
title={device.name}
icon="📹"
actions={
<ActionPanel>
<Action
title="Select Camera"
onAction={async () => {
await closeMainWindow();
await open(
`cap-desktop://action?value=${encodeURIComponent(
JSON.stringify({
switch_camera: {
camera: { DeviceID: device.name },
},
})
)}`
);
}}
/>
</ActionPanel>
}
/>
))}
</List.Section>
<List.Section title="Microphones">
{devices
.filter((d) => d.type === "microphone")
.map((device) => (
<List.Item
key={device.name}
title={device.name}
icon="🎤"
actions={
<ActionPanel>
<Action
title="Select Microphone"
onAction={async () => {
await closeMainWindow();
await open(
`cap-desktop://action?value=${encodeURIComponent(
JSON.stringify({
switch_microphone: {
mic_label: device.name,
},
})
)}`
);
}}
/>
</ActionPanel>
}
/>
))}
</List.Section>
</List>
);
}
9 changes: 9 additions & 0 deletions apps/raycast/src/pause-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { closeMainWindow, showHUD } from "@raycast/api";

import { sendAction } from "./utils";

export default async function Command() {
await closeMainWindow();
await sendAction("pause_recording");
await showHUD("Cap: Recording paused");
}
9 changes: 9 additions & 0 deletions apps/raycast/src/restart-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { closeMainWindow, showHUD } from "@raycast/api";

import { sendAction } from "./utils";

export default async function Command() {
await closeMainWindow();
await sendAction("restart_recording");
await showHUD("Cap: Recording restarted");
}
9 changes: 9 additions & 0 deletions apps/raycast/src/resume-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { closeMainWindow, showHUD } from "@raycast/api";

import { sendAction } from "./utils";

export default async function Command() {
await closeMainWindow();
await sendAction("resume_recording");
await showHUD("Cap: Recording resumed");
}
25 changes: 25 additions & 0 deletions apps/raycast/src/start-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { LaunchProps, closeMainWindow, showHUD } from "@raycast/api";

import { sendActionWithPayload } from "./utils";

interface StartRecordingArguments {
captureMode?: string;
captureName?: string;
}

export default async function Command(props: LaunchProps<{ arguments: StartRecordingArguments }>) {
await closeMainWindow();

const captureMode = props.arguments.captureMode || "screen";
const captureName = props.arguments.captureName || undefined;

await sendActionWithPayload("start_recording", {
capture_mode: { [captureMode]: captureName },
camera: null,
mic_label: null,
capture_system_audio: false,
mode: "Instant",
});

await showHUD("Cap: Recording started");
}
9 changes: 9 additions & 0 deletions apps/raycast/src/stop-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { closeMainWindow, showHUD } from "@raycast/api";

import { sendAction } from "./utils";

export default async function Command() {
await closeMainWindow();
await sendAction("stop_recording");
await showHUD("Cap: Recording stopped");
}
43 changes: 43 additions & 0 deletions apps/raycast/src/switch-camera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { ActionPanel, Action, List, closeMainWindow, open } from "@raycast/api";

interface SwitchCameraArguments {
camera?: string;
}

export default function Command(props: { arguments: SwitchCameraArguments }) {
const cameras = [
{ name: "Built-in Camera", id: "built-in" },
{ name: "External Camera", id: "external" },
];
Comment on lines +8 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 Hardcoded device list never reflects actual hardware

Both switch-camera.ts and switch-microphone.ts define a fixed array ("Built-in Camera", "External Camera", etc.) that is never populated with real system devices. A user with a USB webcam or external audio interface will not see it listed, and selecting a hardcoded entry will try to switch to a device ID/name that likely doesn't exist in the Cap backend. The same issue affects list-devices.ts, where the comment says "actual list comes from the Cap app" but there is no IPC channel for Cap to push device data back to Raycast — the list is always the two hardcoded entries.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast/src/switch-camera.ts
Line: 8-11

Comment:
**Hardcoded device list never reflects actual hardware**

Both `switch-camera.ts` and `switch-microphone.ts` define a fixed array (`"Built-in Camera"`, `"External Camera"`, etc.) that is never populated with real system devices. A user with a USB webcam or external audio interface will not see it listed, and selecting a hardcoded entry will try to switch to a device ID/name that likely doesn't exist in the Cap backend. The same issue affects `list-devices.ts`, where the comment says "actual list comes from the Cap app" but there is no IPC channel for Cap to push device data back to Raycast — the list is always the two hardcoded entries.

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


return (
<List>
{cameras.map((camera) => (
<List.Item
key={camera.id}
title={camera.name}
icon="📹"
actions={
<ActionPanel>
<Action
title="Switch to This Camera"
onAction={async () => {
await closeMainWindow();
await open(
`cap-desktop://action?value=${encodeURIComponent(
JSON.stringify({
switch_camera: {
camera: { DeviceID: props.arguments.camera || camera.name },
},
})
)}`
);
}}
/>
</ActionPanel>
}
/>
))}
</List>
);
}
Loading