-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: Add deeplinks for pause/resume/switch + Raycast Extension #1568
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
07c9436
818c037
eb8c9fc
8b8cb67
4d62dca
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 | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 { | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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, | ||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 } => { | ||||||||||||||||||||||||||||||||||||||||||
| 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
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. [P1] 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 AIThis 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.
Author
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. 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>>(); | ||||||||||||||||||||||||||||||||||||||||||
|
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. This calls
Suggested change
Author
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. 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()) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| 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 |
| 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" | ||
| } | ||
| } |
| 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), | ||
| }); | ||
| } | ||
| } |
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.
Right now
switch_camera.device_idmust matchcap_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.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.
Fixed! SwitchCamera now accepts either device_id or display_name - matches against both.