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
76 changes: 61 additions & 15 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use cap_recording::{
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Url};
use tracing::trace;
use tracing::{warn, error};

use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow};

Expand Down Expand Up @@ -32,25 +32,30 @@ pub enum DeepLinkAction {
OpenSettings {
page: Option<String>,
},
StartDefaultRecording,
ResumeRecording,
TogglePauseRecording,
}

pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) {
trace!("Handling deep actions for: {:?}", &urls);

let actions: Vec<_> = urls
.into_iter()
.filter(|url| !url.as_str().is_empty())
.filter_map(|url| {
DeepLinkAction::try_from(&url)
.map_err(|e| match e {
ActionParseFromUrlError::ParseFailed(msg) => {
eprintln!("Failed to parse deep link \"{}\": {}", &url, msg)
}
ActionParseFromUrlError::Invalid => {
eprintln!("Invalid deep link format \"{}\"", &url)
.map_err(|e| {
let mut safe_url = url.clone();
safe_url.set_query(None);
safe_url.set_fragment(None);
match e {
ActionParseFromUrlError::ParseFailed(msg) => {
error!("Failed to parse deep link \"{}\": {}", safe_url, msg)
}
ActionParseFromUrlError::Invalid => {
warn!("Invalid deep link format \"{}\"", safe_url)
}
ActionParseFromUrlError::NotAction => {}
}
// Likely login action, not handled here.
ActionParseFromUrlError::NotAction => {}
})
.ok()
})
Expand All @@ -64,7 +69,7 @@ pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) {
tauri::async_runtime::spawn(async move {
for action in actions {
if let Err(e) = action.execute(&app_handle).await {
eprintln!("Failed to handle deep link action: {e}");
error!("Failed to handle deep link action: {e}");
}
}
});
Expand All @@ -80,14 +85,35 @@ impl TryFrom<&Url> for DeepLinkAction {
type Error = ActionParseFromUrlError;

fn try_from(url: &Url) -> Result<Self, Self::Error> {
let scheme = url.scheme().to_lowercase();

#[cfg(target_os = "macos")]
if url.scheme() == "file" {
if scheme == "file" {
return url
.to_file_path()
.map(|project_path| Self::OpenEditor { project_path })
.map_err(|_| ActionParseFromUrlError::Invalid);
}

if 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.

cap://record works, but some launchers produce cap:///record (no host, path-only). Accepting a single path segment as a fallback makes this a bit more robust without changing behavior for normal URLs.

Suggested change
if scheme == "cap" {
if scheme == "cap" {
let path = url.path().trim_matches('/');
let (action, extra) = match (url.host_str(), path) {
(Some(host), "") => (host, ""),
(Some(_), _) => return Err(ActionParseFromUrlError::Invalid),
(None, "") => return Err(ActionParseFromUrlError::Invalid),
(None, path) => {
let mut parts = path.split('/');
(parts.next().unwrap_or(""), parts.next().unwrap_or(""))
}
};
if !extra.is_empty() {
return Err(ActionParseFromUrlError::Invalid);
}
return match action {
host if host.eq_ignore_ascii_case("record") => Ok(Self::StartDefaultRecording),
host if host.eq_ignore_ascii_case("stop") => Ok(Self::StopRecording),
host if host.eq_ignore_ascii_case("pause") => Ok(Self::TogglePauseRecording),
host if host.eq_ignore_ascii_case("resume") => Ok(Self::ResumeRecording),
_ => Err(ActionParseFromUrlError::Invalid),
};
}

let host = url.host_str().unwrap_or_default();
let path = url.path().trim_matches('/');

let action = if host.eq_ignore_ascii_case("record") || path.eq_ignore_ascii_case("record") {
Some(Self::StartDefaultRecording)
} else if host.eq_ignore_ascii_case("stop") || path.eq_ignore_ascii_case("stop") {
Some(Self::StopRecording)
} else if host.eq_ignore_ascii_case("pause") || path.eq_ignore_ascii_case("pause") {
Some(Self::TogglePauseRecording)
} else if host.eq_ignore_ascii_case("resume") || path.eq_ignore_ascii_case("resume") {
Some(Self::ResumeRecording)
} else {
None
};

return action.ok_or(ActionParseFromUrlError::Invalid);
}

match url.domain() {
Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction),
_ => Err(ActionParseFromUrlError::Invalid),
Expand All @@ -107,6 +133,17 @@ impl TryFrom<&Url> for DeepLinkAction {

impl DeepLinkAction {
pub async fn execute(self, app: &AppHandle) -> Result<(), String> {
match &self {
DeepLinkAction::StartRecording { .. }
| DeepLinkAction::StartDefaultRecording
| DeepLinkAction::StopRecording
| DeepLinkAction::ResumeRecording
| DeepLinkAction::TogglePauseRecording => {
crate::notifications::NotificationType::DeepLinkTriggered.send_always(app);
}
_ => {}
}

match self {
DeepLinkAction::StartRecording {
capture_mode,
Expand All @@ -125,12 +162,12 @@ impl DeepLinkAction {
.into_iter()
.find(|(s, _)| s.name == name)
.map(|(s, _)| ScreenCaptureTarget::Display { id: s.id })
.ok_or(format!("No screen with name \"{}\"", &name))?,
.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(format!("No window with name \"{}\"", &name))?,
.ok_or_else(|| format!("No window with name \"{}\"", &name))?,
};

let inputs = StartRecordingInputs {
Expand All @@ -153,6 +190,15 @@ impl DeepLinkAction {
DeepLinkAction::OpenSettings { page } => {
crate::show_window(app.clone(), ShowCapWindow::Settings { page }).await
}
DeepLinkAction::StartDefaultRecording => {
crate::RequestOpenRecordingPicker { target_mode: None }.emit(app).map_err(|e| e.to_string())
}
DeepLinkAction::ResumeRecording => {
crate::recording::resume_recording(app.clone(), app.state()).await
}
DeepLinkAction::TogglePauseRecording => {
crate::recording::toggle_pause_recording(app.clone(), app.state()).await
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

cap://pause currently maps to TogglePauseRecording, so sending pause twice will resume. If the intent is separate pause/resume deep links, this arm probably wants to pause only.

Suggested change
crate::recording::toggle_pause_recording(app.clone(), app.state()).await
DeepLinkAction::TogglePauseRecording => {
crate::recording::pause_recording(app.clone(), app.state()).await
}

}
}
}
}
26 changes: 23 additions & 3 deletions apps/desktop/src-tauri/src/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub enum NotificationType {
ScreenshotCopiedToClipboard,
ScreenshotSaveFailed,
ScreenshotCopyFailed,
DeepLinkTriggered,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since DeepLinkTriggered is sent via send_always (bypassing user notification preference), consider also suppressing the notification sound for it so other apps can’t spam audio via deep links.

Suggested change
DeepLinkTriggered,
let skip_sound = matches!(
notification_type,
NotificationType::DeepLinkTriggered
| NotificationType::ScreenshotSaved
| NotificationType::ScreenshotCopiedToClipboard
| NotificationType::ScreenshotSaveFailed
| NotificationType::ScreenshotCopyFailed
);

}

impl NotificationType {
Expand Down Expand Up @@ -62,6 +63,11 @@ impl NotificationType {
"Unable to copy screenshot to clipboard. Please try again",
true,
),
NotificationType::DeepLinkTriggered => (
"Action Triggered",
"An action was triggered via a deep link",
false,
),
}
}

Expand All @@ -86,12 +92,25 @@ impl NotificationType {
pub fn send(self, app: &tauri::AppHandle) {
send_notification(app, self);
}

pub fn send_always(self, app: &tauri::AppHandle) {
send_notification_always(app, self);
}
}

pub fn send_notification(app: &tauri::AppHandle, notification_type: NotificationType) {
let enable_notifications = GeneralSettingsStore::get(app)
.map(|settings| settings.is_some_and(|s| s.enable_notifications))
.unwrap_or(false);
_send_notification(app, notification_type, false);
}

pub fn send_notification_always(app: &tauri::AppHandle, notification_type: NotificationType) {
_send_notification(app, notification_type, true);
}

fn _send_notification(app: &tauri::AppHandle, notification_type: NotificationType, always: bool) {
let enable_notifications = always
|| GeneralSettingsStore::get(app)
.map(|settings| settings.is_some_and(|s| s.enable_notifications))
.unwrap_or(false);

if !enable_notifications {
return;
Expand All @@ -112,6 +131,7 @@ pub fn send_notification(app: &tauri::AppHandle, notification_type: Notification
| NotificationType::ScreenshotCopiedToClipboard
| NotificationType::ScreenshotSaveFailed
| NotificationType::ScreenshotCopyFailed
| NotificationType::DeepLinkTriggered
);

if !skip_sound {
Expand Down
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"]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Registering the short cap scheme makes it very easy for other local apps (and sometimes browsers) to trigger recording actions. Since this expands the surface area, consider making the deep-link notification unconditionally visible for all deep-link actions (not just start), or gating deep-link control behind an explicit user setting.

}
}
},
Expand Down