@desklink/host 0.5.7 → 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 CHANGED
@@ -272,9 +272,12 @@ animating in and out, with light and dark keylines so it reads on any content.
272
272
  On Wayland it uses a click-through layer-shell surface with an edge glow, drawn
273
273
  at the highest announced output integer scale, capped at 4; its bounded area is
274
274
  masked before capture/diff (pixels behind the effect can remain briefly stale).
275
- On a located Hyprland window share the cue gets no pointer or click feedback —
276
- the engine places the pointer itself, so a stream fraction is not the window's
277
- desktop point (see Input and clipboard); keys still show. On bare X11 it is an override-redirect, click-through window that follows the
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
278
281
  pointer, so it stays
279
282
  visible over windows that set their own cursor and never replaces the cursor.
280
283
  Its size follows `Xft.dpi` from 96–384 DPI, defaulting to 1x otherwise;
@@ -382,6 +385,12 @@ lease, or guarantee recovery within 2 seconds. Engine-side ends remain terminal.
382
385
  // "description":{"type":"offer","sdp":"v=0\r\n…"}}}
383
386
  ```
384
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
+
385
394
  With `loopback_tcp`, the engine also listens for ICE over TCP on
386
395
  `127.0.0.1` and offers that passive candidate. It is for a client whose only
387
396
  way to this computer is a forward of its loopback, such as an SSH tunnel: the
@@ -600,7 +609,11 @@ label. A new point replaces the previous one and restarts its timer.
600
609
  Coordinates are integers in the **selected desktop's original pixels**, top-left
601
610
  origin, before video scaling (`geometry.source`, not `geometry.encoded`). On X11
602
611
  this is the root screen; on macOS it is the selected display, with native pixels
603
- converted to display points. Both coordinates must be within its bounds. The
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
604
617
  ring may clip at an edge; its label stays inside the display. `timeout_ms`
605
618
  defaults to 3000 and must be an integer from 1 to 120000. Labels are at most 96
606
619
  printable ASCII bytes, including spaces; other labels are refused rather than
package/engine/Cargo.lock CHANGED
@@ -536,7 +536,7 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
536
536
 
537
537
  [[package]]
538
538
  name = "desklink-host"
539
- version = "0.5.7"
539
+ version = "0.5.8"
540
540
  dependencies = [
541
541
  "anyhow",
542
542
  "ashpd",
package/engine/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "desklink-host"
3
- version = "0.5.7"
3
+ version = "0.5.8"
4
4
  edition = "2021"
5
5
  rust-version = "1.97.1"
6
6
  license = "Apache-2.0"
@@ -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
- if(o.output_manager) {
230
- for(int i=0;i<o.output_count;i++) {
231
- struct Output *out=&o.outputs[i];
232
- out->logical=wl_proxy_marshal_flags(o.output_manager,1,&xdg_output_interface,wl_proxy_get_version(o.output_manager),0,NULL,out->handle);
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
- desklink_wayland_geometry(o.source_w,o.source_h,o.width,o.height,px,py,&x,&y);
293
- snprintf(label,sizeof(label),"%s",pending+offset);deadline=now()+timeout/1000.0;active=1;dirty=1;visible=1;
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
- if(desklink_wayland_geometry(o.source_w,o.source_h,o.width,o.height,px,py,&tx,&ty)
300
- && !positioned){x=tx;y=ty;positioned=1;cue_started=now();}
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) {
@@ -84,8 +84,18 @@ impl WindowLocator {
84
84
  }
85
85
  }
86
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> {
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> {
89
99
  let (clients, monitors) = (query("j/clients")?, query("j/monitors")?);
90
100
  let clients = clients.as_array().map(Vec::as_slice).unwrap_or_default();
91
101
  // A window nobody sees has no point on the screen.
@@ -222,7 +232,7 @@ impl WindowLocator {
222
232
  "a shared window cannot take taps where an overlay covers it",
223
233
  ));
224
234
  }
225
- self.warp((px.floor() as i64, py.floor() as i64))
235
+ Ok((px, py))
226
236
  }
227
237
 
228
238
  /// The address of the one window Hyprland said it is sharing.
@@ -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
  )
@@ -136,9 +136,16 @@ use tokio::sync::mpsc as tokio_mpsc;
136
136
  unsafe extern "C" {
137
137
  fn desklink_point_overlay_main(display: *const std::ffi::c_char) -> i32;
138
138
  fn desklink_agent_overlay_main(display: *const std::ffi::c_char) -> i32;
139
- 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;
140
140
  fn desklink_wayland_layer_shell_available() -> i32;
141
- fn desklink_point_overlay_wayland(w: i32, h: i32, known: i32, x: i32, y: i32) -> i32;
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;
142
149
  }
143
150
 
144
151
  #[cfg(target_os = "linux")]
@@ -183,12 +190,17 @@ fn main() {
183
190
  },
184
191
  "point-overlay-wayland" => {
185
192
  let values: Vec<i32> = args.iter().skip(1).filter_map(|v| v.parse().ok()).collect();
186
- if values.len() != 5 || values[0] <= 0 || values[1] <= 0 {
193
+ if !(5..=6).contains(&values.len()) || values[0] <= 0 || values[1] <= 0 {
187
194
  2
188
195
  } else {
189
196
  unsafe {
190
197
  desklink_point_overlay_wayland(
191
- values[0], values[1], values[2], values[3], values[4],
198
+ values[0],
199
+ values[1],
200
+ values[2],
201
+ values[3],
202
+ values[4],
203
+ values.get(5).copied().unwrap_or(0),
192
204
  )
193
205
  }
194
206
  }
@@ -199,7 +211,8 @@ fn main() {
199
211
  if width <= 0 || height <= 0 {
200
212
  2
201
213
  } else {
202
- unsafe { desklink_agent_overlay_wayland(width, height) }
214
+ let desktop = i32::from(args.get(3).map(String::as_str) == Some("desktop"));
215
+ unsafe { desklink_agent_overlay_wayland(width, height, desktop) }
203
216
  }
204
217
  }
205
218
  "capabilities" => {
@@ -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(vec![std::net::SocketAddr::from(([0, 0, 0, 0], 0))])
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(vec![std::net::SocketAddr::from(([0, 0, 0, 0], 0))])
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(receiver: Arc<MediaReceiver>) -> Arc<dyn PeerConnection> {
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(vec![std::net::SocketAddr::from(([0, 0, 0, 0], 0))])
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
- /// A restart offer/answer round must not interrupt media already flowing on
1690
- /// the selected pair: the picture keeps advancing (no gap over 200 ms) while
1691
- /// the old pair stays reachable, and the ICE generation still turns over.
1692
- #[tokio::test]
1693
- async fn a_restart_keeps_media_flowing_on_the_selected_pair_until_the_new_one_nominates() {
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!(Instant::now() < deadline, "the peers never connected");
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 {
@@ -1267,9 +1267,16 @@ impl Session {
1267
1267
  let source_h = source.height.max(1) as usize;
1268
1268
  let (width, height) =
1269
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");
1270
1275
  #[cfg(target_os = "linux")]
1271
1276
  if request.agent_indicator {
1272
- indicator = crate::indicator::Indicator::start_wayland(source_w, source_h).ok();
1277
+ indicator =
1278
+ crate::indicator::Indicator::start_wayland(source_w, source_h, desktop)
1279
+ .ok();
1273
1280
  }
1274
1281
  #[cfg(target_os = "macos")]
1275
1282
  if request.agent_indicator {
@@ -1312,6 +1319,7 @@ impl Session {
1312
1319
  sink,
1313
1320
  indicator
1314
1321
  .as_ref()
1322
+ .filter(|_| !desktop)
1315
1323
  .map(crate::indicator::Indicator::position),
1316
1324
  Box::new({
1317
1325
  let status = capture_status.clone();
@@ -1754,6 +1762,22 @@ impl Session {
1754
1762
  "timeout must be 1..120000 ms; label must be at most 96 printable ASCII bytes",
1755
1763
  ));
1756
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
+ };
1757
1781
  #[cfg(not(target_os = "windows"))]
1758
1782
  if overlay.is_none() {
1759
1783
  #[cfg(target_os = "linux")]
@@ -1762,7 +1786,10 @@ impl Session {
1762
1786
  PointDesktop::Wayland => crate::indicator::Indicator::start_point_wayland(
1763
1787
  source.width,
1764
1788
  source.height,
1765
- source.position,
1789
+ landed
1790
+ .map(|(x, y)| (x as i32, y as i32))
1791
+ .or(source.position),
1792
+ landed.is_some(),
1766
1793
  ),
1767
1794
  PointDesktop::Unavailable(_) => unreachable!("validated point target"),
1768
1795
  };
@@ -1784,6 +1811,8 @@ impl Session {
1784
1811
  }
1785
1812
  })?);
1786
1813
  }
1814
+ #[cfg(target_os = "linux")]
1815
+ let (x, y) = landed.unwrap_or((x, y));
1787
1816
  #[cfg(not(target_os = "windows"))]
1788
1817
  return overlay
1789
1818
  .as_mut()
@@ -2239,7 +2268,7 @@ impl Inner {
2239
2268
  }
2240
2269
  *last = seq;
2241
2270
  }
2242
- let feedback = match &message {
2271
+ let mut feedback = match &message {
2243
2272
  ControlMessage::Pointer { phase, x, y, .. } if lock(&self.encoded).is_none() => {
2244
2273
  let (x, y) = to_source_pixels(*x, *y, self.encoded_size(), &self.source);
2245
2274
  Some((
@@ -2271,11 +2300,6 @@ impl Inner {
2271
2300
  let located = lock(&self.window).is_some();
2272
2301
  #[cfg(not(target_os = "linux"))]
2273
2302
  let located = false;
2274
- let feedback = if located {
2275
- feedback.filter(|(kind, ..)| *kind == 'T')
2276
- } else {
2277
- feedback
2278
- };
2279
2303
  if self.source.source_type.as_deref() == Some("Window")
2280
2304
  && !located
2281
2305
  && matches!(&message, ControlMessage::Pointer { phase, .. } if !matches!(phase, PointerPhase::Cancel))
@@ -2301,7 +2325,14 @@ impl Inner {
2301
2325
  y,
2302
2326
  button,
2303
2327
  ..
2304
- } => 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
+ }),
2305
2336
  ControlMessage::Wheel { dx, dy, .. } => self.wheel(dx, dy, seq),
2306
2337
  ControlMessage::Key {
2307
2338
  name,
@@ -2402,7 +2433,7 @@ impl Inner {
2402
2433
  y: i64,
2403
2434
  button: i64,
2404
2435
  _seq: u64,
2405
- ) -> std::result::Result<(), (&'static str, String)> {
2436
+ ) -> std::result::Result<Option<(i64, i64)>, (&'static str, String)> {
2406
2437
  let (width, height) = self.encoded_size();
2407
2438
  if !(0..width as i64).contains(&x) || !(0..height as i64).contains(&y) {
2408
2439
  return Err((
@@ -2421,19 +2452,21 @@ impl Inner {
2421
2452
  .place(x, y)
2422
2453
  .map_err(|message| ("input-unavailable", message))
2423
2454
  };
2455
+ // The desktop point the pointer landed on, where its cue belongs.
2424
2456
  return match phase {
2425
- PointerPhase::Move => place(),
2457
+ PointerPhase::Move => place().map(Some),
2426
2458
  PointerPhase::Down => {
2427
2459
  // Refused before the pointer moves: nothing could press.
2428
2460
  self.with_input(|_| Ok(()))?;
2429
- place()?;
2430
- self.with_input(|target| target.button(Button::from_number(button), true))
2461
+ let point = place()?;
2462
+ self.with_input(|target| target.button(Button::from_number(button), true))?;
2463
+ Ok(Some(point))
2431
2464
  }
2432
2465
  PointerPhase::Up => {
2433
2466
  self.with_input(|target| target.button(Button::from_number(button), false))?;
2434
- place()
2467
+ place().map(Some)
2435
2468
  }
2436
- PointerPhase::Cancel => self.with_input(InputTarget::release_all),
2469
+ PointerPhase::Cancel => self.with_input(InputTarget::release_all).map(|()| None),
2437
2470
  };
2438
2471
  }
2439
2472
  self.with_input(|target| match phase {
@@ -2448,6 +2481,7 @@ impl Inner {
2448
2481
  }
2449
2482
  PointerPhase::Cancel => target.release_all(),
2450
2483
  })
2484
+ .map(|()| None)
2451
2485
  }
2452
2486
 
2453
2487
  fn wheel(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/host",
3
- "version": "0.5.7",
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.7",
42
- "@desklink/host-darwin-arm64": "0.5.7"
41
+ "@desklink/host-linux-x64-gnu": "0.5.8",
42
+ "@desklink/host-darwin-arm64": "0.5.8"
43
43
  }
44
44
  }