-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: add deeplink actions for recording control and Raycast extension #1814
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
| }, | ||
|
|
@@ -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), | ||
| }?; | ||
|
|
@@ -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(|_| ()) | ||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The handler calls Prompt To Fix With AIThis 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()) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| dist/ | ||
| node_modules/ | ||
| *.log |
| 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. |
| 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" | ||
| } | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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"); | ||
| } |
| 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"); | ||
| } |
| 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"); | ||
| } |
| 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"); | ||
| } |
| 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"); | ||
| } |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Both Prompt To Fix With AIThis 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> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
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.