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
22 changes: 22 additions & 0 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ pub enum DeepLinkAction {
OpenSettings {
page: Option<String>,
},
StartDefaultRecording,
PauseRecording,
ResumeRecording,
}

pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) {
Expand Down Expand Up @@ -88,6 +91,16 @@ impl TryFrom<&Url> for DeepLinkAction {
.map_err(|_| ActionParseFromUrlError::Invalid);
}

if url.scheme() == "cap" {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Host matching is currently case-sensitive, so cap://Stop etc would be rejected. Might be worth using eq_ignore_ascii_case here for resilience.

Suggested change
if url.scheme() == "cap" {
if url.scheme() == "cap" {
return match url.host_str() {
Some(host) if host.eq_ignore_ascii_case("record") => Ok(Self::StartDefaultRecording),
Some(host) if host.eq_ignore_ascii_case("stop") => Ok(Self::StopRecording),
Some(host) if host.eq_ignore_ascii_case("pause") => Ok(Self::PauseRecording),
Some(host) if host.eq_ignore_ascii_case("resume") => Ok(Self::ResumeRecording),
_ => Err(ActionParseFromUrlError::Invalid),
};
}

return match url.host_str() {
Some("record") => Ok(Self::StartDefaultRecording),
Some("stop") => Ok(Self::StopRecording),
Some("pause") => Ok(Self::PauseRecording),
Some("resume") => Ok(Self::ResumeRecording),
_ => Err(ActionParseFromUrlError::Invalid),
};
}
Comment on lines +94 to +102
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 Security: generic cap:// scheme is accessible by any app or web page

Registering cap as a system-wide URL scheme means any application — including browsers that silently follow cap:// links in web pages — can trigger cap://record, cap://stop, cap://pause, and cap://resume without user confirmation. A malicious web page could embed <a href="cap://record"> or automatically redirect to it, invisibly starting a screen capture session.

The existing cap-desktop://action?value=... scheme has the same surface area, but its JSON-in-query-string format is significantly harder to invoke accidentally or from a web page. The new cap:// URLs reduce the exploit complexity to a single, guessable link.

Consider requiring explicit user-facing confirmation (e.g., showing a toast/alert before acting), or at minimum documenting this behavior so users are aware that any local app can control their recording session via these URLs.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 94-102

Comment:
**Security: generic `cap://` scheme is accessible by any app or web page**

Registering `cap` as a system-wide URL scheme means any application — including browsers that silently follow `cap://` links in web pages — can trigger `cap://record`, `cap://stop`, `cap://pause`, and `cap://resume` without user confirmation. A malicious web page could embed `<a href="cap://record">` or automatically redirect to it, invisibly starting a screen capture session.

The existing `cap-desktop://action?value=...` scheme has the same surface area, but its JSON-in-query-string format is significantly harder to invoke accidentally or from a web page. The new `cap://` URLs reduce the exploit complexity to a single, guessable link.

Consider requiring explicit user-facing confirmation (e.g., showing a toast/alert before acting), or at minimum documenting this behavior so users are aware that any local app can control their recording session via these URLs.

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


match url.domain() {
Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction),
_ => Err(ActionParseFromUrlError::Invalid),
Expand Down Expand Up @@ -153,6 +166,15 @@ impl DeepLinkAction {
DeepLinkAction::OpenSettings { page } => {
crate::show_window(app.clone(), ShowCapWindow::Settings { page }).await
}
DeepLinkAction::StartDefaultRecording => {
app.emit("request-open-recording-picker", ()).map_err(|e| e.to_string())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StartDefaultRecording currently emits a stringly-typed event and (from the name) reads like it should start immediately. If the intent is to open the picker, using the existing typed event keeps emit patterns consistent.

Suggested change
app.emit("request-open-recording-picker", ()).map_err(|e| e.to_string())
DeepLinkAction::StartDefaultRecording => {
crate::RequestOpenRecordingPicker { target_mode: None }
.emit(app)
.map_err(|e| e.to_string())
}

}
Comment on lines +169 to +171
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.

P0 Wrong event payload causes frontend deserialization failure

app.emit("request-open-recording-picker", ()) sends a JSON null payload, but the RequestOpenRecordingPicker struct has a target_mode: Option<RecordingTargetMode> field. The auto-generated frontend type expects { target_mode: null }, not null. This payload mismatch will likely cause the event to silently fail or be ignored by the frontend listener.

All other call sites in hotkeys.rs correctly use the typed tauri_specta event. Use the same pattern here:

Suggested change
DeepLinkAction::StartDefaultRecording => {
app.emit("request-open-recording-picker", ()).map_err(|e| e.to_string())
}
DeepLinkAction::StartDefaultRecording => {
RequestOpenRecordingPicker { target_mode: None }.emit(app).map_err(|e| e.to_string())
}

You'll also need to import RequestOpenRecordingPicker at the top of the file (or reference it as crate::RequestOpenRecordingPicker).

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 169-171

Comment:
**Wrong event payload causes frontend deserialization failure**

`app.emit("request-open-recording-picker", ())` sends a JSON `null` payload, but the `RequestOpenRecordingPicker` struct has a `target_mode: Option<RecordingTargetMode>` field. The auto-generated frontend type expects `{ target_mode: null }`, not `null`. This payload mismatch will likely cause the event to silently fail or be ignored by the frontend listener.

All other call sites in `hotkeys.rs` correctly use the typed `tauri_specta` event. Use the same pattern here:

```suggestion
            DeepLinkAction::StartDefaultRecording => {
                RequestOpenRecordingPicker { target_mode: None }.emit(app).map_err(|e| e.to_string())
            }
```

You'll also need to import `RequestOpenRecordingPicker` at the top of the file (or reference it as `crate::RequestOpenRecordingPicker`).

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

DeepLinkAction::PauseRecording => {
crate::recording::pause_recording(app.clone(), app.state()).await
}
DeepLinkAction::ResumeRecording => {
crate::recording::resume_recording(app.clone(), app.state()).await
}
}
}
}
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