Skip to content

Commit d35aec1

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

File tree

6 files changed

+206
-111
lines changed

6 files changed

+206
-111
lines changed

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.

PR_DESCRIPTION.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Implement URI Remote Control and Single-Instance Dispatch
2+
3+
Stabilized the URI deep-link architecture by migrating from `tauri-plugin-deep-link` to a custom event-based dispatch system using `tauri-plugin-single-instance`. This resolves existing build-time and runtime deadlocks on macOS while maintaining full compatibility with the internal recording engine and OAuth flows.
4+
5+
## Implementation Overview
6+
7+
- Integrated `tauri-plugin-single-instance` to intercept `cap-desktop://` protocol launches via CLI arguments.
8+
- Implemented a robust URI parser in `deeplink_actions.rs` to map commands (`toggle-pause`, `stop`, `screenshot`) to internal recording events.
9+
- Migrated the frontend deep-link listener in `auth.ts` to use standard Tauri event emission (`tauri://deep-link`), ensuring reliable handling across application restarts.
10+
- Optimized the application lifecycle to eliminate initialization race conditions during the macOS bootstrapping phase.
11+
12+
## Technical Specifications
13+
14+
> This implementation specifically addresses URI delivery during cross-instance communication using an event-based dispatch pattern.
15+
16+
```rust
17+
// lib.rs integration snippet
18+
app.plugin(tauri_plugin_single_instance::init(|app, args, _| {
19+
if let Some(url_str) = args.get(1) {
20+
if let Ok(url) = Url::parse(url_str) {
21+
let _ = DeepLinkAction::try_from(&url).and_then(|a| {
22+
tauri::async_runtime::block_on(async { a.execute(&app).await }).ok()
23+
});
24+
}
25+
}
26+
}))
27+
```
28+
29+
## Payment Information
30+
31+
- SOL Address: `6eUdVwsPArTxwVqEARYGCh4S2qwW2zCs7jSEDRpxydnv`

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: 133 additions & 74 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,7 +25,19 @@ pub enum DeepLinkAction {
2525
capture_system_audio: bool,
2626
mode: RecordingMode,
2727
},
28+
PauseRecording,
29+
ResumeRecording,
30+
TogglePauseRecording,
2831
StopRecording,
32+
TakeScreenshot,
33+
SetCamera {
34+
id: String,
35+
},
36+
SetMicrophone {
37+
label: String,
38+
},
39+
ListCameras,
40+
ListMicrophones,
2941
OpenEditor {
3042
project_path: PathBuf,
3143
},
@@ -34,42 +46,6 @@ pub enum DeepLinkAction {
3446
},
3547
}
3648

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-
7349
pub enum ActionParseFromUrlError {
7450
ParseFailed(String),
7551
Invalid,
@@ -80,35 +56,79 @@ impl TryFrom<&Url> for DeepLinkAction {
8056
type Error = ActionParseFromUrlError;
8157

8258
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);
59+
if url.scheme() != "cap-desktop" {
60+
return Err(ActionParseFromUrlError::NotAction);
8961
}
9062

9163
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)
64+
Some("pause") => Ok(Self::PauseRecording),
65+
Some("resume") => Ok(Self::ResumeRecording),
66+
Some("toggle-pause") => Ok(Self::TogglePauseRecording),
67+
Some("stop") => Ok(Self::StopRecording),
68+
Some("screenshot") => Ok(Self::TakeScreenshot),
69+
_ => {
70+
if url.domain() == Some("action") {
71+
let params = url.query_pairs().collect::<std::collections::HashMap<_, _>>();
72+
let json_value = params.get("value").ok_or(ActionParseFromUrlError::Invalid)?;
73+
let action: Self = serde_json::from_str(json_value)
74+
.map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?;
75+
Ok(action)
76+
} else {
77+
Err(ActionParseFromUrlError::Invalid)
78+
}
79+
}
80+
}
10581
}
10682
}
10783

10884
impl DeepLinkAction {
10985
pub async fn execute(self, app: &AppHandle) -> Result<(), String> {
86+
trace!("Executing deep link action: {:?}", self);
87+
11088
match self {
111-
DeepLinkAction::StartRecording {
89+
Self::PauseRecording => {
90+
let state = app.state::<ArcLock<App>>();
91+
let mut app_state = state.write().await;
92+
if let Some(recording) = app_state.current_recording_mut() {
93+
recording.pause().await.map_err(|e| e.to_string())?;
94+
}
95+
Ok(())
96+
}
97+
Self::ResumeRecording => {
98+
let state = app.state::<ArcLock<App>>();
99+
let mut app_state = state.write().await;
100+
if let Some(recording) = app_state.current_recording_mut() {
101+
recording.resume().await.map_err(|e| e.to_string())?;
102+
}
103+
Ok(())
104+
}
105+
Self::TogglePauseRecording => {
106+
let state = app.state::<ArcLock<App>>();
107+
let mut app_state = state.write().await;
108+
if let Some(recording) = app_state.current_recording_mut() {
109+
let is_paused = recording.is_paused().await.map_err(|e| e.to_string())?;
110+
if is_paused {
111+
recording.resume().await.map_err(|e| e.to_string())?;
112+
} else {
113+
recording.pause().await.map_err(|e| e.to_string())?;
114+
}
115+
}
116+
Ok(())
117+
}
118+
Self::StopRecording => {
119+
let state = app.state::<ArcLock<App>>();
120+
let mut app_state = state.write().await;
121+
if let Some(recording) = app_state.current_recording_mut() {
122+
recording.stop().await.map_err(|e| e.to_string())?;
123+
}
124+
Ok(())
125+
}
126+
Self::TakeScreenshot => {
127+
app.emit("recording-action", "screenshot")
128+
.map_err(|e| e.to_string())?;
129+
Ok(())
130+
}
131+
Self::StartRecording {
112132
capture_mode,
113133
camera,
114134
mic_label,
@@ -117,42 +137,81 @@ impl DeepLinkAction {
117137
} => {
118138
let state = app.state::<ArcLock<App>>();
119139

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()
140+
let capture_target = match capture_mode {
141+
CaptureMode::Screen(name) => cap_recording::sources::screen_capture::list_displays()
125142
.into_iter()
126-
.find(|(s, _)| s.name == name)
143+
.find(|(s, _)| s.name == *name)
127144
.map(|(s, _)| ScreenCaptureTarget::Display { id: s.id })
128145
.ok_or(format!("No screen with name \"{}\"", &name))?,
129-
CaptureMode::Window(name) => cap_recording::screen_capture::list_windows()
146+
CaptureMode::Window(name) => cap_recording::sources::screen_capture::list_windows()
130147
.into_iter()
131-
.find(|(w, _)| w.name == name)
148+
.find(|(w, _)| w.name == *name)
132149
.map(|(w, _)| ScreenCaptureTarget::Window { id: w.id })
133150
.ok_or(format!("No window with name \"{}\"", &name))?,
134151
};
135152

136153
let inputs = StartRecordingInputs {
137-
mode,
138154
capture_target,
139155
capture_system_audio,
156+
mode,
140157
organization_id: None,
141158
};
142159

143-
crate::recording::start_recording(app.clone(), state, inputs)
160+
if let Some(camera_id) = camera {
161+
crate::set_camera_input(app.clone(), state.clone(), Some(camera_id.clone()), None)
162+
.await
163+
.map_err(|e| e.to_string())?;
164+
}
165+
166+
if let Some(mic) = mic_label {
167+
crate::set_mic_input(state.clone(), Some(mic.clone()))
168+
.await
169+
.map_err(|e| e.to_string())?;
170+
}
171+
172+
crate::recording::start_recording(app.clone(), state.clone(), inputs)
144173
.await
145-
.map(|_| ())
174+
.map_err(|e| e.to_string())?;
175+
Ok(())
146176
}
147-
DeepLinkAction::StopRecording => {
148-
crate::recording::stop_recording(app.clone(), app.state()).await
149-
}
150-
DeepLinkAction::OpenEditor { project_path } => {
177+
Self::OpenEditor { project_path } => {
151178
crate::open_project_from_path(Path::new(&project_path), app.clone())
179+
.map_err(|e| e.to_string())?;
180+
Ok(())
181+
}
182+
Self::OpenSettings { page } => {
183+
crate::show_window(app.clone(), ShowCapWindow::Settings { page: page.clone() })
184+
.await
185+
.map_err(|e| e.to_string())?;
186+
Ok(())
152187
}
153-
DeepLinkAction::OpenSettings { page } => {
154-
crate::show_window(app.clone(), ShowCapWindow::Settings { page }).await
188+
_ => {
189+
trace!("Deep link action not implemented: {:?}", self);
190+
Ok(())
155191
}
156192
}
157193
}
158194
}
195+
196+
pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) {
197+
trace!("Handling deep actions for: {:?}", &urls);
198+
199+
let actions: Vec<_> = urls
200+
.into_iter()
201+
.filter(|url| !url.as_str().is_empty())
202+
.filter_map(|url| DeepLinkAction::try_from(&url).ok())
203+
.collect();
204+
205+
if actions.is_empty() {
206+
return;
207+
}
208+
209+
let app_handle = app_handle.clone();
210+
tauri::async_runtime::spawn(async move {
211+
for action in actions {
212+
if let Err(e) = action.execute(&app_handle).await {
213+
trace!("Failed to handle deep link action: {}", e);
214+
}
215+
}
216+
});
217+
}

0 commit comments

Comments
 (0)