@desklink/host 0.5.4 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/dist/bridge.js +3 -3
- package/dist/bridge.js.map +1 -1
- package/dist/engineProcess.d.ts.map +1 -1
- package/dist/engineProcess.js +1 -0
- package/dist/engineProcess.js.map +1 -1
- package/dist/protocol.d.ts +39 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/docs/BOUNDARY.md +4 -2
- package/docs/PROTOCOL.md +95 -9
- package/engine/Cargo.lock +1 -1
- package/engine/Cargo.toml +1 -1
- package/engine/src/audio.rs +398 -0
- package/engine/src/capture.rs +0 -1
- package/engine/src/hyprland.rs +319 -0
- package/engine/src/mac.rs +1 -0
- package/engine/src/main.rs +24 -0
- package/engine/src/peer.rs +73 -0
- package/engine/src/protocol.rs +22 -0
- package/engine/src/session.rs +240 -4
- package/engine/src/win.rs +1 -0
- package/engine/vendor/rtc-ice/DESKLINK-PATCH.md +53 -1
- package/engine/vendor/rtc-ice/src/agent/agent_selector.rs +81 -0
- package/engine/vendor/rtc-ice/src/agent/agent_test.rs +206 -0
- package/engine/vendor/rtc-ice/src/agent/mod.rs +91 -0
- package/engine/vendor/rtc-ice/src/dl_trace.rs +49 -0
- package/engine/vendor/rtc-ice/src/lib.rs +3 -0
- package/package.json +3 -3
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
//! Where a shared window sits on a Hyprland desktop, from Hyprland's own IPC.
|
|
2
|
+
//!
|
|
3
|
+
//! Hyprland's ScreenCast portal reports every stream at (0, 0) and never says
|
|
4
|
+
//! which window was picked, while the pointer device spans the whole desktop.
|
|
5
|
+
//! Hyprland itself announces each window share by title on its event socket
|
|
6
|
+
//! (`screencastv2>>1,window,<title>`) and lists every window's place in
|
|
7
|
+
//! `j/clients`. So the shared window is the one with that title and the
|
|
8
|
+
//! stream's size, followed by its address from then on, and the pointer goes
|
|
9
|
+
//! onto it through Hyprland's own cursor warp at the window's current place:
|
|
10
|
+
//! a window moved after the share started still maps. Anything less certain
|
|
11
|
+
//! (no announcement, no such window, two alike, a window off screen) is a
|
|
12
|
+
//! refusal, never a guess.
|
|
13
|
+
|
|
14
|
+
use serde_json::Value;
|
|
15
|
+
use std::io::{BufRead, BufReader, Read, Write};
|
|
16
|
+
use std::net::Shutdown;
|
|
17
|
+
use std::os::unix::net::UnixStream;
|
|
18
|
+
use std::path::PathBuf;
|
|
19
|
+
use std::sync::{Arc, Mutex, MutexGuard};
|
|
20
|
+
|
|
21
|
+
use crate::session::WINDOW_POINTER_REFUSAL as UNKNOWN;
|
|
22
|
+
|
|
23
|
+
fn instance() -> Option<PathBuf> {
|
|
24
|
+
let signature = std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE")?;
|
|
25
|
+
let runtime = std::env::var_os("XDG_RUNTIME_DIR")?;
|
|
26
|
+
Some(PathBuf::from(runtime).join("hypr").join(signature))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
|
30
|
+
mutex
|
|
31
|
+
.lock()
|
|
32
|
+
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Hyprland's window-share announcements, heard from before the portal asks
|
|
36
|
+
/// for consent. Dropping it closes the event socket, which ends its reader.
|
|
37
|
+
pub struct ShareWatch {
|
|
38
|
+
socket: UnixStream,
|
|
39
|
+
titles: Arc<Mutex<Vec<String>>>,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
impl ShareWatch {
|
|
43
|
+
/// `None` when this is not a Hyprland session.
|
|
44
|
+
pub fn start() -> Option<Self> {
|
|
45
|
+
let socket = UnixStream::connect(instance()?.join(".socket2.sock")).ok()?;
|
|
46
|
+
let reader = socket.try_clone().ok()?;
|
|
47
|
+
let titles = Arc::new(Mutex::new(Vec::new()));
|
|
48
|
+
let heard = titles.clone();
|
|
49
|
+
std::thread::spawn(move || {
|
|
50
|
+
for line in BufReader::new(reader).lines().map_while(Result::ok) {
|
|
51
|
+
if let Some(title) = line.strip_prefix("screencastv2>>1,window,") {
|
|
52
|
+
lock(&heard).push(title.to_owned());
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
Some(Self { socket, titles })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
impl Drop for ShareWatch {
|
|
61
|
+
fn drop(&mut self) {
|
|
62
|
+
let _ = self.socket.shutdown(Shutdown::Both);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// The window a portal window share shows, placed by Hyprland.
|
|
67
|
+
pub struct WindowLocator {
|
|
68
|
+
watch: ShareWatch,
|
|
69
|
+
/// The stream's own pixel size, which the window's size times its
|
|
70
|
+
/// monitor's scale must match.
|
|
71
|
+
source: (i64, i64),
|
|
72
|
+
address: Mutex<Option<String>>,
|
|
73
|
+
/// Whether this Hyprland reads a Lua config, whose `dispatch` takes Lua.
|
|
74
|
+
lua: Mutex<Option<bool>>,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
impl WindowLocator {
|
|
78
|
+
pub fn new(watch: ShareWatch, width: i32, height: i32) -> Self {
|
|
79
|
+
Self {
|
|
80
|
+
watch,
|
|
81
|
+
source: (i64::from(width.max(1)), i64::from(height.max(1))),
|
|
82
|
+
address: Mutex::new(None),
|
|
83
|
+
lua: Mutex::new(None),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Put the pointer on `(x, y)` in the shared window's own pixels.
|
|
88
|
+
pub fn place(&self, x: i64, y: i64) -> Result<(), String> {
|
|
89
|
+
let (clients, monitors) = (query("j/clients")?, query("j/monitors")?);
|
|
90
|
+
let clients = clients.as_array().map(Vec::as_slice).unwrap_or_default();
|
|
91
|
+
// A window nobody sees has no point on the screen.
|
|
92
|
+
let shown = |window: &Value| {
|
|
93
|
+
let workspace = &window["workspace"]["id"];
|
|
94
|
+
window["mapped"].as_bool() != Some(false)
|
|
95
|
+
&& window["hidden"].as_bool() != Some(true)
|
|
96
|
+
&& (window["pinned"].as_bool() == Some(true)
|
|
97
|
+
|| monitors.as_array().into_iter().flatten().any(|monitor| {
|
|
98
|
+
&monitor["activeWorkspace"]["id"] == workspace
|
|
99
|
+
|| &monitor["specialWorkspace"]["id"] == workspace
|
|
100
|
+
}))
|
|
101
|
+
};
|
|
102
|
+
let mut address = lock(&self.address);
|
|
103
|
+
if address.is_none() {
|
|
104
|
+
*address = Some(self.identify(clients, &monitors)?);
|
|
105
|
+
}
|
|
106
|
+
let index = clients
|
|
107
|
+
.iter()
|
|
108
|
+
.position(|client| client["address"].as_str() == address.as_deref())
|
|
109
|
+
.ok_or("a shared window cannot take taps: the window has closed")?;
|
|
110
|
+
let window = &clients[index];
|
|
111
|
+
if !shown(window) {
|
|
112
|
+
return Err(String::from(
|
|
113
|
+
"a shared window cannot take taps while it is on a hidden workspace",
|
|
114
|
+
));
|
|
115
|
+
}
|
|
116
|
+
let (left, top, width, height) = bounds(window).ok_or(UNKNOWN)?;
|
|
117
|
+
let (source_w, source_h) = self.source;
|
|
118
|
+
let (px, py) = (
|
|
119
|
+
left + x as f64 * width / source_w as f64,
|
|
120
|
+
top + y as f64 * height / source_h as f64,
|
|
121
|
+
);
|
|
122
|
+
// Hyprland clamps the cursor to the screen edge, so a point off every
|
|
123
|
+
// monitor would press something else or nothing.
|
|
124
|
+
let on_screen = monitors.as_array().into_iter().flatten().any(|monitor| {
|
|
125
|
+
let scale = monitor["scale"].as_f64().filter(|scale| *scale > 0.0);
|
|
126
|
+
monitor["disabled"].as_bool() != Some(true)
|
|
127
|
+
&& matches!(
|
|
128
|
+
(
|
|
129
|
+
monitor["x"].as_f64(),
|
|
130
|
+
monitor["y"].as_f64(),
|
|
131
|
+
monitor["width"].as_f64(),
|
|
132
|
+
monitor["height"].as_f64(),
|
|
133
|
+
scale
|
|
134
|
+
),
|
|
135
|
+
(Some(mx), Some(my), Some(mw), Some(mh), Some(scale))
|
|
136
|
+
if (mx..mx + mw / scale).contains(&px)
|
|
137
|
+
&& (my..my + mh / scale).contains(&py)
|
|
138
|
+
)
|
|
139
|
+
});
|
|
140
|
+
if !on_screen {
|
|
141
|
+
return Err(String::from(
|
|
142
|
+
"a shared window cannot take taps where it hangs off screen",
|
|
143
|
+
));
|
|
144
|
+
}
|
|
145
|
+
// Hyprland draws tiled windows, then floating ones, then fullscreen
|
|
146
|
+
// ones, and pinned ones above all; j/clients lists them in the
|
|
147
|
+
// compositor's own stacking order, so a later layer-mate holding the
|
|
148
|
+
// point is what a press there would reach. A floating window let over
|
|
149
|
+
// fullscreen draws above it, but j/clients cannot tell that one from
|
|
150
|
+
// any floating window, so either pairing over the point is a refusal.
|
|
151
|
+
let layer = |w: &Value| {
|
|
152
|
+
if w["pinned"].as_bool() == Some(true) {
|
|
153
|
+
3
|
|
154
|
+
} else if w["fullscreen"].as_i64().is_some_and(|mode| mode > 0) {
|
|
155
|
+
2
|
|
156
|
+
} else {
|
|
157
|
+
u8::from(w["floating"].as_bool() == Some(true))
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
let covered = clients.iter().enumerate().any(|(i, other)| {
|
|
161
|
+
shown(other)
|
|
162
|
+
&& bounds(other).is_some_and(|(l, t, w, h)| {
|
|
163
|
+
(l..l + w).contains(&px) && (t..t + h).contains(&py)
|
|
164
|
+
})
|
|
165
|
+
&& match (layer(other), layer(window), i > index) {
|
|
166
|
+
(1, 2, _) | (2, 1, _) => true,
|
|
167
|
+
(above, own, later) => above > own || (above == own && later),
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
if covered {
|
|
171
|
+
return Err(String::from(
|
|
172
|
+
"a shared window cannot take taps where another window covers it",
|
|
173
|
+
));
|
|
174
|
+
}
|
|
175
|
+
// Bars, notifications and other layer surfaces draw above windows and
|
|
176
|
+
// are absent from the window-only video: a top or overlay one holding
|
|
177
|
+
// the point is what a press there would reach. Their boxes come local
|
|
178
|
+
// to their monitor, which j/monitors places.
|
|
179
|
+
let anchored = |name: &str| -> Option<(f64, f64)> {
|
|
180
|
+
for monitor in monitors.as_array().into_iter().flatten() {
|
|
181
|
+
if monitor["name"].as_str() == Some(name) {
|
|
182
|
+
return Some((monitor["x"].as_f64()?, monitor["y"].as_f64()?));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
None
|
|
186
|
+
};
|
|
187
|
+
let overlaid = query("j/layers")?
|
|
188
|
+
.as_object()
|
|
189
|
+
.into_iter()
|
|
190
|
+
.flatten()
|
|
191
|
+
.map(|(name, monitor)| Some((anchored(name)?, monitor)))
|
|
192
|
+
.collect::<Option<Vec<((f64, f64), &Value)>>>()
|
|
193
|
+
.ok_or(String::from(
|
|
194
|
+
"a shared window cannot take taps: a screen's layers are unknown",
|
|
195
|
+
))?
|
|
196
|
+
.into_iter()
|
|
197
|
+
.any(|((mx, my), monitor)| {
|
|
198
|
+
monitor["levels"]
|
|
199
|
+
.as_object()
|
|
200
|
+
.into_iter()
|
|
201
|
+
.flatten()
|
|
202
|
+
.any(|(level, surfaces)| {
|
|
203
|
+
let level = level.parse::<u8>().unwrap_or(0);
|
|
204
|
+
(level >= 2)
|
|
205
|
+
&& surfaces.as_array().into_iter().flatten().any(|surface| {
|
|
206
|
+
let (sx, sy) = (surface["x"].as_f64(), surface["y"].as_f64());
|
|
207
|
+
let (sw, sh) = (surface["w"].as_f64(), surface["h"].as_f64());
|
|
208
|
+
!matches!(
|
|
209
|
+
surface["namespace"].as_str(),
|
|
210
|
+
Some("desklink-agent" | "desklink-point")
|
|
211
|
+
) && matches!(
|
|
212
|
+
(sx, sy, sw, sh),
|
|
213
|
+
(Some(sx), Some(sy), Some(sw), Some(sh))
|
|
214
|
+
if (mx + sx..mx + sx + sw).contains(&px)
|
|
215
|
+
&& (my + sy..my + sy + sh).contains(&py)
|
|
216
|
+
)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
});
|
|
220
|
+
if overlaid {
|
|
221
|
+
return Err(String::from(
|
|
222
|
+
"a shared window cannot take taps where an overlay covers it",
|
|
223
|
+
));
|
|
224
|
+
}
|
|
225
|
+
self.warp((px.floor() as i64, py.floor() as i64))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// The address of the one window Hyprland said it is sharing.
|
|
229
|
+
fn identify(&self, clients: &[Value], monitors: &Value) -> Result<String, String> {
|
|
230
|
+
let mut titles = lock(&self.watch.titles).clone();
|
|
231
|
+
titles.dedup();
|
|
232
|
+
let [title] = titles.as_slice() else {
|
|
233
|
+
return Err(String::from(UNKNOWN));
|
|
234
|
+
};
|
|
235
|
+
let (source_w, source_h) = self.source;
|
|
236
|
+
let scale = |monitor: &Value| {
|
|
237
|
+
monitors
|
|
238
|
+
.as_array()
|
|
239
|
+
.into_iter()
|
|
240
|
+
.flatten()
|
|
241
|
+
.find(|m| &m["id"] == monitor)
|
|
242
|
+
.and_then(|m| m["scale"].as_f64())
|
|
243
|
+
};
|
|
244
|
+
let alike: Vec<&Value> = clients
|
|
245
|
+
.iter()
|
|
246
|
+
.filter(|client| {
|
|
247
|
+
let size = |i: usize| client["size"][i].as_f64();
|
|
248
|
+
client["title"].as_str() == Some(title.as_str())
|
|
249
|
+
&& client["mapped"].as_bool() != Some(false)
|
|
250
|
+
&& client["hidden"].as_bool() != Some(true)
|
|
251
|
+
&& matches!(
|
|
252
|
+
(size(0), size(1), scale(&client["monitor"])),
|
|
253
|
+
(Some(w), Some(h), Some(s))
|
|
254
|
+
if ((w * s).round() as i64 - source_w).abs() <= 1
|
|
255
|
+
&& ((h * s).round() as i64 - source_h).abs() <= 1
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
.collect();
|
|
259
|
+
match alike.as_slice() {
|
|
260
|
+
[window] => window["address"]
|
|
261
|
+
.as_str()
|
|
262
|
+
.map(str::to_owned)
|
|
263
|
+
.ok_or_else(|| String::from(UNKNOWN)),
|
|
264
|
+
[] => Err(String::from(UNKNOWN)),
|
|
265
|
+
_ => Err(format!(
|
|
266
|
+
"a shared window cannot take taps: {} windows look like it; share the whole screen to point and click",
|
|
267
|
+
alike.len()
|
|
268
|
+
)),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fn warp(&self, (x, y): (i64, i64)) -> Result<(), String> {
|
|
273
|
+
let legacy = format!("dispatch movecursor {x} {y}");
|
|
274
|
+
let lua = format!("dispatch hl.dsp.cursor.move({{ x = {x}, y = {y} }})");
|
|
275
|
+
let mut dialect = lock(&self.lua);
|
|
276
|
+
let tries: &[(bool, &str)] = match *dialect {
|
|
277
|
+
Some(true) => &[(true, &lua)],
|
|
278
|
+
Some(false) => &[(false, &legacy)],
|
|
279
|
+
None => &[(false, &legacy), (true, &lua)],
|
|
280
|
+
};
|
|
281
|
+
let mut said = String::new();
|
|
282
|
+
for (is_lua, command) in tries {
|
|
283
|
+
said = request(command)?;
|
|
284
|
+
if said.trim() == "ok" {
|
|
285
|
+
*dialect = Some(*is_lua);
|
|
286
|
+
return Ok(());
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
Err(format!(
|
|
290
|
+
"Hyprland did not move the pointer: {}",
|
|
291
|
+
said.trim()
|
|
292
|
+
))
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// A window's box in the layout: left, top, width, height.
|
|
297
|
+
fn bounds(window: &Value) -> Option<(f64, f64, f64, f64)> {
|
|
298
|
+
let pair = |key: &str| Some((window[key][0].as_f64()?, window[key][1].as_f64()?));
|
|
299
|
+
let ((left, top), (width, height)) = pair("at").zip(pair("size"))?;
|
|
300
|
+
Some((left, top, width, height))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
fn request(command: &str) -> Result<String, String> {
|
|
304
|
+
let ask = || -> std::io::Result<String> {
|
|
305
|
+
let path = instance()
|
|
306
|
+
.ok_or_else(|| std::io::Error::other("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
|
|
307
|
+
let mut socket = UnixStream::connect(path.join(".socket.sock"))?;
|
|
308
|
+
socket.write_all(command.as_bytes())?;
|
|
309
|
+
let mut reply = String::new();
|
|
310
|
+
socket.read_to_string(&mut reply)?;
|
|
311
|
+
Ok(reply)
|
|
312
|
+
};
|
|
313
|
+
ask().map_err(|error| format!("Hyprland's control socket did not answer: {error}"))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
fn query(command: &str) -> Result<Value, String> {
|
|
317
|
+
serde_json::from_str(&request(command)?)
|
|
318
|
+
.map_err(|error| format!("Hyprland's {command} was not JSON: {error}"))
|
|
319
|
+
}
|
package/engine/src/mac.rs
CHANGED
|
@@ -184,6 +184,7 @@ fn capabilities() -> Value {
|
|
|
184
184
|
"formats": ["bgra"],
|
|
185
185
|
"cursor": "embedded",
|
|
186
186
|
"cursor_modes": ["embedded"],
|
|
187
|
+
// ScreenCaptureKit audio is not wired up; macOS is out of scope.
|
|
187
188
|
"audio": false,
|
|
188
189
|
"displays": displays(),
|
|
189
190
|
"grant": if capture_granted { "granted" } else { "missing-screen-recording" },
|
package/engine/src/main.rs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
//! process, owns its stdin/stdout, and carries the SDP/ICE the engine produces
|
|
7
7
|
//! over whatever authenticated channel it already has.
|
|
8
8
|
|
|
9
|
+
#[cfg(target_os = "linux")]
|
|
10
|
+
mod audio;
|
|
9
11
|
#[cfg(target_os = "linux")]
|
|
10
12
|
mod capture;
|
|
11
13
|
#[cfg(target_os = "macos")]
|
|
@@ -43,6 +45,8 @@ mod h264_openh264;
|
|
|
43
45
|
mod h264_vaapi;
|
|
44
46
|
#[cfg(all(target_os = "macos", desklink_vpx))]
|
|
45
47
|
mod h264_vt;
|
|
48
|
+
#[cfg(target_os = "linux")]
|
|
49
|
+
mod hyprland;
|
|
46
50
|
#[cfg(any(
|
|
47
51
|
target_os = "linux",
|
|
48
52
|
all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
|
|
@@ -574,6 +578,15 @@ fn render_event(notice: session::Notice) -> Option<String> {
|
|
|
574
578
|
"session.frame.changed",
|
|
575
579
|
serde_json::json!({ "sessionId": session_id, "seq": seq, "damage": damage, "timestamp_us": timestamp_us }),
|
|
576
580
|
),
|
|
581
|
+
session::SessionEvent::Audio {
|
|
582
|
+
seq,
|
|
583
|
+
timestamp_us,
|
|
584
|
+
bytes,
|
|
585
|
+
gap_micros,
|
|
586
|
+
} => (
|
|
587
|
+
"session.audio.changed",
|
|
588
|
+
serde_json::json!({ "sessionId": session_id, "seq": seq, "timestamp_us": timestamp_us, "bytes": bytes, "gap_ms": gap_micros / 1000 }),
|
|
589
|
+
),
|
|
577
590
|
session::SessionEvent::Input { input } => (
|
|
578
591
|
"session.input",
|
|
579
592
|
serde_json::json!({ "sessionId": session_id, "input": input }),
|
|
@@ -658,6 +671,7 @@ async fn dispatch(
|
|
|
658
671
|
loopback_tcp: params.loopback_tcp,
|
|
659
672
|
agent_indicator: params.agent_indicator,
|
|
660
673
|
local_frames: params.local_frames,
|
|
674
|
+
audio: params.audio,
|
|
661
675
|
ice_servers: params
|
|
662
676
|
.ice_servers
|
|
663
677
|
.into_iter()
|
|
@@ -684,6 +698,7 @@ async fn dispatch(
|
|
|
684
698
|
"geometry": session.geometry(),
|
|
685
699
|
"cursor": session.cursor_info(),
|
|
686
700
|
"agentIndicator": session.agent_indicator(),
|
|
701
|
+
"audio": session.audio_info(),
|
|
687
702
|
});
|
|
688
703
|
*current = Some(session);
|
|
689
704
|
Ok(result)
|
|
@@ -787,6 +802,15 @@ async fn dispatch(
|
|
|
787
802
|
.frame(params.since, ¶ms.path, params.region)
|
|
788
803
|
.map_err(|error| ErrorBody::new(error.code, error.message))
|
|
789
804
|
}
|
|
805
|
+
"session.audio" => {
|
|
806
|
+
let session = require_session(current)?;
|
|
807
|
+
let params: protocol::AudioParams = serde_json::from_value(request.params.clone())
|
|
808
|
+
.map_err(|error| ErrorBody::new("malformed", error.to_string()))?;
|
|
809
|
+
check_session(session, ¶ms.session_id, None)?;
|
|
810
|
+
session
|
|
811
|
+
.audio(¶ms.path, params.max_bytes)
|
|
812
|
+
.map_err(|error| ErrorBody::new(error.code, error.message))
|
|
813
|
+
}
|
|
790
814
|
"session.feed" => {
|
|
791
815
|
let session = require_session(current)?;
|
|
792
816
|
let params: protocol::FeedParams = serde_json::from_value(request.params.clone())
|
package/engine/src/peer.rs
CHANGED
|
@@ -47,6 +47,33 @@ use webrtc::peer_connection::{
|
|
|
47
47
|
};
|
|
48
48
|
use webrtc::runtime::default_runtime;
|
|
49
49
|
|
|
50
|
+
// desklink scout trace (dl-roam-postnom-attr): engine-side restart timeline.
|
|
51
|
+
// Enabled with `DESKLINK_ICE_TRACE=1`; stamps microseconds since the Unix epoch
|
|
52
|
+
// so these lines interleave with the vendored ICE agent's `[ice ...]` lines in
|
|
53
|
+
// one order. Droppable: grep `desklink scout trace`.
|
|
54
|
+
pub(crate) fn dl_trace_on() -> bool {
|
|
55
|
+
std::env::var("DESKLINK_ICE_TRACE")
|
|
56
|
+
.map(|v| v != "0" && !v.is_empty())
|
|
57
|
+
.unwrap_or(false)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
pub(crate) fn dl_trace_us() -> u128 {
|
|
61
|
+
std::time::SystemTime::now()
|
|
62
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
63
|
+
.map(|d| d.as_micros())
|
|
64
|
+
.unwrap_or(0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
macro_rules! dl_eng {
|
|
68
|
+
($($arg:tt)*) => {
|
|
69
|
+
if $crate::peer::dl_trace_on() {
|
|
70
|
+
eprintln!("[eng {}] {}", $crate::peer::dl_trace_us(), format_args!($($arg)*));
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
pub(crate) use dl_eng;
|
|
76
|
+
|
|
50
77
|
/// VP9 clock rate, fixed by RFC 9628.
|
|
51
78
|
const VP9_CLOCK_RATE: u32 = 90_000;
|
|
52
79
|
|
|
@@ -420,6 +447,12 @@ impl PeerConnectionEventHandler for Handler {
|
|
|
420
447
|
}
|
|
421
448
|
|
|
422
449
|
async fn on_connection_state_change(&self, state: RTCPeerConnectionState) {
|
|
450
|
+
// desklink scout trace (dl-roam-postnom-attr): the engine's rtc-level
|
|
451
|
+
// transport state event, on the same microsecond clock as the vendored
|
|
452
|
+
// agent's `[ice ...]` lines, so the rtc Connected gate is separable.
|
|
453
|
+
if dl_trace_on() {
|
|
454
|
+
dl_eng!("peer_state_change {state:?}");
|
|
455
|
+
}
|
|
423
456
|
let connected = matches!(state, RTCPeerConnectionState::Connected);
|
|
424
457
|
if connected {
|
|
425
458
|
self.wants_keyframe.store(true, Ordering::SeqCst);
|
|
@@ -1590,6 +1623,15 @@ pub(crate) mod tests {
|
|
|
1590
1623
|
.expect("the restart answer is accepted");
|
|
1591
1624
|
}
|
|
1592
1625
|
|
|
1626
|
+
/// How long the restarted generation may take to nominate and reconnect,
|
|
1627
|
+
/// measured from the answered offer: two `check_interval` ticks. The vendored
|
|
1628
|
+
/// agent ticks every 200 ms (`vendor/rtc-ice/src/agent/agent_config.rs`), and
|
|
1629
|
+
/// the routed roam leg measured the engine's own first-valid-pair ->
|
|
1630
|
+
/// USE-CANDIDATE at 197 ms, one tick; the extra tick is headroom for the
|
|
1631
|
+
/// loopback's round trip and for a loaded machine. See the assertion in
|
|
1632
|
+
/// `a_restart_keeps_media_flowing_on_the_selected_pair_until_the_new_one_nominates`.
|
|
1633
|
+
const RECONNECT_BUDGET: Duration = Duration::from_millis(400);
|
|
1634
|
+
|
|
1593
1635
|
/// A receiver peer that records when each inbound RTP packet arrives and
|
|
1594
1636
|
/// reports its own ICE candidates, so two peers can really connect.
|
|
1595
1637
|
struct MediaReceiver {
|
|
@@ -1783,6 +1825,37 @@ pub(crate) mod tests {
|
|
|
1783
1825
|
.accept_answer(reanswer.sdp)
|
|
1784
1826
|
.await
|
|
1785
1827
|
.expect("the restart answer is accepted");
|
|
1828
|
+
let answered_at = Instant::now();
|
|
1829
|
+
|
|
1830
|
+
// The restarted generation has to be nominated off its own check cycle:
|
|
1831
|
+
// nomination is decided in the agent's `contact_candidates` pass, which the
|
|
1832
|
+
// driver reaches on a `check_interval` tick (200 ms) or when an inbound
|
|
1833
|
+
// Binding request arrives while a valid pair already exists. A pair that
|
|
1834
|
+
// becomes valid just after a tick would therefore wait a full tick for its
|
|
1835
|
+
// USE-CANDIDATE, and one tick is the whole engine-side cost measured on the
|
|
1836
|
+
// routed roam leg (first valid pair -> USE-CANDIDATE: 197 ms, i.e. one tick).
|
|
1837
|
+
//
|
|
1838
|
+
// What this asserts, and what it cannot: the engine's "first valid pair"
|
|
1839
|
+
// instant is not observable from here (the peer connection exposes no pair
|
|
1840
|
+
// state), so the bound is measured from the answer, the earliest instant the
|
|
1841
|
+
// engine could nominate, at two ticks. The loopback's client answers checks
|
|
1842
|
+
// and sends its own triggered checks in the same burst, so an inbound request
|
|
1843
|
+
// is nearly always available and the base engine meets this bound too; the
|
|
1844
|
+
// tick itself is graded by the routed roam leg, whose relay log carries both
|
|
1845
|
+
// instants (197 ms, one tick). This guards the property going forward: a
|
|
1846
|
+
// lost pass, a skipped tick or a stalled nomination blows the bound.
|
|
1847
|
+
let reconnected = async {
|
|
1848
|
+
while !engine.is_connected() {
|
|
1849
|
+
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
1850
|
+
}
|
|
1851
|
+
answered_at.elapsed()
|
|
1852
|
+
};
|
|
1853
|
+
let reconnect = tokio::time::timeout(RECONNECT_BUDGET, reconnected)
|
|
1854
|
+
.await
|
|
1855
|
+
.expect("the restarted generation was never nominated");
|
|
1856
|
+
eprintln!(
|
|
1857
|
+
"restart: transport reconnected {reconnect:?} after the answer (budget {RECONNECT_BUDGET:?})"
|
|
1858
|
+
);
|
|
1786
1859
|
|
|
1787
1860
|
// Keep the picture advancing across and after the restart.
|
|
1788
1861
|
tokio::time::sleep(Duration::from_millis(800)).await;
|
package/engine/src/protocol.rs
CHANGED
|
@@ -144,6 +144,11 @@ pub struct OpenParams {
|
|
|
144
144
|
/// the engine then spends nothing per frame on either.
|
|
145
145
|
#[serde(default = "default_local_frames")]
|
|
146
146
|
pub local_frames: bool,
|
|
147
|
+
/// Capture the desktop's own system audio for `session.audio`. Absent means
|
|
148
|
+
/// video only; there is never a silent downgrade, so a caller that asks and
|
|
149
|
+
/// cannot be answered is refused.
|
|
150
|
+
#[serde(default)]
|
|
151
|
+
pub audio: bool,
|
|
147
152
|
}
|
|
148
153
|
|
|
149
154
|
fn default_local_frames() -> bool {
|
|
@@ -244,6 +249,23 @@ pub struct SessionRef {
|
|
|
244
249
|
pub generation: Option<u64>,
|
|
245
250
|
}
|
|
246
251
|
|
|
252
|
+
/// Read the desktop's own audio, the way `FrameParams` reads a frame: bytes go
|
|
253
|
+
/// to the caller's path and JSON carries only metadata.
|
|
254
|
+
#[derive(Debug, Deserialize)]
|
|
255
|
+
pub struct AudioParams {
|
|
256
|
+
pub session_id: String,
|
|
257
|
+
#[serde(default)]
|
|
258
|
+
pub path: String,
|
|
259
|
+
/// The most bytes to write. Bounded to whole sample frames, so a truncated
|
|
260
|
+
/// chunk never ends mid-sample.
|
|
261
|
+
#[serde(default = "default_max_audio_bytes")]
|
|
262
|
+
pub max_bytes: usize,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
fn default_max_audio_bytes() -> usize {
|
|
266
|
+
1 << 20
|
|
267
|
+
}
|
|
268
|
+
|
|
247
269
|
#[derive(Debug, Deserialize)]
|
|
248
270
|
pub struct DescriptionParams {
|
|
249
271
|
pub session_id: String,
|