@desklink/host 0.5.6 → 0.5.8
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/docs/PROTOCOL.md +39 -3
- package/engine/Cargo.lock +1 -1
- package/engine/Cargo.toml +1 -1
- package/engine/native/agent_overlay_wayland.c +43 -18
- package/engine/src/capture.rs +0 -1
- package/engine/src/hyprland.rs +329 -0
- package/engine/src/indicator.rs +9 -2
- package/engine/src/main.rs +20 -5
- package/engine/src/peer.rs +132 -11
- package/engine/src/session.rs +106 -9
- package/package.json +3 -3
package/docs/PROTOCOL.md
CHANGED
|
@@ -198,7 +198,9 @@ build has, not what this machine can necessarily use.
|
|
|
198
198
|
|
|
199
199
|
`input.unavailable_reason` and `input.grant` describe the portal/uinput path:
|
|
200
200
|
without kernel input access a portal session opens and captures, but input
|
|
201
|
-
messages are refused with `input-unavailable`.
|
|
201
|
+
messages are refused with `input-unavailable`. A Hyprland window share's
|
|
202
|
+
pointer moves are the exception: they warp through Hyprland's own IPC instead
|
|
203
|
+
of any device (see Input and clipboard). X display capture uses XTest
|
|
202
204
|
instead and does not require that grant. The engine probes XTest on that named
|
|
203
205
|
display; if it is unavailable, capture and clipboard still open, and input
|
|
204
206
|
messages are refused with `input-unavailable`. X11 clipboard uses CLIPBOARD on the
|
|
@@ -270,7 +272,12 @@ animating in and out, with light and dark keylines so it reads on any content.
|
|
|
270
272
|
On Wayland it uses a click-through layer-shell surface with an edge glow, drawn
|
|
271
273
|
at the highest announced output integer scale, capped at 4; its bounded area is
|
|
272
274
|
masked before capture/diff (pixels behind the effect can remain briefly stale).
|
|
273
|
-
On
|
|
275
|
+
On a located Hyprland window share each pointer cue goes to the desktop point
|
|
276
|
+
the pointer landed on — the window's place plus the tapped point (see Input and
|
|
277
|
+
clipboard) — and the window's picture holds no layer surface, so nothing is
|
|
278
|
+
masked; a refused tap draws no cue. Known limit: the cue's surface sits on the
|
|
279
|
+
one output the compositor gave it as the share started, and a tap landing on
|
|
280
|
+
another output draws no cue. On bare X11 it is an override-redirect, click-through window that follows the
|
|
274
281
|
pointer, so it stays
|
|
275
282
|
visible over windows that set their own cursor and never replaces the cursor.
|
|
276
283
|
Its size follows `Xft.dpi` from 96–384 DPI, defaulting to 1x otherwise;
|
|
@@ -378,6 +385,12 @@ lease, or guarantee recovery within 2 seconds. Engine-side ends remain terminal.
|
|
|
378
385
|
// "description":{"type":"offer","sdp":"v=0\r\n…"}}}
|
|
379
386
|
```
|
|
380
387
|
|
|
388
|
+
The engine offers UDP host candidates on every IPv4 interface and, when this
|
|
389
|
+
computer has an IPv6 address other than loopback and link-local, on every such
|
|
390
|
+
IPv6 address too. A phone off home Wi-Fi often has only a carrier IPv6 address,
|
|
391
|
+
and that is its one direct path here. Loopback and link-local addresses are
|
|
392
|
+
never offered.
|
|
393
|
+
|
|
381
394
|
With `loopback_tcp`, the engine also listens for ICE over TCP on
|
|
382
395
|
`127.0.0.1` and offers that passive candidate. It is for a client whose only
|
|
383
396
|
way to this computer is a forward of its loopback, such as an SSH tunnel: the
|
|
@@ -596,7 +609,11 @@ label. A new point replaces the previous one and restarts its timer.
|
|
|
596
609
|
Coordinates are integers in the **selected desktop's original pixels**, top-left
|
|
597
610
|
origin, before video scaling (`geometry.source`, not `geometry.encoded`). On X11
|
|
598
611
|
this is the root screen; on macOS it is the selected display, with native pixels
|
|
599
|
-
converted to display points.
|
|
612
|
+
converted to display points. On a located Hyprland window share (see Input and
|
|
613
|
+
clipboard) they are the window's own pixels, and the ring shows on the desktop
|
|
614
|
+
point a press there would land, on the output holding it; a point the window
|
|
615
|
+
cannot take there is refused with `indicator-unavailable` and the reason. Both
|
|
616
|
+
coordinates must be within its bounds. The
|
|
600
617
|
ring may clip at an edge; its label stays inside the display. `timeout_ms`
|
|
601
618
|
defaults to 3000 and must be an integer from 1 to 120000. Labels are at most 96
|
|
602
619
|
printable ASCII bytes, including spaces; other labels are refused rather than
|
|
@@ -688,6 +705,25 @@ so a tap would land at the same fraction of the desktop instead of on the
|
|
|
688
705
|
window. Keys, text, wheel, pointer `cancel` and `release_all` are unaffected;
|
|
689
706
|
share the monitor to point and click.
|
|
690
707
|
|
|
708
|
+
On Hyprland the engine places the pointer itself. Hyprland announces each
|
|
709
|
+
window share by title on its event socket, and the engine takes the one window
|
|
710
|
+
in `j/clients` with that title and the stream's size. That match runs once, at
|
|
711
|
+
the first tap, and the announcement says nothing else about which share it is:
|
|
712
|
+
if the shared window's title changes before that first tap and another window
|
|
713
|
+
of the stream's size now carries the announced title, taps land on the other
|
|
714
|
+
window; and when two window shares are announced at once (another session, or
|
|
715
|
+
any other consumer of the portal), the engine cannot tell which announcement
|
|
716
|
+
is this share's, so every tap refuses with `input-unavailable` for the
|
|
717
|
+
session's lifetime. Each `move`, `down` and
|
|
718
|
+
`up` warps the pointer to the window's current position plus the local point
|
|
719
|
+
(so a window moved after the share started still maps); buttons and keys go
|
|
720
|
+
through the input device as before. A tap is still refused with
|
|
721
|
+
`input-unavailable` when the window cannot be told apart (no announcement,
|
|
722
|
+
no such window, or several that look alike), when it has closed or sits on a
|
|
723
|
+
hidden workspace, when another window covers the tapped point or a layer
|
|
724
|
+
surface sits over it, or when the window hangs off screen. Other compositors
|
|
725
|
+
keep the refusal above.
|
|
726
|
+
|
|
691
727
|
The engine holds its own pressed-key/button state. `close`, `release_all`,
|
|
692
728
|
control-channel loss and process exit all synthesize releases for exactly the
|
|
693
729
|
keys and buttons this session pressed, so a dropped connection cannot leave a
|
package/engine/Cargo.lock
CHANGED
package/engine/Cargo.toml
CHANGED
|
@@ -97,7 +97,26 @@ struct Overlay {
|
|
|
97
97
|
struct wl_surface *surface;
|
|
98
98
|
struct Buffer buffers[2];
|
|
99
99
|
int width, height, scale, source_w, source_h, configured, closed;
|
|
100
|
+
// Desktop mode: points are compositor layout coordinates, drawn only on
|
|
101
|
+
// the output this surface is on (`mine`), never scaled onto another.
|
|
102
|
+
int desktop; struct Output *mine;
|
|
100
103
|
};
|
|
104
|
+
static int locate(struct Overlay *o, double px, double py, double *x, double *y) {
|
|
105
|
+
if(!o->desktop)return desklink_wayland_geometry(o->source_w,o->source_h,o->width,o->height,px,py,x,y);
|
|
106
|
+
struct Output *m=o->mine;
|
|
107
|
+
if(!m||!m->valid||px<m->x||py<m->y||px>=m->x+m->w||py>=m->y+m->h)return 0;
|
|
108
|
+
*x=px-m->x;*y=py-m->y;
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
static void surface_enter(void *data, struct wl_surface *surface, struct wl_output *output) {
|
|
112
|
+
(void)surface;struct Overlay *o=data;
|
|
113
|
+
for(int i=0;i<o->output_count;i++)if(o->outputs[i].handle==output)o->mine=&o->outputs[i];
|
|
114
|
+
}
|
|
115
|
+
static void surface_leave(void *data, struct wl_surface *surface, struct wl_output *output) {
|
|
116
|
+
(void)surface;struct Overlay *o=data;
|
|
117
|
+
if(o->mine&&o->mine->handle==output)o->mine=NULL;
|
|
118
|
+
}
|
|
119
|
+
static const struct wl_surface_listener surface_listener={.enter=surface_enter,.leave=surface_leave};
|
|
101
120
|
static double now(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC,&t); return t.tv_sec+t.tv_nsec/1e9; }
|
|
102
121
|
static void registry_global(void *data, struct wl_registry *registry, uint32_t name, const char *interface, uint32_t version) {
|
|
103
122
|
struct Overlay *o=data;
|
|
@@ -214,9 +233,9 @@ static void draw_point(struct Overlay *o,struct Buffer *b,double x,double y,cons
|
|
|
214
233
|
}
|
|
215
234
|
}
|
|
216
235
|
|
|
217
|
-
static int overlay_main(int source_w,int source_h,int point_mode,int origin_known,int origin_x,int origin_y) {
|
|
236
|
+
static int overlay_main(int source_w,int source_h,int point_mode,int origin_known,int origin_x,int origin_y,int desktop) {
|
|
218
237
|
|
|
219
|
-
struct Overlay o={.source_w=source_w,.source_h=source_h,.scale=1};
|
|
238
|
+
struct Overlay o={.source_w=source_w,.source_h=source_h,.scale=1,.desktop=desktop};
|
|
220
239
|
o.display=wl_display_connect(NULL);
|
|
221
240
|
if(!o.display)return 2;
|
|
222
241
|
struct wl_registry *registry=wl_display_get_registry(o.display);
|
|
@@ -225,18 +244,18 @@ static int overlay_main(int source_w,int source_h,int point_mode,int origin_know
|
|
|
225
244
|
if(!o.shell){puts("layer_shell_unavailable");fflush(stdout);wl_display_disconnect(o.display);return 2;}
|
|
226
245
|
if(!o.compositor||!o.shm){wl_display_disconnect(o.display);return 2;}
|
|
227
246
|
struct wl_output *selected=NULL;int matches=0;
|
|
228
|
-
if(point_mode) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
wl_proxy_add_listener(out->logical,logical_listener,out);
|
|
234
|
-
}
|
|
235
|
-
if(wl_display_roundtrip(o.display)<0)return 2;
|
|
247
|
+
if((point_mode||desktop) && o.output_manager) {
|
|
248
|
+
for(int i=0;i<o.output_count;i++) {
|
|
249
|
+
struct Output *out=&o.outputs[i];
|
|
250
|
+
out->logical=wl_proxy_marshal_flags(o.output_manager,1,&xdg_output_interface,wl_proxy_get_version(o.output_manager),0,NULL,out->handle);
|
|
251
|
+
wl_proxy_add_listener(out->logical,logical_listener,out);
|
|
236
252
|
}
|
|
253
|
+
if(wl_display_roundtrip(o.display)<0)return 2;
|
|
254
|
+
}
|
|
255
|
+
if(point_mode) {
|
|
237
256
|
for(int i=0;i<o.output_count;i++) {
|
|
238
257
|
struct Output *out=&o.outputs[i];
|
|
239
|
-
if(origin_known && out->valid && origin_x>=out->x && origin_y>=out->y && origin_x<out->x+out->w && origin_y<out->y+out->h){selected=out->handle;matches++;}
|
|
258
|
+
if(origin_known && out->valid && origin_x>=out->x && origin_y>=out->y && origin_x<out->x+out->w && origin_y<out->y+out->h){selected=out->handle;o.mine=out;matches++;}
|
|
240
259
|
}
|
|
241
260
|
if(matches>1)selected=NULL;
|
|
242
261
|
if(!selected && o.output_count==1 && (!origin_known || !o.output_manager))selected=o.outputs[0].handle;
|
|
@@ -250,6 +269,8 @@ static int overlay_main(int source_w,int source_h,int point_mode,int origin_know
|
|
|
250
269
|
FT_Library library=NULL;FT_Face font=point_mode?point_font(&library):NULL;
|
|
251
270
|
if(point_mode&&!font){wl_display_disconnect(o.display);return 2;}
|
|
252
271
|
o.surface=wl_compositor_create_surface(o.compositor);
|
|
272
|
+
// An agent cue's surface lands where the compositor puts it; it learns which output that is.
|
|
273
|
+
if(desktop && !point_mode)wl_surface_add_listener(o.surface,&surface_listener,&o);
|
|
253
274
|
o.layer=wl_proxy_marshal_flags(o.shell,0,&layer_surface_interface,1,0,NULL,o.surface,selected,3,point_mode?"desklink-point":"desklink-agent");
|
|
254
275
|
wl_proxy_add_listener(o.layer,layer_listener,&o);
|
|
255
276
|
uint32_t anchor,keyboard_focus,empty_input;
|
|
@@ -289,19 +310,23 @@ static int overlay_main(int source_w,int source_h,int point_mode,int origin_know
|
|
|
289
310
|
if(point_mode && kind=='P') {
|
|
290
311
|
unsigned timeout=0;int offset=0;
|
|
291
312
|
if(sscanf(pending,"P %lf %lf %u %n",&px,&py,&timeout,&offset)==3 && offset && timeout>=1 && timeout<=120000 && strlen(pending+offset)<=96) {
|
|
292
|
-
|
|
293
|
-
|
|
313
|
+
snprintf(label,sizeof(label),"%s",pending+offset);deadline=now()+timeout/1000.0;active=1;dirty=1;
|
|
314
|
+
visible=locate(&o,px,py,&x,&y);
|
|
294
315
|
}
|
|
295
316
|
}
|
|
296
317
|
else if(kind=='S')ending=now();
|
|
297
318
|
else if(kind=='M'||kind=='C'||kind=='T'||kind=='A') {
|
|
319
|
+
int placed=1;
|
|
298
320
|
if(px>=0&&py>=0) {
|
|
299
|
-
|
|
300
|
-
|
|
321
|
+
placed=locate(&o,px,py,&tx,&ty);
|
|
322
|
+
if(placed && !positioned){x=tx;y=ty;positioned=1;cue_started=now();}
|
|
323
|
+
// A desktop point off this output has no place here:
|
|
324
|
+
// the cue hides rather than show anywhere else.
|
|
325
|
+
if(!placed && o.desktop){positioned=0;cue_started=0;}
|
|
301
326
|
}
|
|
302
327
|
if(!active)started=now();
|
|
303
328
|
active=1;
|
|
304
|
-
if(kind=='C')click=now();
|
|
329
|
+
if(kind=='C' && (placed||!o.desktop))click=now();
|
|
305
330
|
if(kind=='T')typed=now();
|
|
306
331
|
}
|
|
307
332
|
}
|
|
@@ -331,8 +356,8 @@ static int overlay_main(int source_w,int source_h,int point_mode,int origin_know
|
|
|
331
356
|
return 0;
|
|
332
357
|
}
|
|
333
358
|
|
|
334
|
-
int desklink_agent_overlay_wayland(int w,int h){return overlay_main(w,h,0,0,0,0);}
|
|
335
|
-
int desklink_point_overlay_wayland(int w,int h,int known,int x,int y){return overlay_main(w,h,1,known,x,y);}
|
|
359
|
+
int desklink_agent_overlay_wayland(int w,int h,int desktop){return overlay_main(w,h,0,0,0,0,desktop);}
|
|
360
|
+
int desklink_point_overlay_wayland(int w,int h,int known,int x,int y,int desktop){return overlay_main(w,h,1,known,x,y,desktop);}
|
|
336
361
|
|
|
337
362
|
// Read-only capability probe for idempotent clear before a helper exists.
|
|
338
363
|
int desklink_wayland_layer_shell_available(void) {
|
package/engine/src/capture.rs
CHANGED
|
@@ -303,7 +303,6 @@ fn run_loop(
|
|
|
303
303
|
*pw::keys::MEDIA_CATEGORY => "Capture",
|
|
304
304
|
*pw::keys::MEDIA_ROLE => "Screen",
|
|
305
305
|
};
|
|
306
|
-
props.insert(*pw::keys::TARGET_OBJECT, node_id.to_string());
|
|
307
306
|
let stream = pw::stream::StreamBox::new(&core, "desklink-capture", props)
|
|
308
307
|
.context("pw_stream_new failed")?;
|
|
309
308
|
|
|
@@ -0,0 +1,329 @@
|
|
|
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, and say
|
|
88
|
+
/// which desktop point that was.
|
|
89
|
+
pub fn place(&self, x: i64, y: i64) -> Result<(i64, i64), String> {
|
|
90
|
+
let (px, py) = self.locate(x, y)?;
|
|
91
|
+
let point = (px.floor() as i64, py.floor() as i64);
|
|
92
|
+
self.warp(point)?;
|
|
93
|
+
Ok(point)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// The desktop point `(x, y)` in the shared window's own pixels shows
|
|
97
|
+
/// at, when a press there would reach the window.
|
|
98
|
+
pub fn locate(&self, x: i64, y: i64) -> Result<(f64, f64), String> {
|
|
99
|
+
let (clients, monitors) = (query("j/clients")?, query("j/monitors")?);
|
|
100
|
+
let clients = clients.as_array().map(Vec::as_slice).unwrap_or_default();
|
|
101
|
+
// A window nobody sees has no point on the screen.
|
|
102
|
+
let shown = |window: &Value| {
|
|
103
|
+
let workspace = &window["workspace"]["id"];
|
|
104
|
+
window["mapped"].as_bool() != Some(false)
|
|
105
|
+
&& window["hidden"].as_bool() != Some(true)
|
|
106
|
+
&& (window["pinned"].as_bool() == Some(true)
|
|
107
|
+
|| monitors.as_array().into_iter().flatten().any(|monitor| {
|
|
108
|
+
&monitor["activeWorkspace"]["id"] == workspace
|
|
109
|
+
|| &monitor["specialWorkspace"]["id"] == workspace
|
|
110
|
+
}))
|
|
111
|
+
};
|
|
112
|
+
let mut address = lock(&self.address);
|
|
113
|
+
if address.is_none() {
|
|
114
|
+
*address = Some(self.identify(clients, &monitors)?);
|
|
115
|
+
}
|
|
116
|
+
let index = clients
|
|
117
|
+
.iter()
|
|
118
|
+
.position(|client| client["address"].as_str() == address.as_deref())
|
|
119
|
+
.ok_or("a shared window cannot take taps: the window has closed")?;
|
|
120
|
+
let window = &clients[index];
|
|
121
|
+
if !shown(window) {
|
|
122
|
+
return Err(String::from(
|
|
123
|
+
"a shared window cannot take taps while it is on a hidden workspace",
|
|
124
|
+
));
|
|
125
|
+
}
|
|
126
|
+
let (left, top, width, height) = bounds(window).ok_or(UNKNOWN)?;
|
|
127
|
+
let (source_w, source_h) = self.source;
|
|
128
|
+
let (px, py) = (
|
|
129
|
+
left + x as f64 * width / source_w as f64,
|
|
130
|
+
top + y as f64 * height / source_h as f64,
|
|
131
|
+
);
|
|
132
|
+
// Hyprland clamps the cursor to the screen edge, so a point off every
|
|
133
|
+
// monitor would press something else or nothing.
|
|
134
|
+
let on_screen = monitors.as_array().into_iter().flatten().any(|monitor| {
|
|
135
|
+
let scale = monitor["scale"].as_f64().filter(|scale| *scale > 0.0);
|
|
136
|
+
monitor["disabled"].as_bool() != Some(true)
|
|
137
|
+
&& matches!(
|
|
138
|
+
(
|
|
139
|
+
monitor["x"].as_f64(),
|
|
140
|
+
monitor["y"].as_f64(),
|
|
141
|
+
monitor["width"].as_f64(),
|
|
142
|
+
monitor["height"].as_f64(),
|
|
143
|
+
scale
|
|
144
|
+
),
|
|
145
|
+
(Some(mx), Some(my), Some(mw), Some(mh), Some(scale))
|
|
146
|
+
if (mx..mx + mw / scale).contains(&px)
|
|
147
|
+
&& (my..my + mh / scale).contains(&py)
|
|
148
|
+
)
|
|
149
|
+
});
|
|
150
|
+
if !on_screen {
|
|
151
|
+
return Err(String::from(
|
|
152
|
+
"a shared window cannot take taps where it hangs off screen",
|
|
153
|
+
));
|
|
154
|
+
}
|
|
155
|
+
// Hyprland draws tiled windows, then floating ones, then fullscreen
|
|
156
|
+
// ones, and pinned ones above all; j/clients lists them in the
|
|
157
|
+
// compositor's own stacking order, so a later layer-mate holding the
|
|
158
|
+
// point is what a press there would reach. A floating window let over
|
|
159
|
+
// fullscreen draws above it, but j/clients cannot tell that one from
|
|
160
|
+
// any floating window, so either pairing over the point is a refusal.
|
|
161
|
+
let layer = |w: &Value| {
|
|
162
|
+
if w["pinned"].as_bool() == Some(true) {
|
|
163
|
+
3
|
|
164
|
+
} else if w["fullscreen"].as_i64().is_some_and(|mode| mode > 0) {
|
|
165
|
+
2
|
|
166
|
+
} else {
|
|
167
|
+
u8::from(w["floating"].as_bool() == Some(true))
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
let covered = clients.iter().enumerate().any(|(i, other)| {
|
|
171
|
+
shown(other)
|
|
172
|
+
&& bounds(other).is_some_and(|(l, t, w, h)| {
|
|
173
|
+
(l..l + w).contains(&px) && (t..t + h).contains(&py)
|
|
174
|
+
})
|
|
175
|
+
&& match (layer(other), layer(window), i > index) {
|
|
176
|
+
(1, 2, _) | (2, 1, _) => true,
|
|
177
|
+
(above, own, later) => above > own || (above == own && later),
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
if covered {
|
|
181
|
+
return Err(String::from(
|
|
182
|
+
"a shared window cannot take taps where another window covers it",
|
|
183
|
+
));
|
|
184
|
+
}
|
|
185
|
+
// Bars, notifications and other layer surfaces draw above windows and
|
|
186
|
+
// are absent from the window-only video: a top or overlay one holding
|
|
187
|
+
// the point is what a press there would reach. Their boxes come local
|
|
188
|
+
// to their monitor, which j/monitors places.
|
|
189
|
+
let anchored = |name: &str| -> Option<(f64, f64)> {
|
|
190
|
+
for monitor in monitors.as_array().into_iter().flatten() {
|
|
191
|
+
if monitor["name"].as_str() == Some(name) {
|
|
192
|
+
return Some((monitor["x"].as_f64()?, monitor["y"].as_f64()?));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
None
|
|
196
|
+
};
|
|
197
|
+
let overlaid = query("j/layers")?
|
|
198
|
+
.as_object()
|
|
199
|
+
.into_iter()
|
|
200
|
+
.flatten()
|
|
201
|
+
.map(|(name, monitor)| Some((anchored(name)?, monitor)))
|
|
202
|
+
.collect::<Option<Vec<((f64, f64), &Value)>>>()
|
|
203
|
+
.ok_or(String::from(
|
|
204
|
+
"a shared window cannot take taps: a screen's layers are unknown",
|
|
205
|
+
))?
|
|
206
|
+
.into_iter()
|
|
207
|
+
.any(|((mx, my), monitor)| {
|
|
208
|
+
monitor["levels"]
|
|
209
|
+
.as_object()
|
|
210
|
+
.into_iter()
|
|
211
|
+
.flatten()
|
|
212
|
+
.any(|(level, surfaces)| {
|
|
213
|
+
let level = level.parse::<u8>().unwrap_or(0);
|
|
214
|
+
(level >= 2)
|
|
215
|
+
&& surfaces.as_array().into_iter().flatten().any(|surface| {
|
|
216
|
+
let (sx, sy) = (surface["x"].as_f64(), surface["y"].as_f64());
|
|
217
|
+
let (sw, sh) = (surface["w"].as_f64(), surface["h"].as_f64());
|
|
218
|
+
!matches!(
|
|
219
|
+
surface["namespace"].as_str(),
|
|
220
|
+
Some("desklink-agent" | "desklink-point")
|
|
221
|
+
) && matches!(
|
|
222
|
+
(sx, sy, sw, sh),
|
|
223
|
+
(Some(sx), Some(sy), Some(sw), Some(sh))
|
|
224
|
+
if (mx + sx..mx + sx + sw).contains(&px)
|
|
225
|
+
&& (my + sy..my + sy + sh).contains(&py)
|
|
226
|
+
)
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
});
|
|
230
|
+
if overlaid {
|
|
231
|
+
return Err(String::from(
|
|
232
|
+
"a shared window cannot take taps where an overlay covers it",
|
|
233
|
+
));
|
|
234
|
+
}
|
|
235
|
+
Ok((px, py))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// The address of the one window Hyprland said it is sharing.
|
|
239
|
+
fn identify(&self, clients: &[Value], monitors: &Value) -> Result<String, String> {
|
|
240
|
+
let mut titles = lock(&self.watch.titles).clone();
|
|
241
|
+
titles.dedup();
|
|
242
|
+
let [title] = titles.as_slice() else {
|
|
243
|
+
return Err(String::from(UNKNOWN));
|
|
244
|
+
};
|
|
245
|
+
let (source_w, source_h) = self.source;
|
|
246
|
+
let scale = |monitor: &Value| {
|
|
247
|
+
monitors
|
|
248
|
+
.as_array()
|
|
249
|
+
.into_iter()
|
|
250
|
+
.flatten()
|
|
251
|
+
.find(|m| &m["id"] == monitor)
|
|
252
|
+
.and_then(|m| m["scale"].as_f64())
|
|
253
|
+
};
|
|
254
|
+
let alike: Vec<&Value> = clients
|
|
255
|
+
.iter()
|
|
256
|
+
.filter(|client| {
|
|
257
|
+
let size = |i: usize| client["size"][i].as_f64();
|
|
258
|
+
client["title"].as_str() == Some(title.as_str())
|
|
259
|
+
&& client["mapped"].as_bool() != Some(false)
|
|
260
|
+
&& client["hidden"].as_bool() != Some(true)
|
|
261
|
+
&& matches!(
|
|
262
|
+
(size(0), size(1), scale(&client["monitor"])),
|
|
263
|
+
(Some(w), Some(h), Some(s))
|
|
264
|
+
if ((w * s).round() as i64 - source_w).abs() <= 1
|
|
265
|
+
&& ((h * s).round() as i64 - source_h).abs() <= 1
|
|
266
|
+
)
|
|
267
|
+
})
|
|
268
|
+
.collect();
|
|
269
|
+
match alike.as_slice() {
|
|
270
|
+
[window] => window["address"]
|
|
271
|
+
.as_str()
|
|
272
|
+
.map(str::to_owned)
|
|
273
|
+
.ok_or_else(|| String::from(UNKNOWN)),
|
|
274
|
+
[] => Err(String::from(UNKNOWN)),
|
|
275
|
+
_ => Err(format!(
|
|
276
|
+
"a shared window cannot take taps: {} windows look like it; share the whole screen to point and click",
|
|
277
|
+
alike.len()
|
|
278
|
+
)),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
fn warp(&self, (x, y): (i64, i64)) -> Result<(), String> {
|
|
283
|
+
let legacy = format!("dispatch movecursor {x} {y}");
|
|
284
|
+
let lua = format!("dispatch hl.dsp.cursor.move({{ x = {x}, y = {y} }})");
|
|
285
|
+
let mut dialect = lock(&self.lua);
|
|
286
|
+
let tries: &[(bool, &str)] = match *dialect {
|
|
287
|
+
Some(true) => &[(true, &lua)],
|
|
288
|
+
Some(false) => &[(false, &legacy)],
|
|
289
|
+
None => &[(false, &legacy), (true, &lua)],
|
|
290
|
+
};
|
|
291
|
+
let mut said = String::new();
|
|
292
|
+
for (is_lua, command) in tries {
|
|
293
|
+
said = request(command)?;
|
|
294
|
+
if said.trim() == "ok" {
|
|
295
|
+
*dialect = Some(*is_lua);
|
|
296
|
+
return Ok(());
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
Err(format!(
|
|
300
|
+
"Hyprland did not move the pointer: {}",
|
|
301
|
+
said.trim()
|
|
302
|
+
))
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/// A window's box in the layout: left, top, width, height.
|
|
307
|
+
fn bounds(window: &Value) -> Option<(f64, f64, f64, f64)> {
|
|
308
|
+
let pair = |key: &str| Some((window[key][0].as_f64()?, window[key][1].as_f64()?));
|
|
309
|
+
let ((left, top), (width, height)) = pair("at").zip(pair("size"))?;
|
|
310
|
+
Some((left, top, width, height))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
fn request(command: &str) -> Result<String, String> {
|
|
314
|
+
let ask = || -> std::io::Result<String> {
|
|
315
|
+
let path = instance()
|
|
316
|
+
.ok_or_else(|| std::io::Error::other("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
|
|
317
|
+
let mut socket = UnixStream::connect(path.join(".socket.sock"))?;
|
|
318
|
+
socket.write_all(command.as_bytes())?;
|
|
319
|
+
let mut reply = String::new();
|
|
320
|
+
socket.read_to_string(&mut reply)?;
|
|
321
|
+
Ok(reply)
|
|
322
|
+
};
|
|
323
|
+
ask().map_err(|error| format!("Hyprland's control socket did not answer: {error}"))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
fn query(command: &str) -> Result<Value, String> {
|
|
327
|
+
serde_json::from_str(&request(command)?)
|
|
328
|
+
.map_err(|error| format!("Hyprland's {command} was not JSON: {error}"))
|
|
329
|
+
}
|
package/engine/src/indicator.rs
CHANGED
|
@@ -157,11 +157,14 @@ impl Indicator {
|
|
|
157
157
|
)
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/// `desktop`: points are compositor layout coordinates, drawn on the
|
|
161
|
+
/// output that holds `position`.
|
|
160
162
|
#[cfg(target_os = "linux")]
|
|
161
163
|
pub fn start_point_wayland(
|
|
162
164
|
width: i32,
|
|
163
165
|
height: i32,
|
|
164
166
|
position: Option<(i32, i32)>,
|
|
167
|
+
desktop: bool,
|
|
165
168
|
) -> Result<Self> {
|
|
166
169
|
let (x, y) = position.unwrap_or_default();
|
|
167
170
|
Self::spawn(
|
|
@@ -173,6 +176,7 @@ impl Indicator {
|
|
|
173
176
|
i32::from(position.is_some()).to_string(),
|
|
174
177
|
x.to_string(),
|
|
175
178
|
y.to_string(),
|
|
179
|
+
i32::from(desktop).to_string(),
|
|
176
180
|
]),
|
|
177
181
|
1.0,
|
|
178
182
|
1.0,
|
|
@@ -217,14 +221,17 @@ impl Indicator {
|
|
|
217
221
|
}
|
|
218
222
|
}
|
|
219
223
|
|
|
224
|
+
/// `desktop`: cues are compositor layout coordinates, drawn only where
|
|
225
|
+
/// they fall on the output the overlay opened on.
|
|
220
226
|
#[cfg(target_os = "linux")]
|
|
221
|
-
pub fn start_wayland(width: usize, height: usize) -> Result<Self> {
|
|
227
|
+
pub fn start_wayland(width: usize, height: usize, desktop: bool) -> Result<Self> {
|
|
222
228
|
let executable = std::env::current_exe()?;
|
|
223
229
|
Self::spawn(
|
|
224
230
|
Command::new(executable)
|
|
225
231
|
.arg("agent-overlay-wayland")
|
|
226
232
|
.arg(width.to_string())
|
|
227
|
-
.arg(height.to_string())
|
|
233
|
+
.arg(height.to_string())
|
|
234
|
+
.args(desktop.then_some("desktop")),
|
|
228
235
|
1.0,
|
|
229
236
|
1.0,
|
|
230
237
|
)
|
package/engine/src/main.rs
CHANGED
|
@@ -45,6 +45,8 @@ mod h264_openh264;
|
|
|
45
45
|
mod h264_vaapi;
|
|
46
46
|
#[cfg(all(target_os = "macos", desklink_vpx))]
|
|
47
47
|
mod h264_vt;
|
|
48
|
+
#[cfg(target_os = "linux")]
|
|
49
|
+
mod hyprland;
|
|
48
50
|
#[cfg(any(
|
|
49
51
|
target_os = "linux",
|
|
50
52
|
all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
|
|
@@ -134,9 +136,16 @@ use tokio::sync::mpsc as tokio_mpsc;
|
|
|
134
136
|
unsafe extern "C" {
|
|
135
137
|
fn desklink_point_overlay_main(display: *const std::ffi::c_char) -> i32;
|
|
136
138
|
fn desklink_agent_overlay_main(display: *const std::ffi::c_char) -> i32;
|
|
137
|
-
fn desklink_agent_overlay_wayland(source_w: i32, source_h: i32) -> i32;
|
|
139
|
+
fn desklink_agent_overlay_wayland(source_w: i32, source_h: i32, desktop: i32) -> i32;
|
|
138
140
|
fn desklink_wayland_layer_shell_available() -> i32;
|
|
139
|
-
fn desklink_point_overlay_wayland(
|
|
141
|
+
fn desklink_point_overlay_wayland(
|
|
142
|
+
w: i32,
|
|
143
|
+
h: i32,
|
|
144
|
+
known: i32,
|
|
145
|
+
x: i32,
|
|
146
|
+
y: i32,
|
|
147
|
+
desktop: i32,
|
|
148
|
+
) -> i32;
|
|
140
149
|
}
|
|
141
150
|
|
|
142
151
|
#[cfg(target_os = "linux")]
|
|
@@ -181,12 +190,17 @@ fn main() {
|
|
|
181
190
|
},
|
|
182
191
|
"point-overlay-wayland" => {
|
|
183
192
|
let values: Vec<i32> = args.iter().skip(1).filter_map(|v| v.parse().ok()).collect();
|
|
184
|
-
if values.len()
|
|
193
|
+
if !(5..=6).contains(&values.len()) || values[0] <= 0 || values[1] <= 0 {
|
|
185
194
|
2
|
|
186
195
|
} else {
|
|
187
196
|
unsafe {
|
|
188
197
|
desklink_point_overlay_wayland(
|
|
189
|
-
values[0],
|
|
198
|
+
values[0],
|
|
199
|
+
values[1],
|
|
200
|
+
values[2],
|
|
201
|
+
values[3],
|
|
202
|
+
values[4],
|
|
203
|
+
values.get(5).copied().unwrap_or(0),
|
|
190
204
|
)
|
|
191
205
|
}
|
|
192
206
|
}
|
|
@@ -197,7 +211,8 @@ fn main() {
|
|
|
197
211
|
if width <= 0 || height <= 0 {
|
|
198
212
|
2
|
|
199
213
|
} else {
|
|
200
|
-
|
|
214
|
+
let desktop = i32::from(args.get(3).map(String::as_str) == Some("desktop"));
|
|
215
|
+
unsafe { desklink_agent_overlay_wayland(width, height, desktop) }
|
|
201
216
|
}
|
|
202
217
|
}
|
|
203
218
|
"capabilities" => {
|
package/engine/src/peer.rs
CHANGED
|
@@ -267,6 +267,33 @@ pub struct TransportOptions {
|
|
|
267
267
|
pub pace_bps: f64,
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
/// The UDP sockets ICE gathers host candidates from: an ephemeral port on every
|
|
271
|
+
/// IPv4 interface and, when this computer has an IPv6 address a peer could
|
|
272
|
+
/// reach, on every IPv6 one too. Mobile carriers commonly hand a phone a global
|
|
273
|
+
/// IPv6 address, and a stateful firewall passes the simultaneous checks, so
|
|
274
|
+
/// IPv6 can be the only direct path a phone off home Wi-Fi has. The library
|
|
275
|
+
/// expands a wildcard into the interfaces' own addresses, skipping loopback and
|
|
276
|
+
/// link-local; with none left it binds the wildcard itself and offers `::`,
|
|
277
|
+
/// which no peer can use, so `[::]` is asked for only when there is one.
|
|
278
|
+
fn udp_addrs() -> Vec<std::net::SocketAddr> {
|
|
279
|
+
let has_ipv6 = rtc::shared::ifaces::ifaces()
|
|
280
|
+
.unwrap_or_default()
|
|
281
|
+
.iter()
|
|
282
|
+
.filter_map(|interface| interface.addr)
|
|
283
|
+
.any(|addr| match addr.ip() {
|
|
284
|
+
std::net::IpAddr::V6(ip) => {
|
|
285
|
+
// fe80::/10; `Ipv6Addr::is_unicast_link_local` is still unstable.
|
|
286
|
+
!ip.is_loopback() && !ip.is_unspecified() && ip.segments()[0] & 0xffc0 != 0xfe80
|
|
287
|
+
}
|
|
288
|
+
std::net::IpAddr::V4(_) => false,
|
|
289
|
+
});
|
|
290
|
+
let mut addrs = vec![std::net::SocketAddr::from(([0, 0, 0, 0], 0))];
|
|
291
|
+
if has_ipv6 {
|
|
292
|
+
addrs.push(std::net::SocketAddr::from(([0u16; 8], 0)));
|
|
293
|
+
}
|
|
294
|
+
addrs
|
|
295
|
+
}
|
|
296
|
+
|
|
270
297
|
/// What the pacer lets go back to back: a few dozen packets. A 4K key frame is
|
|
271
298
|
/// hundreds of packets, and released at once it overflows the receiver's
|
|
272
299
|
/// socket buffer, which loses the frame and draws another key frame request —
|
|
@@ -899,7 +926,7 @@ impl VideoPeer {
|
|
|
899
926
|
// An ephemeral port on every interface: ICE needs a socket to gather
|
|
900
927
|
// candidates from, and the peer needs no fixed port because the
|
|
901
928
|
// consumer's authenticated channel carries the candidates.
|
|
902
|
-
.with_udp_addrs(
|
|
929
|
+
.with_udp_addrs(udp_addrs())
|
|
903
930
|
.with_tcp_addrs(if options.loopback_tcp {
|
|
904
931
|
vec![std::net::SocketAddr::from(([127, 0, 0, 1], 0))]
|
|
905
932
|
} else {
|
|
@@ -1258,7 +1285,7 @@ pub(crate) mod tests {
|
|
|
1258
1285
|
.with_interceptor_registry(registry)
|
|
1259
1286
|
.with_runtime(default_runtime().expect("a runtime"))
|
|
1260
1287
|
.with_handler(Arc::new(NoopHandler))
|
|
1261
|
-
.with_udp_addrs(
|
|
1288
|
+
.with_udp_addrs(udp_addrs())
|
|
1262
1289
|
.build()
|
|
1263
1290
|
.await
|
|
1264
1291
|
.expect("an answering peer");
|
|
@@ -1667,7 +1694,10 @@ pub(crate) mod tests {
|
|
|
1667
1694
|
}
|
|
1668
1695
|
}
|
|
1669
1696
|
|
|
1670
|
-
async fn media_receiver(
|
|
1697
|
+
async fn media_receiver(
|
|
1698
|
+
receiver: Arc<MediaReceiver>,
|
|
1699
|
+
udp: Vec<std::net::SocketAddr>,
|
|
1700
|
+
) -> Arc<dyn PeerConnection> {
|
|
1671
1701
|
let mut media_engine = MediaEngine::default();
|
|
1672
1702
|
media_engine
|
|
1673
1703
|
.register_codec(vp9_codec(), RtpCodecKind::Video)
|
|
@@ -1679,18 +1709,25 @@ pub(crate) mod tests {
|
|
|
1679
1709
|
.with_interceptor_registry(registry)
|
|
1680
1710
|
.with_runtime(default_runtime().expect("a runtime"))
|
|
1681
1711
|
.with_handler(receiver)
|
|
1682
|
-
.with_udp_addrs(
|
|
1712
|
+
.with_udp_addrs(udp)
|
|
1683
1713
|
.build()
|
|
1684
1714
|
.await
|
|
1685
1715
|
.expect("a receiving peer");
|
|
1686
1716
|
Arc::new(peer)
|
|
1687
1717
|
}
|
|
1688
1718
|
|
|
1689
|
-
///
|
|
1690
|
-
///
|
|
1691
|
-
///
|
|
1692
|
-
|
|
1693
|
-
|
|
1719
|
+
/// An engine and a client receiving on `client_udp`, offered, answered and
|
|
1720
|
+
/// connected over ICE, with the client's RTP arrival times and the
|
|
1721
|
+
/// candidates the engine offered.
|
|
1722
|
+
struct Connected {
|
|
1723
|
+
engine: Arc<VideoPeer>,
|
|
1724
|
+
offer: String,
|
|
1725
|
+
client: Arc<dyn PeerConnection>,
|
|
1726
|
+
arrivals: Arc<Mutex<Vec<Instant>>>,
|
|
1727
|
+
offered: Arc<Mutex<Vec<String>>>,
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
async fn connected(client_udp: Vec<std::net::SocketAddr>) -> Connected {
|
|
1694
1731
|
let (events, mut events_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
1695
1732
|
let (engine, offer) = VideoPeer::offer(transport(), events, VideoCodec::Vp9)
|
|
1696
1733
|
.await
|
|
@@ -1706,7 +1743,7 @@ pub(crate) mod tests {
|
|
|
1706
1743
|
arrivals: Arc::clone(&arrivals),
|
|
1707
1744
|
connected: Arc::clone(&connected),
|
|
1708
1745
|
});
|
|
1709
|
-
let client = media_receiver(Arc::clone(&receiver)).await;
|
|
1746
|
+
let client = media_receiver(Arc::clone(&receiver), client_udp).await;
|
|
1710
1747
|
|
|
1711
1748
|
client
|
|
1712
1749
|
.set_remote_description(RTCSessionDescription::offer(offer.clone()).expect("an offer"))
|
|
@@ -1724,6 +1761,8 @@ pub(crate) mod tests {
|
|
|
1724
1761
|
|
|
1725
1762
|
// The engine's candidates reach the client.
|
|
1726
1763
|
let client_for_engine_candidates = Arc::clone(&client);
|
|
1764
|
+
let offered: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
|
1765
|
+
let offered_by_engine = Arc::clone(&offered);
|
|
1727
1766
|
tokio::spawn(async move {
|
|
1728
1767
|
while let Some(event) = events_rx.recv().await {
|
|
1729
1768
|
if let PeerEvent::Candidate {
|
|
@@ -1732,6 +1771,7 @@ pub(crate) mod tests {
|
|
|
1732
1771
|
sdp_m_line_index,
|
|
1733
1772
|
} = event
|
|
1734
1773
|
{
|
|
1774
|
+
offered_by_engine.lock().unwrap().push(candidate.clone());
|
|
1735
1775
|
let _ = client_for_engine_candidates
|
|
1736
1776
|
.add_ice_candidate(RTCIceCandidateInit {
|
|
1737
1777
|
candidate,
|
|
@@ -1757,9 +1797,35 @@ pub(crate) mod tests {
|
|
|
1757
1797
|
// Wait for the ICE path to come up on both ends.
|
|
1758
1798
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
1759
1799
|
while !(engine.is_connected() && connected.load(Ordering::SeqCst)) {
|
|
1760
|
-
assert!(
|
|
1800
|
+
assert!(
|
|
1801
|
+
Instant::now() < deadline,
|
|
1802
|
+
"the peers never connected; the engine offered {:?}",
|
|
1803
|
+
offered.lock().unwrap()
|
|
1804
|
+
);
|
|
1761
1805
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
1762
1806
|
}
|
|
1807
|
+
Connected {
|
|
1808
|
+
engine,
|
|
1809
|
+
offer,
|
|
1810
|
+
client,
|
|
1811
|
+
arrivals,
|
|
1812
|
+
offered,
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/// A restart offer/answer round must not interrupt media already flowing on
|
|
1817
|
+
/// the selected pair: the picture keeps advancing (no gap over 200 ms) while
|
|
1818
|
+
/// the old pair stays reachable, and the ICE generation still turns over.
|
|
1819
|
+
#[tokio::test]
|
|
1820
|
+
async fn a_restart_keeps_media_flowing_on_the_selected_pair_until_the_new_one_nominates() {
|
|
1821
|
+
let Connected {
|
|
1822
|
+
engine,
|
|
1823
|
+
offer,
|
|
1824
|
+
client,
|
|
1825
|
+
arrivals,
|
|
1826
|
+
offered,
|
|
1827
|
+
} = connected(vec![std::net::SocketAddr::from(([0, 0, 0, 0], 0))]).await;
|
|
1828
|
+
eprintln!("restart: the engine offered {:?}", offered.lock().unwrap());
|
|
1763
1829
|
|
|
1764
1830
|
// Any bytes the VP9 packetizer turns into one access unit.
|
|
1765
1831
|
let frame: Vec<u8> = (0..200u32).map(|i| (i % 251) as u8).collect();
|
|
@@ -1886,6 +1952,61 @@ pub(crate) mod tests {
|
|
|
1886
1952
|
let _ = client.close().await;
|
|
1887
1953
|
}
|
|
1888
1954
|
|
|
1955
|
+
/// A phone whose only direct way here is IPv6, such as one on a carrier that
|
|
1956
|
+
/// hands it a global IPv6 address, still gets the picture: the engine offers
|
|
1957
|
+
/// its IPv6 addresses, never a loopback or link-local one, and a client that
|
|
1958
|
+
/// can only send from `::1` connects and receives media.
|
|
1959
|
+
#[tokio::test]
|
|
1960
|
+
async fn a_client_with_only_an_ipv6_route_gets_the_picture() {
|
|
1961
|
+
if !udp_addrs().iter().any(std::net::SocketAddr::is_ipv6) {
|
|
1962
|
+
eprintln!("ipv6: skipped, this computer has no IPv6 address a peer could reach");
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
let Connected {
|
|
1966
|
+
engine,
|
|
1967
|
+
client,
|
|
1968
|
+
arrivals,
|
|
1969
|
+
offered,
|
|
1970
|
+
..
|
|
1971
|
+
} = connected(vec![std::net::SocketAddr::from((
|
|
1972
|
+
std::net::Ipv6Addr::LOCALHOST,
|
|
1973
|
+
0,
|
|
1974
|
+
))])
|
|
1975
|
+
.await;
|
|
1976
|
+
let offered = offered.lock().unwrap().clone();
|
|
1977
|
+
eprintln!("ipv6: the engine offered {offered:?}");
|
|
1978
|
+
let ipv6: Vec<std::net::Ipv6Addr> = offered
|
|
1979
|
+
.iter()
|
|
1980
|
+
.filter_map(|candidate| candidate.split(' ').nth(4)?.parse().ok())
|
|
1981
|
+
.collect();
|
|
1982
|
+
assert!(!ipv6.is_empty(), "no IPv6 candidate was offered");
|
|
1983
|
+
assert!(
|
|
1984
|
+
!ipv6
|
|
1985
|
+
.iter()
|
|
1986
|
+
.any(|ip| ip.is_loopback() || ip.segments()[0] & 0xffc0 == 0xfe80),
|
|
1987
|
+
"a loopback or link-local address reached the client: {ipv6:?}"
|
|
1988
|
+
);
|
|
1989
|
+
|
|
1990
|
+
let frame: Vec<u8> = (0..200u32).map(|i| (i % 251) as u8).collect();
|
|
1991
|
+
for _ in 0..50 {
|
|
1992
|
+
engine
|
|
1993
|
+
.send_frame(&frame, true, Instant::now())
|
|
1994
|
+
.await
|
|
1995
|
+
.expect("a frame is sent");
|
|
1996
|
+
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
1997
|
+
}
|
|
1998
|
+
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
1999
|
+
let received = arrivals.lock().unwrap().len();
|
|
2000
|
+
eprintln!("ipv6: {received} of 50 frames arrived at a client on ::1");
|
|
2001
|
+
assert!(
|
|
2002
|
+
received >= 25,
|
|
2003
|
+
"the picture did not arrive over IPv6: {received} packets"
|
|
2004
|
+
);
|
|
2005
|
+
|
|
2006
|
+
engine.close().await;
|
|
2007
|
+
let _ = client.close().await;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
1889
2010
|
#[tokio::test]
|
|
1890
2011
|
async fn a_forwarded_client_is_offered_ice_over_tcp_on_the_loopback() {
|
|
1891
2012
|
let offered = |loopback_tcp: bool| async move {
|
package/engine/src/session.rs
CHANGED
|
@@ -816,6 +816,11 @@ struct Inner {
|
|
|
816
816
|
point_overlay: Mutex<Option<crate::indicator::Indicator>>,
|
|
817
817
|
#[cfg(target_os = "linux")]
|
|
818
818
|
point_desktop: PointDesktop,
|
|
819
|
+
/// A portal window share's place on a Hyprland desktop; absent elsewhere,
|
|
820
|
+
/// where a shared window's taps are refused. Dropped on close, which
|
|
821
|
+
/// closes its Hyprland event socket.
|
|
822
|
+
#[cfg(target_os = "linux")]
|
|
823
|
+
window: Mutex<Option<crate::hyprland::WindowLocator>>,
|
|
819
824
|
capture: Mutex<Option<FrameSource>>,
|
|
820
825
|
/// Absent for an encoded source: there is no local keyboard to translate a
|
|
821
826
|
/// character through, because the client's keys are forwarded instead.
|
|
@@ -1195,6 +1200,8 @@ impl Session {
|
|
|
1195
1200
|
None
|
|
1196
1201
|
};
|
|
1197
1202
|
let mut cursor_info = serde_json::json!({"mode":"embedded", "positions":false});
|
|
1203
|
+
#[cfg(target_os = "linux")]
|
|
1204
|
+
let mut share_watch = None;
|
|
1198
1205
|
let Selected {
|
|
1199
1206
|
source,
|
|
1200
1207
|
capture,
|
|
@@ -1225,6 +1232,9 @@ impl Session {
|
|
|
1225
1232
|
_ => {
|
|
1226
1233
|
#[cfg(target_os = "linux")]
|
|
1227
1234
|
{
|
|
1235
|
+
// Hyprland names the window it shares only on its
|
|
1236
|
+
// event socket, as the share starts.
|
|
1237
|
+
share_watch = crate::hyprland::ShareWatch::start();
|
|
1228
1238
|
portal::open(request.restore_token.as_deref(), request.cursor).await
|
|
1229
1239
|
}
|
|
1230
1240
|
#[cfg(not(target_os = "linux"))]
|
|
@@ -1257,9 +1267,16 @@ impl Session {
|
|
|
1257
1267
|
let source_h = source.height.max(1) as usize;
|
|
1258
1268
|
let (width, height) =
|
|
1259
1269
|
fit(source_w, source_h, request.max_width, request.max_height);
|
|
1270
|
+
// A Hyprland window share's cues go to the desktop point each
|
|
1271
|
+
// press lands on, and its picture holds no layer surface to mask.
|
|
1272
|
+
#[cfg(target_os = "linux")]
|
|
1273
|
+
let desktop =
|
|
1274
|
+
share_watch.is_some() && source.source_type.as_deref() == Some("Window");
|
|
1260
1275
|
#[cfg(target_os = "linux")]
|
|
1261
1276
|
if request.agent_indicator {
|
|
1262
|
-
indicator =
|
|
1277
|
+
indicator =
|
|
1278
|
+
crate::indicator::Indicator::start_wayland(source_w, source_h, desktop)
|
|
1279
|
+
.ok();
|
|
1263
1280
|
}
|
|
1264
1281
|
#[cfg(target_os = "macos")]
|
|
1265
1282
|
if request.agent_indicator {
|
|
@@ -1302,6 +1319,7 @@ impl Session {
|
|
|
1302
1319
|
sink,
|
|
1303
1320
|
indicator
|
|
1304
1321
|
.as_ref()
|
|
1322
|
+
.filter(|_| !desktop)
|
|
1305
1323
|
.map(crate::indicator::Indicator::position),
|
|
1306
1324
|
Box::new({
|
|
1307
1325
|
let status = capture_status.clone();
|
|
@@ -1354,6 +1372,10 @@ impl Session {
|
|
|
1354
1372
|
let source_w = source.width.max(1) as usize;
|
|
1355
1373
|
let source_h = source.height.max(1) as usize;
|
|
1356
1374
|
let (width, height) = fit(source_w, source_h, request.max_width, request.max_height);
|
|
1375
|
+
#[cfg(target_os = "linux")]
|
|
1376
|
+
let window = share_watch
|
|
1377
|
+
.filter(|_| source.source_type.as_deref() == Some("Window"))
|
|
1378
|
+
.map(|watch| crate::hyprland::WindowLocator::new(watch, source.width, source.height));
|
|
1357
1379
|
|
|
1358
1380
|
let bitrate_kbps = if request.bitrate_kbps == 0 {
|
|
1359
1381
|
auto_bitrate(width, height)
|
|
@@ -1469,6 +1491,8 @@ impl Session {
|
|
|
1469
1491
|
} else {
|
|
1470
1492
|
PointDesktop::Wayland
|
|
1471
1493
|
},
|
|
1494
|
+
#[cfg(target_os = "linux")]
|
|
1495
|
+
window: Mutex::new(window),
|
|
1472
1496
|
capture: Mutex::new(Some(capture)),
|
|
1473
1497
|
layout: Mutex::new(Some(layout)),
|
|
1474
1498
|
#[cfg(target_os = "linux")]
|
|
@@ -1567,6 +1591,8 @@ impl Session {
|
|
|
1567
1591
|
point_overlay: Mutex::new(None),
|
|
1568
1592
|
#[cfg(target_os = "linux")]
|
|
1569
1593
|
point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
|
|
1594
|
+
#[cfg(target_os = "linux")]
|
|
1595
|
+
window: Mutex::new(None),
|
|
1570
1596
|
capture: Mutex::new(None),
|
|
1571
1597
|
layout: Mutex::new(None),
|
|
1572
1598
|
#[cfg(target_os = "linux")]
|
|
@@ -1736,6 +1762,22 @@ impl Session {
|
|
|
1736
1762
|
"timeout must be 1..120000 ms; label must be at most 96 printable ASCII bytes",
|
|
1737
1763
|
));
|
|
1738
1764
|
}
|
|
1765
|
+
// A located window's point shows where a press there would land, on
|
|
1766
|
+
// the output holding it; the next may land on another output, so each
|
|
1767
|
+
// gets its own helper.
|
|
1768
|
+
#[cfg(target_os = "linux")]
|
|
1769
|
+
let landed = match lock(&self.inner.window).as_ref() {
|
|
1770
|
+
Some(window) => {
|
|
1771
|
+
let (px, py) = window
|
|
1772
|
+
.locate(x, y)
|
|
1773
|
+
.map_err(|message| SessionError::new("indicator-unavailable", message))?;
|
|
1774
|
+
if let Some(helper) = overlay.take() {
|
|
1775
|
+
helper.stop();
|
|
1776
|
+
}
|
|
1777
|
+
Some((px.floor() as i64, py.floor() as i64))
|
|
1778
|
+
}
|
|
1779
|
+
None => None,
|
|
1780
|
+
};
|
|
1739
1781
|
#[cfg(not(target_os = "windows"))]
|
|
1740
1782
|
if overlay.is_none() {
|
|
1741
1783
|
#[cfg(target_os = "linux")]
|
|
@@ -1744,7 +1786,10 @@ impl Session {
|
|
|
1744
1786
|
PointDesktop::Wayland => crate::indicator::Indicator::start_point_wayland(
|
|
1745
1787
|
source.width,
|
|
1746
1788
|
source.height,
|
|
1747
|
-
|
|
1789
|
+
landed
|
|
1790
|
+
.map(|(x, y)| (x as i32, y as i32))
|
|
1791
|
+
.or(source.position),
|
|
1792
|
+
landed.is_some(),
|
|
1748
1793
|
),
|
|
1749
1794
|
PointDesktop::Unavailable(_) => unreachable!("validated point target"),
|
|
1750
1795
|
};
|
|
@@ -1766,6 +1811,8 @@ impl Session {
|
|
|
1766
1811
|
}
|
|
1767
1812
|
})?);
|
|
1768
1813
|
}
|
|
1814
|
+
#[cfg(target_os = "linux")]
|
|
1815
|
+
let (x, y) = landed.unwrap_or((x, y));
|
|
1769
1816
|
#[cfg(not(target_os = "windows"))]
|
|
1770
1817
|
return overlay
|
|
1771
1818
|
.as_mut()
|
|
@@ -2118,6 +2165,8 @@ impl Inner {
|
|
|
2118
2165
|
drop(capture);
|
|
2119
2166
|
#[cfg(target_os = "linux")]
|
|
2120
2167
|
drop(self.audio.lock().ok().and_then(|mut held| held.take()));
|
|
2168
|
+
#[cfg(target_os = "linux")]
|
|
2169
|
+
drop(lock(&self.window).take());
|
|
2121
2170
|
self.notify(SessionEvent::State {
|
|
2122
2171
|
capture: "ended",
|
|
2123
2172
|
transport: String::from("closed"),
|
|
@@ -2219,7 +2268,7 @@ impl Inner {
|
|
|
2219
2268
|
}
|
|
2220
2269
|
*last = seq;
|
|
2221
2270
|
}
|
|
2222
|
-
let feedback = match &message {
|
|
2271
|
+
let mut feedback = match &message {
|
|
2223
2272
|
ControlMessage::Pointer { phase, x, y, .. } if lock(&self.encoded).is_none() => {
|
|
2224
2273
|
let (x, y) = to_source_pixels(*x, *y, self.encoded_size(), &self.source);
|
|
2225
2274
|
Some((
|
|
@@ -2245,17 +2294,26 @@ impl Inner {
|
|
|
2245
2294
|
}
|
|
2246
2295
|
// A shared window's place on the desktop is unknown (the portal reports
|
|
2247
2296
|
// no origin for it), while the pointer device spans the whole desktop:
|
|
2248
|
-
// a tap would land somewhere the person never pointed.
|
|
2297
|
+
// a tap would land somewhere the person never pointed. Only Hyprland
|
|
2298
|
+
// says where the window is.
|
|
2299
|
+
#[cfg(target_os = "linux")]
|
|
2300
|
+
let located = lock(&self.window).is_some();
|
|
2301
|
+
#[cfg(not(target_os = "linux"))]
|
|
2302
|
+
let located = false;
|
|
2249
2303
|
if self.source.source_type.as_deref() == Some("Window")
|
|
2304
|
+
&& !located
|
|
2250
2305
|
&& matches!(&message, ControlMessage::Pointer { phase, .. } if !matches!(phase, PointerPhase::Cancel))
|
|
2251
2306
|
{
|
|
2252
2307
|
self.reject(seq, "input-unavailable", WINDOW_POINTER_REFUSAL);
|
|
2253
2308
|
return;
|
|
2254
2309
|
}
|
|
2310
|
+
// A located window's pointer moves through the compositor; only its
|
|
2311
|
+
// buttons need the input device, which `pointer` checks first.
|
|
2255
2312
|
if !matches!(
|
|
2256
2313
|
&message,
|
|
2257
2314
|
ControlMessage::ClipboardRead { .. } | ControlMessage::ClipboardWrite { .. }
|
|
2258
|
-
) &&
|
|
2315
|
+
) && !(located && matches!(&message, ControlMessage::Pointer { .. }))
|
|
2316
|
+
&& lock(&self.input).is_none()
|
|
2259
2317
|
{
|
|
2260
2318
|
self.reject(seq, "input-unavailable", "no input backend");
|
|
2261
2319
|
return;
|
|
@@ -2267,7 +2325,14 @@ impl Inner {
|
|
|
2267
2325
|
y,
|
|
2268
2326
|
button,
|
|
2269
2327
|
..
|
|
2270
|
-
} => self.pointer(phase, x, y, button, seq)
|
|
2328
|
+
} => self.pointer(phase, x, y, button, seq).map(|landed| {
|
|
2329
|
+
// A located window's cue goes where the press landed, or nowhere.
|
|
2330
|
+
if located {
|
|
2331
|
+
feedback = feedback
|
|
2332
|
+
.zip(landed)
|
|
2333
|
+
.map(|((kind, ..), (x, y))| (kind, x, y));
|
|
2334
|
+
}
|
|
2335
|
+
}),
|
|
2271
2336
|
ControlMessage::Wheel { dx, dy, .. } => self.wheel(dx, dy, seq),
|
|
2272
2337
|
ControlMessage::Key {
|
|
2273
2338
|
name,
|
|
@@ -2368,7 +2433,7 @@ impl Inner {
|
|
|
2368
2433
|
y: i64,
|
|
2369
2434
|
button: i64,
|
|
2370
2435
|
_seq: u64,
|
|
2371
|
-
) -> std::result::Result<(
|
|
2436
|
+
) -> std::result::Result<Option<(i64, i64)>, (&'static str, String)> {
|
|
2372
2437
|
let (width, height) = self.encoded_size();
|
|
2373
2438
|
if !(0..width as i64).contains(&x) || !(0..height as i64).contains(&y) {
|
|
2374
2439
|
return Err((
|
|
@@ -2380,6 +2445,30 @@ impl Inner {
|
|
|
2380
2445
|
// created for, and clamps to, the source's own pixels. One conversion
|
|
2381
2446
|
// here keeps every caller honest.
|
|
2382
2447
|
let (x, y) = to_source_pixels(x, y, (width, height), &self.source);
|
|
2448
|
+
#[cfg(target_os = "linux")]
|
|
2449
|
+
if let Some(window) = lock(&self.window).as_ref() {
|
|
2450
|
+
let place = || {
|
|
2451
|
+
window
|
|
2452
|
+
.place(x, y)
|
|
2453
|
+
.map_err(|message| ("input-unavailable", message))
|
|
2454
|
+
};
|
|
2455
|
+
// The desktop point the pointer landed on, where its cue belongs.
|
|
2456
|
+
return match phase {
|
|
2457
|
+
PointerPhase::Move => place().map(Some),
|
|
2458
|
+
PointerPhase::Down => {
|
|
2459
|
+
// Refused before the pointer moves: nothing could press.
|
|
2460
|
+
self.with_input(|_| Ok(()))?;
|
|
2461
|
+
let point = place()?;
|
|
2462
|
+
self.with_input(|target| target.button(Button::from_number(button), true))?;
|
|
2463
|
+
Ok(Some(point))
|
|
2464
|
+
}
|
|
2465
|
+
PointerPhase::Up => {
|
|
2466
|
+
self.with_input(|target| target.button(Button::from_number(button), false))?;
|
|
2467
|
+
place().map(Some)
|
|
2468
|
+
}
|
|
2469
|
+
PointerPhase::Cancel => self.with_input(InputTarget::release_all).map(|()| None),
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2383
2472
|
self.with_input(|target| match phase {
|
|
2384
2473
|
PointerPhase::Move => target.move_absolute(x, y),
|
|
2385
2474
|
PointerPhase::Down => {
|
|
@@ -2392,6 +2481,7 @@ impl Inner {
|
|
|
2392
2481
|
}
|
|
2393
2482
|
PointerPhase::Cancel => target.release_all(),
|
|
2394
2483
|
})
|
|
2484
|
+
.map(|()| None)
|
|
2395
2485
|
}
|
|
2396
2486
|
|
|
2397
2487
|
fn wheel(
|
|
@@ -3407,7 +3497,10 @@ fn spawn_peer_events(inner: &Arc<Inner>, mut events: tokio_mpsc::UnboundedReceiv
|
|
|
3407
3497
|
// desklink scout trace: the session's transport emit, one
|
|
3408
3498
|
// line after the peer event that caused it.
|
|
3409
3499
|
if crate::peer::dl_trace_on() {
|
|
3410
|
-
crate::peer::dl_eng!(
|
|
3500
|
+
crate::peer::dl_eng!(
|
|
3501
|
+
"session_transport_emit {}",
|
|
3502
|
+
format!("{state}").to_lowercase()
|
|
3503
|
+
);
|
|
3411
3504
|
}
|
|
3412
3505
|
}
|
|
3413
3506
|
PeerEvent::ControlOpen => {
|
|
@@ -3467,7 +3560,7 @@ fn available_parallelism() -> usize {
|
|
|
3467
3560
|
.unwrap_or(4)
|
|
3468
3561
|
}
|
|
3469
3562
|
|
|
3470
|
-
const WINDOW_POINTER_REFUSAL: &str = "a shared window cannot take taps: its place on the desktop is unknown; share the whole screen to point and click";
|
|
3563
|
+
pub(crate) const WINDOW_POINTER_REFUSAL: &str = "a shared window cannot take taps: its place on the desktop is unknown; share the whole screen to point and click";
|
|
3471
3564
|
|
|
3472
3565
|
/// Encoded-surface pixels to the source's own pixels. The client aims at what it
|
|
3473
3566
|
/// sees; the applier is sized and clamped to the source, so the layout origin
|
|
@@ -3583,6 +3676,8 @@ mod tests {
|
|
|
3583
3676
|
point_overlay: Mutex::new(None),
|
|
3584
3677
|
#[cfg(target_os = "linux")]
|
|
3585
3678
|
point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
|
|
3679
|
+
#[cfg(target_os = "linux")]
|
|
3680
|
+
window: Mutex::new(None),
|
|
3586
3681
|
capture: Mutex::new(None),
|
|
3587
3682
|
layout: Mutex::new(None),
|
|
3588
3683
|
#[cfg(target_os = "linux")]
|
|
@@ -3842,6 +3937,8 @@ mod tests {
|
|
|
3842
3937
|
point_overlay: Mutex::new(None),
|
|
3843
3938
|
#[cfg(target_os = "linux")]
|
|
3844
3939
|
point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
|
|
3940
|
+
#[cfg(target_os = "linux")]
|
|
3941
|
+
window: Mutex::new(None),
|
|
3845
3942
|
layout: Mutex::new(Some(Layout::from_environment().expect("a keymap"))),
|
|
3846
3943
|
#[cfg(target_os = "linux")]
|
|
3847
3944
|
audio: Mutex::new(None),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desklink/host",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"ws": "8.21.3"
|
|
39
39
|
},
|
|
40
40
|
"optionalDependencies": {
|
|
41
|
-
"@desklink/host-linux-x64-gnu": "0.5.
|
|
42
|
-
"@desklink/host-darwin-arm64": "0.5.
|
|
41
|
+
"@desklink/host-linux-x64-gnu": "0.5.8",
|
|
42
|
+
"@desklink/host-darwin-arm64": "0.5.8"
|
|
43
43
|
}
|
|
44
44
|
}
|