Skip to content

Commit b73d2bc

Browse files
Den A EvDen A Ev
authored andcommitted
feat: implement stable deep-link remote control via single-instance
1 parent bd987b3 commit b73d2bc

5 files changed

Lines changed: 116 additions & 115 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
"@tanstack/solid-query": "^5.51.21",
4343
"@tauri-apps/api": "2.8.0",
4444
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
45-
"@tauri-apps/plugin-deep-link": "^2.4.1",
4645
"@tauri-apps/plugin-dialog": "^2.4.0",
4746
"@tauri-apps/plugin-fs": "^2.4.1",
4847
"@tauri-apps/plugin-http": "^2.5.1",

apps/desktop/src-tauri/src/deeplink_actions.rs

Lines changed: 82 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use cap_recording::{
33
};
44
use serde::{Deserialize, Serialize};
55
use std::path::{Path, PathBuf};
6-
use tauri::{AppHandle, Manager, Url};
6+
use tauri::{AppHandle, Emitter, Manager, Url};
77
use tracing::trace;
88

99
use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow};
@@ -25,6 +25,12 @@ pub enum DeepLinkAction {
2525
capture_system_audio: bool,
2626
mode: RecordingMode,
2727
},
28+
TogglePauseRecording,
29+
TakeScreenshot,
30+
SetCamera { id: String },
31+
SetMicrophone { label: String },
32+
ListCameras,
33+
ListMicrophones,
2834
StopRecording,
2935
OpenEditor {
3036
project_path: PathBuf,
@@ -34,42 +40,6 @@ pub enum DeepLinkAction {
3440
},
3541
}
3642

37-
pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) {
38-
trace!("Handling deep actions for: {:?}", &urls);
39-
40-
let actions: Vec<_> = urls
41-
.into_iter()
42-
.filter(|url| !url.as_str().is_empty())
43-
.filter_map(|url| {
44-
DeepLinkAction::try_from(&url)
45-
.map_err(|e| match e {
46-
ActionParseFromUrlError::ParseFailed(msg) => {
47-
eprintln!("Failed to parse deep link \"{}\": {}", &url, msg)
48-
}
49-
ActionParseFromUrlError::Invalid => {
50-
eprintln!("Invalid deep link format \"{}\"", &url)
51-
}
52-
// Likely login action, not handled here.
53-
ActionParseFromUrlError::NotAction => {}
54-
})
55-
.ok()
56-
})
57-
.collect();
58-
59-
if actions.is_empty() {
60-
return;
61-
}
62-
63-
let app_handle = app_handle.clone();
64-
tauri::async_runtime::spawn(async move {
65-
for action in actions {
66-
if let Err(e) = action.execute(&app_handle).await {
67-
eprintln!("Failed to handle deep link action: {e}");
68-
}
69-
}
70-
});
71-
}
72-
7343
pub enum ActionParseFromUrlError {
7444
ParseFailed(String),
7545
Invalid,
@@ -80,78 +50,108 @@ impl TryFrom<&Url> for DeepLinkAction {
8050
type Error = ActionParseFromUrlError;
8151

8252
fn try_from(url: &Url) -> Result<Self, Self::Error> {
83-
#[cfg(target_os = "macos")]
84-
if url.scheme() == "file" {
85-
return url
86-
.to_file_path()
87-
.map(|project_path| Self::OpenEditor { project_path })
88-
.map_err(|_| ActionParseFromUrlError::Invalid);
53+
if url.scheme() != "cap-desktop" {
54+
return Err(ActionParseFromUrlError::NotAction);
8955
}
9056

9157
match url.domain() {
92-
Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction),
93-
_ => Err(ActionParseFromUrlError::Invalid),
94-
}?;
95-
96-
let params = url
97-
.query_pairs()
98-
.collect::<std::collections::HashMap<_, _>>();
99-
let json_value = params
100-
.get("value")
101-
.ok_or(ActionParseFromUrlError::Invalid)?;
102-
let action: Self = serde_json::from_str(json_value)
103-
.map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?;
104-
Ok(action)
58+
Some("toggle-pause") => Ok(Self::TogglePauseRecording),
59+
Some("stop") => Ok(Self::StopRecording),
60+
Some("screenshot") => Ok(Self::TakeScreenshot),
61+
_ => {
62+
// Original JSON path
63+
if url.domain() == Some("action") {
64+
let params = url.query_pairs().collect::<std::collections::HashMap<_, _>>();
65+
let json_value = params.get("value").ok_or(ActionParseFromUrlError::Invalid)?;
66+
let action: Self = serde_json::from_str(json_value)
67+
.map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?;
68+
Ok(action)
69+
} else {
70+
Err(ActionParseFromUrlError::Invalid)
71+
}
72+
}
73+
}
10574
}
10675
}
10776

10877
impl DeepLinkAction {
109-
pub async fn execute(self, app: &AppHandle) -> Result<(), String> {
78+
pub async fn execute(&self, app: &AppHandle) -> anyhow::Result<()> {
11079
match self {
111-
DeepLinkAction::StartRecording {
80+
Self::TogglePauseRecording => {
81+
app.emit("recording-action", "toggle-pause")
82+
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
83+
Ok(())
84+
}
85+
Self::StopRecording => {
86+
app.emit("recording-action", "stop")
87+
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
88+
Ok(())
89+
}
90+
Self::TakeScreenshot => {
91+
app.emit("recording-action", "screenshot")
92+
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
93+
Ok(())
94+
}
95+
Self::StartRecording {
11296
capture_mode,
11397
camera,
11498
mic_label,
11599
capture_system_audio,
116100
mode,
117101
} => {
118-
let state = app.state::<ArcLock<App>>();
102+
let state = app.state::<crate::ArcLock<crate::App>>();
119103

120-
crate::set_camera_input(app.clone(), state.clone(), camera, None).await?;
121-
crate::set_mic_input(state.clone(), mic_label).await?;
122-
123-
let capture_target: ScreenCaptureTarget = match capture_mode {
124-
CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays()
104+
let capture_target: cap_recording::sources::screen_capture::ScreenCaptureTarget = match capture_mode {
105+
CaptureMode::Screen(name) => cap_recording::sources::screen_capture::list_displays()
125106
.into_iter()
126-
.find(|(s, _)| s.name == name)
127-
.map(|(s, _)| ScreenCaptureTarget::Display { id: s.id })
128-
.ok_or(format!("No screen with name \"{}\"", &name))?,
129-
CaptureMode::Window(name) => cap_recording::screen_capture::list_windows()
107+
.find(|(s, _)| s.name == *name)
108+
.map(|(s, _)| cap_recording::sources::screen_capture::ScreenCaptureTarget::Display { id: s.id })
109+
.ok_or(anyhow::anyhow!("No screen with name \"{}\"", &name))?,
110+
CaptureMode::Window(name) => cap_recording::sources::screen_capture::list_windows()
130111
.into_iter()
131-
.find(|(w, _)| w.name == name)
132-
.map(|(w, _)| ScreenCaptureTarget::Window { id: w.id })
133-
.ok_or(format!("No window with name \"{}\"", &name))?,
112+
.find(|(w, _)| w.name == *name)
113+
.map(|(w, _)| cap_recording::sources::screen_capture::ScreenCaptureTarget::Window { id: w.id })
114+
.ok_or(anyhow::anyhow!("No window with name \"{}\"", &name))?,
134115
};
135116

136-
let inputs = StartRecordingInputs {
137-
mode,
117+
let inputs = crate::recording::StartRecordingInputs {
138118
capture_target,
139-
capture_system_audio,
119+
capture_system_audio: *capture_system_audio,
120+
mode: *mode,
140121
organization_id: None,
141122
};
142123

143-
crate::recording::start_recording(app.clone(), state, inputs)
124+
if let Some(camera_id) = camera {
125+
crate::set_camera_input(app.clone(), state.clone(), Some(camera_id.clone()), None)
126+
.await
127+
.map_err(|e| anyhow::anyhow!(e))?;
128+
}
129+
130+
if let Some(mic) = mic_label {
131+
crate::set_mic_input(state.clone(), Some(mic.clone()))
132+
.await
133+
.map_err(|e| anyhow::anyhow!(e))?;
134+
}
135+
136+
crate::recording::start_recording(app.clone(), state.clone(), inputs)
144137
.await
145-
.map(|_| ())
138+
.map_err(|e| anyhow::anyhow!(e))?;
139+
Ok(())
146140
}
147-
DeepLinkAction::StopRecording => {
148-
crate::recording::stop_recording(app.clone(), app.state()).await
141+
Self::OpenEditor { project_path } => {
142+
crate::open_project_from_path(Path::new(project_path), app.clone())
143+
.map_err(|e| anyhow::anyhow!(e))?;
144+
Ok(())
149145
}
150-
DeepLinkAction::OpenEditor { project_path } => {
151-
crate::open_project_from_path(Path::new(&project_path), app.clone())
146+
Self::OpenSettings { page } => {
147+
crate::show_window(app.clone(), crate::ShowCapWindow::Settings { page: page.clone() })
148+
.await
149+
.map_err(|e| anyhow::anyhow!(e))?;
150+
Ok(())
152151
}
153-
DeepLinkAction::OpenSettings { page } => {
154-
crate::show_window(app.clone(), ShowCapWindow::Settings { page }).await
152+
_ => {
153+
tracing::warn!("Deep link action not implemented: {:?}", self);
154+
Ok(())
155155
}
156156
}
157157
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,7 @@ use std::{
8787
},
8888
time::{Duration, SystemTime, UNIX_EPOCH},
8989
};
90-
use tauri::{AppHandle, Manager, State, Window, WindowEvent, ipc::Channel};
91-
use tauri_plugin_deep_link::DeepLinkExt;
90+
use tauri::{AppHandle, Manager, Emitter, Wry, State, Window, WindowEvent, ipc::Channel, Url};
9291
use tauri_plugin_dialog::DialogExt;
9392
use tauri_plugin_global_shortcut::GlobalShortcutExt;
9493
use tauri_plugin_notification::{NotificationExt, PermissionState};
@@ -3295,29 +3294,39 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
32953294
tauri::async_runtime::set(tokio::runtime::Handle::current());
32963295

32973296
#[allow(unused_mut)]
3298-
let mut builder =
3299-
tauri::Builder::default().plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
3300-
trace!("Single instance invoked with args {args:?}");
3301-
3302-
// This is also handled as a deeplink on some platforms (eg macOS), see deeplink_actions
3303-
let Some(cap_file) = args
3304-
.iter()
3305-
.find(|arg| arg.ends_with(".cap"))
3306-
.map(PathBuf::from)
3307-
else {
3308-
let app = app.clone();
3297+
let mut builder = tauri::Builder::default().plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
3298+
info!("Single instance invoked with args {:?}", &args);
3299+
3300+
// 1. Handle Remote Control URLs
3301+
let url_str = args.get(1).cloned();
3302+
if let Some(arg) = url_str {
3303+
if arg.starts_with("cap-desktop://") {
3304+
let app_handle = app.clone();
3305+
let url_string = arg.clone();
33093306
tokio::spawn(async move {
3310-
ShowCapWindow::Main {
3311-
init_target_mode: None,
3307+
if let Ok(url) = Url::parse(&url_string) {
3308+
let _ = app_handle.emit("tauri://deep-link", vec![url_string.clone()]);
3309+
if let Ok(action) = deeplink_actions::DeepLinkAction::try_from(&url) {
3310+
if let Err(e) = action.execute(&app_handle).await {
3311+
error!("Failed to execute deep link action: {}", e);
3312+
}
3313+
}
33123314
}
3313-
.show(&app)
3314-
.await
33153315
});
3316-
return;
3317-
};
3316+
}
3317+
}
33183318

3319-
let _ = open_project_from_path(&cap_file, app.clone());
3320-
}));
3319+
// 2. Handle .cap files
3320+
let cap_file = args.iter()
3321+
.find(|arg| arg.ends_with(".cap"))
3322+
.map(PathBuf::from);
3323+
3324+
if let Some(file) = cap_file {
3325+
let _ = open_project_from_path(&file, app.clone());
3326+
}
3327+
3328+
let _ = app.get_webview_window("main").map(|w| w.set_focus());
3329+
}));
33213330

33223331
#[cfg(target_os = "macos")]
33233332
{
@@ -3335,7 +3344,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
33353344
.plugin(tauri_plugin_updater::Builder::new().build())
33363345
.plugin(tauri_plugin_notification::init())
33373346
.plugin(flags::plugin::init())
3338-
.plugin(tauri_plugin_deep_link::init())
33393347
.plugin(tauri_plugin_clipboard_manager::init())
33403348
.plugin(tauri_plugin_fs::init())
33413349
.plugin(tauri_plugin_opener::init())
@@ -3597,11 +3605,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
35973605
prewarmer.request(event.force).await;
35983606
});
35993607

3600-
let app_handle = app.clone();
3601-
app.deep_link().on_open_url(move |event| {
3602-
deeplink_actions::handle(&app_handle, event.urls());
3603-
});
3604-
36053608
Ok(())
36063609
})
36073610
.on_window_event(|window, event| {

apps/desktop/src/utils/auth.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { createMutation } from "@tanstack/solid-query";
22
import { invoke } from "@tauri-apps/api/core";
33
import { listen } from "@tauri-apps/api/event";
44
import { getCurrentWindow } from "@tauri-apps/api/window";
5-
import { onOpenUrl } from "@tauri-apps/plugin-deep-link";
65
import * as shell from "@tauri-apps/plugin-shell";
76
import { z } from "zod";
87
import callbackTemplate from "~/components/callback.template";
@@ -176,12 +175,12 @@ async function startDeepLinkSession(signal: AbortSignal) {
176175
resolvePromise(value);
177176
};
178177

179-
stopListening = await onOpenUrl(async (urls) => {
180-
for (const urlString of urls) {
178+
stopListening = (await listen<string[]>("tauri://deep-link", (event) => {
179+
for (const urlString of event.payload) {
181180
if (signal.aborted) return;
182181
settle(parseAuthParams(new URL(urlString)));
183182
}
184-
});
183+
})) as unknown as () => void;
185184

186185
const dispose = async () => {
187186
stopListening?.();

0 commit comments

Comments
 (0)