-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: extend deeplink actions for Raycast extension support #1543
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 | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,7 +6,48 @@ use std::path::{Path, PathBuf}; | |||||||
| use tauri::{AppHandle, Manager, Url}; | ||||||||
| use tracing::trace; | ||||||||
|
|
||||||||
| use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; | ||||||||
| use crate::{ | ||||||||
| App, ArcLock, feeds::microphone::MicrophoneFeed, recording::StartRecordingInputs, | ||||||||
| windows::ShowCapWindow, | ||||||||
| }; | ||||||||
|
|
||||||||
| #[derive(Debug, Serialize, Deserialize)] | ||||||||
| #[serde(rename_all = "snake_case")] | ||||||||
| pub struct DeepLinkRecordingStatus { | ||||||||
| pub is_recording: bool, | ||||||||
| pub is_paused: bool, | ||||||||
| pub recording_mode: Option<String>, | ||||||||
| } | ||||||||
|
|
||||||||
| #[derive(Debug, Serialize, Deserialize)] | ||||||||
| #[serde(rename_all = "snake_case")] | ||||||||
| pub struct DeepLinkDevices { | ||||||||
| pub cameras: Vec<DeepLinkCamera>, | ||||||||
| pub microphones: Vec<String>, | ||||||||
| pub screens: Vec<DeepLinkScreen>, | ||||||||
| pub windows: Vec<DeepLinkWindow>, | ||||||||
| } | ||||||||
|
|
||||||||
| #[derive(Debug, Serialize, Deserialize)] | ||||||||
| #[serde(rename_all = "snake_case")] | ||||||||
| pub struct DeepLinkCamera { | ||||||||
| pub name: String, | ||||||||
| pub id: String, | ||||||||
| } | ||||||||
|
|
||||||||
| #[derive(Debug, Serialize, Deserialize)] | ||||||||
| #[serde(rename_all = "snake_case")] | ||||||||
| pub struct DeepLinkScreen { | ||||||||
| pub name: String, | ||||||||
| pub id: String, | ||||||||
| } | ||||||||
|
|
||||||||
| #[derive(Debug, Serialize, Deserialize)] | ||||||||
| #[serde(rename_all = "snake_case")] | ||||||||
| pub struct DeepLinkWindow { | ||||||||
| pub name: String, | ||||||||
| pub owner_name: String, | ||||||||
| } | ||||||||
|
|
||||||||
| #[derive(Debug, Serialize, Deserialize)] | ||||||||
| #[serde(rename_all = "snake_case")] | ||||||||
|
|
@@ -26,6 +67,21 @@ pub enum DeepLinkAction { | |||||||
| mode: RecordingMode, | ||||||||
| }, | ||||||||
| StopRecording, | ||||||||
| PauseRecording, | ||||||||
| ResumeRecording, | ||||||||
| TogglePauseRecording, | ||||||||
| RestartRecording, | ||||||||
| TakeScreenshot { | ||||||||
| capture_mode: CaptureMode, | ||||||||
| }, | ||||||||
| SetMicrophone { | ||||||||
| label: Option<String>, | ||||||||
| }, | ||||||||
| SetCamera { | ||||||||
| id: Option<DeviceOrModelID>, | ||||||||
| }, | ||||||||
| ListDevices, | ||||||||
| GetStatus, | ||||||||
| OpenEditor { | ||||||||
| project_path: PathBuf, | ||||||||
| }, | ||||||||
|
|
@@ -104,6 +160,21 @@ impl TryFrom<&Url> for DeepLinkAction { | |||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| fn resolve_capture_target(capture_mode: &CaptureMode) -> Result<ScreenCaptureTarget, String> { | ||||||||
| match capture_mode { | ||||||||
| CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays() | ||||||||
| .into_iter() | ||||||||
| .find(|(s, _)| s.name == *name) | ||||||||
| .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id }) | ||||||||
| .ok_or_else(|| format!("No screen with name \"{}\"", name)), | ||||||||
| CaptureMode::Window(name) => cap_recording::screen_capture::list_windows() | ||||||||
| .into_iter() | ||||||||
| .find(|(w, _)| w.name == *name) | ||||||||
| .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) | ||||||||
| .ok_or_else(|| format!("No window with name \"{}\"", name)), | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| impl DeepLinkAction { | ||||||||
| pub async fn execute(self, app: &AppHandle) -> Result<(), String> { | ||||||||
| match self { | ||||||||
|
|
@@ -119,18 +190,7 @@ impl DeepLinkAction { | |||||||
| crate::set_camera_input(app.clone(), state.clone(), camera).await?; | ||||||||
| crate::set_mic_input(state.clone(), mic_label).await?; | ||||||||
|
|
||||||||
| let capture_target: ScreenCaptureTarget = match capture_mode { | ||||||||
| CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays() | ||||||||
| .into_iter() | ||||||||
| .find(|(s, _)| s.name == name) | ||||||||
| .map(|(s, _)| ScreenCaptureTarget::Display { id: s.id }) | ||||||||
| .ok_or(format!("No screen with name \"{}\"", &name))?, | ||||||||
| CaptureMode::Window(name) => cap_recording::screen_capture::list_windows() | ||||||||
| .into_iter() | ||||||||
| .find(|(w, _)| w.name == name) | ||||||||
| .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) | ||||||||
| .ok_or(format!("No window with name \"{}\"", &name))?, | ||||||||
| }; | ||||||||
| let capture_target = resolve_capture_target(&capture_mode)?; | ||||||||
|
|
||||||||
| let inputs = StartRecordingInputs { | ||||||||
| mode, | ||||||||
|
|
@@ -146,6 +206,70 @@ 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 { capture_mode } => { | ||||||||
| let capture_target = resolve_capture_target(&capture_mode)?; | ||||||||
|
|
||||||||
| crate::recording::take_screenshot(app.clone(), capture_target) | ||||||||
| .await | ||||||||
| .map(|_| ()) | ||||||||
| } | ||||||||
| DeepLinkAction::SetMicrophone { label } => { | ||||||||
| let state = app.state::<ArcLock<App>>(); | ||||||||
| crate::set_mic_input(state, label).await | ||||||||
| } | ||||||||
| DeepLinkAction::SetCamera { id } => { | ||||||||
| let state = app.state::<ArcLock<App>>(); | ||||||||
| crate::set_camera_input(app.clone(), state, id).await | ||||||||
| } | ||||||||
| DeepLinkAction::ListDevices => { | ||||||||
| let devices = get_available_devices(); | ||||||||
| let json = serde_json::to_string(&devices).map_err(|e| e.to_string())?; | ||||||||
| println!("CAP_DEEPLINK_RESPONSE:{}", json); | ||||||||
| Ok(()) | ||||||||
|
Comment on lines
+238
to
+242
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. logic: Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 238:242
Comment:
**logic:** `ListDevices` and `GetStatus` actions print JSON to stdout, but there's no mechanism in the Raycast extension to capture this output. The deeplink protocol is fire-and-forget via URL scheme, so the extension cannot receive the response. Should these actions use IPC commands instead of deeplinks, or is there a planned mechanism for capturing stdout from the deeplink handler?
How can I resolve this? If you propose a fix, please make it concise. |
||||||||
| } | ||||||||
| DeepLinkAction::GetStatus => { | ||||||||
| let state = app.state::<ArcLock<App>>(); | ||||||||
| let app_state = state.read().await; | ||||||||
| let status = if let Some(recording) = app_state.current_recording() { | ||||||||
| let is_paused = recording.is_paused().await.unwrap_or(false); | ||||||||
|
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.
Suggested change
|
||||||||
| let mode = match recording { | ||||||||
| crate::recording::InProgressRecording::Instant { .. } => { | ||||||||
| Some("instant".to_string()) | ||||||||
| } | ||||||||
| crate::recording::InProgressRecording::Studio { .. } => { | ||||||||
| Some("studio".to_string()) | ||||||||
| } | ||||||||
| }; | ||||||||
| DeepLinkRecordingStatus { | ||||||||
| is_recording: true, | ||||||||
| is_paused, | ||||||||
| recording_mode: mode, | ||||||||
| } | ||||||||
| } else { | ||||||||
| DeepLinkRecordingStatus { | ||||||||
| is_recording: false, | ||||||||
| is_paused: false, | ||||||||
| recording_mode: None, | ||||||||
| } | ||||||||
| }; | ||||||||
| let json = serde_json::to_string(&status).map_err(|e| e.to_string())?; | ||||||||
| println!("CAP_DEEPLINK_RESPONSE:{}", json); | ||||||||
| Ok(()) | ||||||||
| } | ||||||||
| DeepLinkAction::OpenEditor { project_path } => { | ||||||||
| crate::open_project_from_path(Path::new(&project_path), app.clone()) | ||||||||
| } | ||||||||
|
|
@@ -155,3 +279,37 @@ impl DeepLinkAction { | |||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| fn get_available_devices() -> DeepLinkDevices { | ||||||||
| let cameras: Vec<DeepLinkCamera> = cap_camera::list_cameras() | ||||||||
| .map(|c| DeepLinkCamera { | ||||||||
| name: c.display_name().to_string(), | ||||||||
| id: c.device_id().to_string(), | ||||||||
| }) | ||||||||
| .collect(); | ||||||||
|
|
||||||||
| let microphones: Vec<String> = MicrophoneFeed::list().keys().cloned().collect(); | ||||||||
|
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. Microphone list ordering can be nondeterministic (map key iteration). Sorting makes the output stable.
Suggested change
|
||||||||
|
|
||||||||
| let screens: Vec<DeepLinkScreen> = cap_recording::screen_capture::list_displays() | ||||||||
| .into_iter() | ||||||||
| .map(|(s, _)| DeepLinkScreen { | ||||||||
| name: s.name, | ||||||||
| id: s.id.to_string(), | ||||||||
| }) | ||||||||
| .collect(); | ||||||||
|
|
||||||||
| let windows: Vec<DeepLinkWindow> = cap_recording::screen_capture::list_windows() | ||||||||
| .into_iter() | ||||||||
| .map(|(w, _)| DeepLinkWindow { | ||||||||
| name: w.name, | ||||||||
| owner_name: w.owner_name, | ||||||||
| }) | ||||||||
| .collect(); | ||||||||
|
|
||||||||
| DeepLinkDevices { | ||||||||
| cameras, | ||||||||
| microphones, | ||||||||
| screens, | ||||||||
| windows, | ||||||||
| } | ||||||||
| } | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| # Cap Raycast Extension | ||
|
|
||
| Control [Cap](https://cap.so) screen recording directly from Raycast. | ||
|
|
||
| ## Features | ||
|
|
||
| - **Start Recording** - Start a new screen or window recording (instant or studio mode) | ||
| - **Stop Recording** - Stop the current recording | ||
| - **Pause Recording** - Pause the current recording | ||
| - **Resume Recording** - Resume a paused recording | ||
| - **Toggle Pause** - Toggle pause/resume on the current recording | ||
| - **Take Screenshot** - Capture a screenshot of a screen or window | ||
| - **Recording Status** - Check the current recording status | ||
| - **Open Settings** - Open Cap settings | ||
|
|
||
| ## Requirements | ||
|
|
||
| - [Cap](https://cap.so) desktop app must be installed and running | ||
| - macOS | ||
|
|
||
| ## Installation | ||
|
|
||
| 1. Clone this repository | ||
| 2. Navigate to the `extensions/raycast` directory | ||
| 3. Run `npm install` | ||
| 4. Run `npm run dev` to start development mode | ||
|
|
||
| ## How It Works | ||
|
|
||
| This extension uses Cap's deeplink API to control the app. Commands are sent via the `cap-desktop://` URL scheme. | ||
|
|
||
| ## Deeplink Format | ||
|
|
||
| ``` | ||
| cap-desktop://action?value=<URL-encoded JSON> | ||
| ``` | ||
|
|
||
| ### Available Actions | ||
|
|
||
| | Action | Description | | ||
| |--------|-------------| | ||
| | `get_status` | Get current recording status | | ||
| | `list_devices` | List available cameras, microphones, screens, and windows | | ||
| | `start_recording` | Start a new recording | | ||
| | `stop_recording` | Stop the current recording | | ||
| | `pause_recording` | Pause the current recording | | ||
| | `resume_recording` | Resume a paused recording | | ||
| | `toggle_pause_recording` | Toggle pause state | | ||
| | `restart_recording` | Restart the current recording | | ||
| | `take_screenshot` | Take a screenshot | | ||
| | `set_microphone` | Switch microphone | | ||
| | `set_camera` | Switch camera | | ||
| | `open_settings` | Open Cap settings | | ||
| | `open_editor` | Open a project in the editor | |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,96 @@ | ||||||
| { | ||||||
| "$schema": "https://www.raycast.com/schemas/extension.json", | ||||||
| "name": "cap", | ||||||
|
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. Since
Suggested change
|
||||||
| "title": "Cap", | ||||||
| "description": "Control Cap screen recording from Raycast", | ||||||
| "icon": "icon.png", | ||||||
| "author": "cap", | ||||||
| "categories": ["Productivity", "Applications"], | ||||||
| "license": "MIT", | ||||||
| "commands": [ | ||||||
| { | ||||||
| "name": "start-recording", | ||||||
| "title": "Start Recording", | ||||||
| "subtitle": "Cap", | ||||||
| "description": "Start a new screen recording", | ||||||
| "mode": "view", | ||||||
| "keywords": ["record", "capture", "screen"] | ||||||
| }, | ||||||
| { | ||||||
| "name": "stop-recording", | ||||||
| "title": "Stop Recording", | ||||||
| "subtitle": "Cap", | ||||||
| "description": "Stop the current recording", | ||||||
| "mode": "no-view", | ||||||
| "keywords": ["stop", "end", "finish"] | ||||||
| }, | ||||||
| { | ||||||
| "name": "pause-recording", | ||||||
| "title": "Pause Recording", | ||||||
| "subtitle": "Cap", | ||||||
| "description": "Pause the current recording", | ||||||
| "mode": "no-view", | ||||||
| "keywords": ["pause", "hold"] | ||||||
| }, | ||||||
| { | ||||||
| "name": "resume-recording", | ||||||
| "title": "Resume Recording", | ||||||
| "subtitle": "Cap", | ||||||
| "description": "Resume a paused recording", | ||||||
| "mode": "no-view", | ||||||
| "keywords": ["resume", "continue"] | ||||||
| }, | ||||||
| { | ||||||
| "name": "toggle-pause", | ||||||
| "title": "Toggle Pause", | ||||||
| "subtitle": "Cap", | ||||||
| "description": "Toggle pause/resume on the current recording", | ||||||
| "mode": "no-view", | ||||||
| "keywords": ["toggle", "pause", "resume"] | ||||||
| }, | ||||||
| { | ||||||
| "name": "take-screenshot", | ||||||
| "title": "Take Screenshot", | ||||||
| "subtitle": "Cap", | ||||||
| "description": "Take a screenshot of a screen or window", | ||||||
| "mode": "view", | ||||||
| "keywords": ["screenshot", "capture", "snap"] | ||||||
| }, | ||||||
| { | ||||||
| "name": "recording-status", | ||||||
| "title": "Recording Status", | ||||||
| "subtitle": "Cap", | ||||||
| "description": "Show the current recording status", | ||||||
| "mode": "no-view", | ||||||
| "keywords": ["status", "state"] | ||||||
| }, | ||||||
| { | ||||||
| "name": "open-settings", | ||||||
| "title": "Open Settings", | ||||||
| "subtitle": "Cap", | ||||||
| "description": "Open Cap settings", | ||||||
| "mode": "no-view", | ||||||
| "keywords": ["settings", "preferences", "config"] | ||||||
| } | ||||||
| ], | ||||||
| "dependencies": { | ||||||
| "@raycast/api": "^1.87.0", | ||||||
| "@raycast/utils": "^1.17.0" | ||||||
| }, | ||||||
| "devDependencies": { | ||||||
| "@raycast/eslint-config": "^1.0.11", | ||||||
| "@types/node": "22.10.2", | ||||||
| "@types/react": "19.0.2", | ||||||
| "eslint": "^9.16.0", | ||||||
| "prettier": "^3.4.2", | ||||||
| "typescript": "^5.7.2" | ||||||
| }, | ||||||
| "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" | ||||||
| } | ||||||
| } | ||||||
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.
list_devices/get_statuscurrently print the JSON response to stdout. When invoked via a URL scheme there usually isn’t a stdout consumer, so these responses may never be observable from Raycast. If you need Raycast to read the response, consider a transport it can actually access (clipboard, file, notification, or callback URL).