-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat(desktop): add cap:// deep links and minimal Raycast controls #1830
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 |
|---|---|---|
| @@ -1,12 +1,17 @@ | ||
| use cap_recording::{ | ||
| RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget, | ||
| }; | ||
| use scap_targets::Display; | ||
| use serde::{Deserialize, Serialize}; | ||
| use std::path::{Path, PathBuf}; | ||
| use tauri::{AppHandle, Manager, Url}; | ||
| use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; | ||
| use tracing::trace; | ||
|
|
||
| use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; | ||
| use crate::{ | ||
| App, ArcLock, recording::StartRecordingInputs, recording_settings::RecordingSettingsStore, | ||
| windows::ShowCapWindow, | ||
| }; | ||
|
|
||
| #[derive(Debug, Serialize, Deserialize)] | ||
| #[serde(rename_all = "snake_case")] | ||
|
|
@@ -18,6 +23,7 @@ pub enum CaptureMode { | |
| #[derive(Debug, Serialize, Deserialize)] | ||
| #[serde(rename_all = "snake_case")] | ||
| pub enum DeepLinkAction { | ||
| StartDefaultRecording, | ||
| StartRecording { | ||
| capture_mode: CaptureMode, | ||
| camera: Option<DeviceOrModelID>, | ||
|
|
@@ -88,10 +94,19 @@ impl TryFrom<&Url> for DeepLinkAction { | |
| .map_err(|_| ActionParseFromUrlError::Invalid); | ||
| } | ||
|
|
||
| match url.domain() { | ||
| Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction), | ||
| _ => Err(ActionParseFromUrlError::Invalid), | ||
| }?; | ||
| if url.scheme() == "cap" { | ||
| return match url.host_str() { | ||
| Some("record") => Ok(Self::StartDefaultRecording), | ||
| Some("stop") => Ok(Self::StopRecording), | ||
| _ => Err(ActionParseFromUrlError::Invalid), | ||
| }; | ||
| } | ||
|
|
||
| match url.host_str() { | ||
| Some("action") => {} | ||
| Some(_) => return Err(ActionParseFromUrlError::NotAction), | ||
| None => return Err(ActionParseFromUrlError::Invalid), | ||
| } | ||
|
|
||
| let params = url | ||
| .query_pairs() | ||
|
|
@@ -108,6 +123,44 @@ impl TryFrom<&Url> for DeepLinkAction { | |
| impl DeepLinkAction { | ||
| pub async fn execute(self, app: &AppHandle) -> Result<(), String> { | ||
| match self { | ||
| DeepLinkAction::StartDefaultRecording => { | ||
| let proceed = app | ||
| .dialog() | ||
| .message("Start a new recording from an external deep link?") | ||
| .title("Cap") | ||
| .kind(MessageDialogKind::Info) | ||
| .buttons(MessageDialogButtons::OkCancel) | ||
| .blocking_show(); | ||
|
|
||
| if !proceed { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let state = app.state::<ArcLock<App>>(); | ||
| let settings = RecordingSettingsStore::get(app) | ||
| .ok() | ||
| .flatten() | ||
| .unwrap_or_default(); | ||
|
|
||
| crate::set_mic_input(state.clone(), settings.mic_name).await?; | ||
| crate::set_camera_input(app.clone(), state.clone(), settings.camera_id, None) | ||
| .await?; | ||
|
|
||
| let inputs = StartRecordingInputs { | ||
| capture_target: settings.target.unwrap_or_else(|| { | ||
| ScreenCaptureTarget::Display { | ||
| id: Display::primary().id(), | ||
| } | ||
| }), | ||
| mode: settings.mode.unwrap_or_default(), | ||
| capture_system_audio: settings.system_audio, | ||
| organization_id: settings.organization_id, | ||
| }; | ||
|
|
||
| crate::recording::start_recording(app.clone(), state, inputs) | ||
| .await | ||
| .map(|_| ()) | ||
| } | ||
| DeepLinkAction::StartRecording { | ||
| capture_mode, | ||
| camera, | ||
|
|
@@ -156,3 +209,30 @@ impl DeepLinkAction { | |
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn parses_cap_record_as_start_default_recording() { | ||
| let url = Url::parse("cap://record").expect("valid cap record url"); | ||
| let action = DeepLinkAction::try_from(&url).expect("cap record should parse"); | ||
| assert!(matches!(action, DeepLinkAction::StartDefaultRecording)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parses_cap_stop_as_stop_recording() { | ||
|
Comment on lines
+218
to
+225
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 Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 217-224
Comment:
**`parses_existing_action_format` test will always fail**
The `match url.domain()` block that precedes the JSON-parsing code has no arm that returns `Ok` — both arms always return `Err(...)`, so the `?` at the end short-circuits and the function exits with `Err(Invalid)` before it ever reaches `query_pairs()` or `serde_json::from_str`. For `cap-desktop://action?value=...`, `url.domain()` returns `Some("action")`, which falls through to the `_ => Err(ActionParseFromUrlError::Invalid)` arm. The test therefore panics at `.expect("action deep link should parse")`. Since Rust toolchain was unavailable when authoring this PR, this was not caught.
How can I resolve this? If you propose a fix, please make it concise. |
||
| let url = Url::parse("cap://stop").expect("valid cap stop url"); | ||
| let action = DeepLinkAction::try_from(&url).expect("cap stop should parse"); | ||
| assert!(matches!(action, DeepLinkAction::StopRecording)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parses_existing_action_format() { | ||
| let url = Url::parse("cap-desktop://action?value=%7B%22stop_recording%22%3Anull%7D") | ||
| .expect("valid action deep link"); | ||
| let action = DeepLinkAction::try_from(&url).expect("action deep link should parse"); | ||
| assert!(matches!(action, DeepLinkAction::StopRecording)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # Cap Raycast Extension | ||
|
|
||
| Lightweight Raycast commands for Cap Desktop. | ||
|
|
||
| ## Commands | ||
| - Start Recording: opens `cap://record` and starts recording with your current Cap defaults. | ||
| - Stop Recording: opens `cap://stop` and stops the active recording. | ||
| - Open Dashboard: opens `https://cap.so/dashboard` in your browser. | ||
|
|
||
| ## Local usage | ||
| 1. `cd extensions/raycast` | ||
| 2. `npm install` | ||
| 3. `npm run dev` | ||
| 4. In Raycast, import this extension from the local folder. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| { | ||
| "$schema": "https://www.raycast.com/schemas/extension.json", | ||
| "name": "cap", | ||
| "title": "Cap", | ||
| "description": "Control Cap Desktop recording from Raycast", | ||
| "icon": "assets/cap-icon.png", | ||
| "author": "cap-software", | ||
| "categories": ["Productivity", "Developer Tools"], | ||
| "license": "MIT", | ||
| "commands": [ | ||
| { | ||
| "name": "start-recording", | ||
| "title": "Start Recording", | ||
| "description": "Start recording in Cap with your current default settings", | ||
| "mode": "no-view" | ||
| }, | ||
| { | ||
| "name": "stop-recording", | ||
| "title": "Stop Recording", | ||
| "description": "Stop the current Cap recording", | ||
| "mode": "no-view" | ||
| }, | ||
| { | ||
| "name": "open-dashboard", | ||
| "title": "Open Dashboard", | ||
| "description": "Open Cap dashboard in your browser", | ||
| "mode": "no-view" | ||
| } | ||
| ], | ||
| "dependencies": { | ||
| "@raycast/api": "^1.83.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@raycast/eslint-config": "^1.0.11", | ||
| "@types/node": "20.8.10", | ||
| "eslint": "^8.57.0", | ||
| "prettier": "^3.3.3", | ||
| "typescript": "^5.4.5" | ||
| }, | ||
| "scripts": { | ||
| "build": "ray build --skip-types -e dist -o dist", | ||
| "dev": "ray develop", | ||
| "fix-lint": "ray lint --fix", | ||
| "lint": "ray lint", | ||
| "prepublishOnly": "echo \"\\n\\nUse npm run publish for Raycast Store release.\\n\" && exit 1", | ||
| "publish": "ray publish" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { closeMainWindow, open, showToast, Toast } from "@raycast/api"; | ||
|
|
||
| export default async function Command() { | ||
| try { | ||
| await closeMainWindow(); | ||
| await open("https://cap.so/dashboard"); | ||
| } catch (error) { | ||
| await showToast({ | ||
| style: Toast.Style.Failure, | ||
| title: "Failed to open dashboard", | ||
| message: String(error), | ||
| }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { closeMainWindow, open, showHUD, showToast, Toast } from "@raycast/api"; | ||
|
|
||
| export default async function Command() { | ||
| try { | ||
| await closeMainWindow(); | ||
| await open("cap://record"); | ||
| await showHUD("Sent start request to Cap"); | ||
| } catch (error) { | ||
| await showToast({ | ||
| style: Toast.Style.Failure, | ||
| title: "Failed to start Cap recording", | ||
| message: String(error), | ||
| }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { closeMainWindow, open, showHUD, showToast, Toast } from "@raycast/api"; | ||
|
|
||
| export default async function Command() { | ||
| try { | ||
| await closeMainWindow(); | ||
| await open("cap://stop"); | ||
| await showHUD("Sent stop request to Cap"); | ||
| } catch (error) { | ||
| await showToast({ | ||
| style: Toast.Style.Failure, | ||
| title: "Failed to stop Cap recording", | ||
| message: String(error), | ||
| }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "target": "ES2022", | ||
| "module": "ES2022", | ||
| "moduleResolution": "Bundler", | ||
| "lib": ["ES2022"], | ||
| "strict": true, | ||
| "skipLibCheck": true | ||
| }, | ||
| "include": ["src"] | ||
| } |
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.
StartRecordingpathset_mic_inputandset_camera_inputerrors are swallowed withlet _ =, so a recording can silently start without the user's saved microphone or camera. TheStartRecordingarm immediately below uses?to propagate the same calls as hard errors — the inconsistency means default-recording failures are invisible while explicit-payload failures surface correctly.Prompt To Fix With AI