feat: add deep-link support and Raycast extension#1708
feat: add deep-link support and Raycast extension#1708Angelebeats wants to merge 7 commits intoCapSoftware:mainfrom
Conversation
| .map_err(|_| ActionParseFromUrlError::Invalid); | ||
| } | ||
|
|
||
| if url.scheme() == "cap" { |
There was a problem hiding this comment.
Host matching is currently case-sensitive, so cap://Stop etc would be rejected. Might be worth using eq_ignore_ascii_case here for resilience.
| 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), | |
| }; | |
| } |
| crate::show_window(app.clone(), ShowCapWindow::Settings { page }).await | ||
| } | ||
| DeepLinkAction::StartDefaultRecording => { | ||
| app.emit("request-open-recording-picker", ()).map_err(|e| e.to_string()) |
There was a problem hiding this comment.
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.
| 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()) | |
| } |
| DeepLinkAction::StartDefaultRecording => { | ||
| app.emit("request-open-recording-picker", ()).map_err(|e| e.to_string()) | ||
| } |
There was a problem hiding this 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:
| 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.| if url.scheme() == "cap" { | ||
| 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), | ||
| }; | ||
| } |
There was a problem hiding this 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.
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.| .map_err(|_| ActionParseFromUrlError::Invalid); | ||
| } | ||
|
|
||
| if url.scheme().eq_ignore_ascii_case("cap") { |
There was a problem hiding this comment.
This currently accepts any path after the host (e.g. cap://record/anything). If the intent is to support only the exact host actions, consider rejecting non-root paths to avoid surprising matches.
| if url.scheme().eq_ignore_ascii_case("cap") { | |
| if url.scheme().eq_ignore_ascii_case("cap") { | |
| if url.path() != "/" { | |
| return Err(ActionParseFromUrlError::Invalid); | |
| } | |
| return match url.host_str() { | |
| Some(h) if h.eq_ignore_ascii_case("record") => Ok(Self::StartDefaultRecording), | |
| Some(h) if h.eq_ignore_ascii_case("stop") => Ok(Self::StopRecording), | |
| Some(h) if h.eq_ignore_ascii_case("pause") => Ok(Self::PauseRecording), | |
| Some(h) if h.eq_ignore_ascii_case("resume") => Ok(Self::ResumeRecording), | |
| _ => Err(ActionParseFromUrlError::Invalid), | |
| }; | |
| } |
| | DeepLinkAction::StartDefaultRecording | ||
| | DeepLinkAction::PauseRecording | ||
| | DeepLinkAction::ResumeRecording => { | ||
| crate::notifications::NotificationType::DeepLinkTriggered.send(app); |
There was a problem hiding this comment.
NotificationType::DeepLinkTriggered is gated by enable_notifications (see send_notification). If this is meant as a security heads-up, users who disabled notifications won’t get any signal that a deep-link triggered recording actions. Might be worth using an always-on in-app toast for deep links, or bypassing that setting just for this case.
…or deep-link security
…command to toggle
| let enable_notifications = GeneralSettingsStore::get(app) | ||
| .map(|settings| settings.is_some_and(|s| s.enable_notifications)) | ||
| .unwrap_or(false); | ||
| pub fn send_notification(app: &tauri::AppHandle, notification_type: NotificationType, always: bool) { |
There was a problem hiding this comment.
Changing send_notification to require always is a breaking API change — any existing send_notification(app, ty) call sites will stop compiling. Might be cleaner to keep the 2-arg send_notification and add a separate send_notification_always (or a private inner helper) for the bypass.
| return match url.host_str() { | ||
| Some(h) if h.eq_ignore_ascii_case("record") => Ok(Self::StartDefaultRecording), | ||
| Some(h) if h.eq_ignore_ascii_case("stop") => Ok(Self::StopRecording), | ||
| Some(h) if h.eq_ignore_ascii_case("pause") => Ok(Self::TogglePauseRecording), |
There was a problem hiding this comment.
Minor: cap://pause maps to TogglePauseRecording, but the enum still has PauseRecording (and an execute arm). If it’s not used by the JSON deep-link path, consider dropping it to reduce confusion / surface area.
| | DeepLinkAction::StartDefaultRecording | ||
| | DeepLinkAction::ResumeRecording | ||
| | DeepLinkAction::TogglePauseRecording => { | ||
| crate::notifications::NotificationType::DeepLinkTriggered.send_always(app); |
There was a problem hiding this comment.
This will fire an always-on OS notification for every deep-link action (including pause/resume toggles), which feels like it could get pretty noisy for Raycast/automation and also bypasses the user's notification preference. Consider only forcing the notification for actions that start recording, and let the others respect the setting.
| crate::notifications::NotificationType::DeepLinkTriggered.send_always(app); | |
| match &self { | |
| DeepLinkAction::StartRecording { .. } | DeepLinkAction::StartDefaultRecording => { | |
| crate::notifications::NotificationType::DeepLinkTriggered.send_always(app); | |
| } | |
| DeepLinkAction::StopRecording | |
| | DeepLinkAction::ResumeRecording | |
| | DeepLinkAction::TogglePauseRecording => { | |
| crate::notifications::NotificationType::DeepLinkTriggered.send(app); | |
| } | |
| _ => {} | |
| } |
… security notifications
| return Err(ActionParseFromUrlError::Invalid); | ||
| } | ||
|
|
||
| return match url.host_str().map(|h| h.to_lowercase().as_str()) { |
There was a problem hiding this comment.
url.host_str().map(|h| h.to_lowercase().as_str()) returns an &str pointing at a temporary String (won’t compile). Matching on host_str() directly avoids the allocation and fixes the lifetime issue.
| return match url.host_str().map(|h| h.to_lowercase().as_str()) { | |
| if scheme == "cap" { | |
| let path = url.path().trim_matches("/"); | |
| if !path.is_empty() { | |
| return Err(ActionParseFromUrlError::Invalid); | |
| } | |
| 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::TogglePauseRecording), | |
| Some(host) if host.eq_ignore_ascii_case("resume") => Ok(Self::ResumeRecording), | |
| _ => Err(ActionParseFromUrlError::Invalid), | |
| }; | |
| } |
This PR adds deep-link support to Cap (cap://record, cap://stop, cap://pause) and includes a dedicated Raycast extension for remote control.
Greptile Summary
This PR adds three new deep-link actions (
StartDefaultRecording,PauseRecording,ResumeRecording) and a newcap://URL scheme to complement the existingcap-desktop://scheme, enabling Raycast and other external tools to remotely control Cap's recording lifecycle viacap://record,cap://stop,cap://pause, andcap://resume.Key issues found:
StartDefaultRecording:app.emit("request-open-recording-picker", ())emits a JSONnullpayload, but theRequestOpenRecordingPickertauri_specta struct expects{ "target_mode": null }. Every other call site inhotkeys.rscorrectly usesRequestOpenRecordingPicker { target_mode: None }.emit(&app). This mismatch will cause the frontend event listener to receive an unexpected payload, likely causingcap://recordto silently do nothing.cap://scheme expands the attack surface: Registering the short, guessablecapscheme system-wide means any local application or browser link (e.g., a<a href="cap://record">on a web page) can invisibly trigger screen recording. The existingcap-desktop://action?value=...format was harder to exploit accidentally; the new URLs remove that friction.Confidence Score: 2/5
cap://recordaction will silently fail due to a payload type mismatch in the emitted event.StartDefaultRecordinghandler emits therequest-open-recording-pickerevent with a()(unit/null) payload instead of the typedRequestOpenRecordingPicker { target_mode: None }struct used everywhere else in the codebase. This payload mismatch means the primary advertised feature (cap://recordopening the recording picker) will not function correctly. The fix is straightforward but the bug must be resolved before merging.apps/desktop/src-tauri/src/deeplink_actions.rs— specifically theStartDefaultRecordingarm in theexecutematch.Important Files Changed
StartDefaultRecording,PauseRecording, andResumeRecordingvariants and acap://URL parsing branch; contains a P0 event payload bug —app.emit("request-open-recording-picker", ())emitsnullinstead of the expected{ target_mode: null }, causing frontend deserialization failure. Also bypasses the typedtauri_spectaevent system used everywhere else in the codebase."cap"alongside"cap-desktop"in the deep-link scheme registration; mechanically correct but introduces a short, guessable system-wide scheme.Sequence Diagram
sequenceDiagram participant Raycast as Raycast or Browser participant OS as OS Deep-Link Router participant Tauri as Tauri Plugin participant Handler as deeplink_actions participant Recording as recording module participant Frontend as Frontend Webview Raycast->>OS: "open cap://record" OS->>Tauri: "cap scheme triggered" Tauri->>Handler: "urls: [cap://record]" Handler->>Handler: "parse: scheme==cap, host==record" Handler->>Handler: "Ok(StartDefaultRecording)" Handler->>Frontend: "app.emit(request-open-recording-picker, null) ⚠️" Note over Frontend: "Expected payload: {target_mode:null}, got: null" Raycast->>OS: "open cap://stop" OS->>Tauri: "cap scheme triggered" Tauri->>Handler: "urls: [cap://stop]" Handler->>Handler: "Ok(StopRecording)" Handler->>Recording: "stop_recording(app, state)" Raycast->>OS: "open cap://pause" OS->>Tauri: "cap scheme triggered" Tauri->>Handler: "urls: [cap://pause]" Handler->>Handler: "Ok(PauseRecording)" Handler->>Recording: "pause_recording(app, state)" Raycast->>OS: "open cap://resume" OS->>Tauri: "cap scheme triggered" Tauri->>Handler: "urls: [cap://resume]" Handler->>Handler: "Ok(ResumeRecording)" Handler->>Recording: "resume_recording(app, state)"Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "feat: implement cap:// deep-link protoco..." | Re-trigger Greptile