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
90 changes: 85 additions & 5 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
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")]
Expand All @@ -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>,
Expand Down Expand Up @@ -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()
Expand All @@ -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();

Comment on lines +132 to +134
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 Silent error discard for mic/camera setup differs from StartRecording path

set_mic_input and set_camera_input errors are swallowed with let _ =, so a recording can silently start without the user's saved microphone or camera. The StartRecording arm 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.

Suggested change
.buttons(MessageDialogButtons::OkCancel)
.blocking_show();
crate::set_mic_input(state.clone(), settings.mic_name).await?;
crate::set_camera_input(app.clone(), state.clone(), settings.camera_id, None)
.await?;
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 131-133

Comment:
**Silent error discard for mic/camera setup differs from `StartRecording` path**

`set_mic_input` and `set_camera_input` errors are swallowed with `let _ =`, so a recording can silently start without the user's saved microphone or camera. The `StartRecording` arm 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.

```suggestion
                crate::set_mic_input(state.clone(), settings.mic_name).await?;
                crate::set_camera_input(app.clone(), state.clone(), settings.camera_id, None)
                    .await?;
```

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

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,
Expand Down Expand Up @@ -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
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 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.

Prompt To Fix With AI
This 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));
}
}
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"updater": { "active": false, "pubkey": "" },
"deep-link": {
"desktop": {
"schemes": ["cap-desktop"]
"schemes": ["cap-desktop", "cap"]
}
}
},
Expand Down
14 changes: 14 additions & 0 deletions extensions/raycast/README.md
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.
Binary file added extensions/raycast/assets/cap-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions extensions/raycast/package.json
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"
}
}
14 changes: 14 additions & 0 deletions extensions/raycast/src/open-dashboard.ts
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),
});
}
}
15 changes: 15 additions & 0 deletions extensions/raycast/src/start-recording.ts
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),
});
}
}
15 changes: 15 additions & 0 deletions extensions/raycast/src/stop-recording.ts
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),
});
}
}
11 changes: 11 additions & 0 deletions extensions/raycast/tsconfig.json
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"]
}