@desklink/host 0.5.8 → 0.5.10

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/engine/src/mac.rs CHANGED
@@ -39,6 +39,8 @@ unsafe extern "C" {
39
39
  pub(crate) fn CGPreflightScreenCaptureAccess() -> bool;
40
40
  pub(crate) fn CGPreflightPostEventAccess() -> bool;
41
41
  pub(crate) fn CGRequestPostEventAccess() -> bool;
42
+ pub(crate) fn CGPreflightListenEventAccess() -> bool;
43
+ pub(crate) fn CGRequestListenEventAccess() -> bool;
42
44
  fn CGGetActiveDisplayList(max: u32, displays: *mut DisplayId, count: *mut u32) -> i32;
43
45
  fn CGMainDisplayID() -> DisplayId;
44
46
  fn CGDisplayPixelsWide(display: DisplayId) -> usize;
@@ -713,6 +715,12 @@ pub fn run() -> i32 {
713
715
  let _ = crate::input::request_access();
714
716
  println!("If the Accessibility alert did not open, allow {} in System Settings › Privacy & Security › Accessibility.", tcc_responsible_app_name());
715
717
  }
718
+ if crate::input::observe_probe().is_ok() {
719
+ println!("Input Monitoring permission is already granted to {}.", tcc_responsible_app_name());
720
+ } else {
721
+ let _ = crate::input::request_observe_access();
722
+ println!("If the Input Monitoring alert did not open, allow {} in System Settings › Privacy & Security › Input Monitoring.", tcc_responsible_app_name());
723
+ }
716
724
  }
717
725
  _ => println!("desklink-host {}\nUSAGE: desklink-host [serve|capabilities|capture-probe|encode-probe|setup-input|version]", env!("CARGO_PKG_VERSION")),
718
726
  }
@@ -26,7 +26,7 @@ mod clipboard;
26
26
  mod clipboard;
27
27
  #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
28
28
  mod convert;
29
- #[cfg(target_os = "linux")]
29
+ #[cfg(any(target_os = "linux", all(target_os = "macos", desklink_vpx)))]
30
30
  mod cursor;
31
31
  #[cfg(target_os = "linux")]
32
32
  mod encoder;
@@ -72,6 +72,8 @@ mod keymap;
72
72
  mod keymap;
73
73
  #[cfg(target_os = "macos")]
74
74
  mod mac;
75
+ #[cfg(any(target_os = "linux", all(any(target_os = "macos", target_os = "windows"), desklink_vpx)))]
76
+ mod observe;
75
77
  #[cfg(any(
76
78
  target_os = "linux",
77
79
  all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
@@ -136,7 +138,14 @@ use tokio::sync::mpsc as tokio_mpsc;
136
138
  unsafe extern "C" {
137
139
  fn desklink_point_overlay_main(display: *const std::ffi::c_char) -> i32;
138
140
  fn desklink_agent_overlay_main(display: *const std::ffi::c_char) -> i32;
139
- fn desklink_agent_overlay_wayland(source_w: i32, source_h: i32, desktop: i32) -> i32;
141
+ fn desklink_agent_overlay_wayland(
142
+ source_w: i32,
143
+ source_h: i32,
144
+ desktop: i32,
145
+ origin_known: i32,
146
+ origin_x: i32,
147
+ origin_y: i32,
148
+ ) -> i32;
140
149
  fn desklink_wayland_layer_shell_available() -> i32;
141
150
  fn desklink_point_overlay_wayland(
142
151
  w: i32,
@@ -212,7 +221,21 @@ fn main() {
212
221
  2
213
222
  } else {
214
223
  let desktop = i32::from(args.get(3).map(String::as_str) == Some("desktop"));
215
- unsafe { desklink_agent_overlay_wayland(width, height, desktop) }
224
+ // A desktop cue respawned after a press names the layout
225
+ // point that chose its output: `w h desktop ox oy`.
226
+ let (known, ox, oy) = match (args.get(4), args.get(5)) {
227
+ (Some(x), Some(y)) => match (x.parse(), y.parse()) {
228
+ (Ok(x), Ok(y)) => (1, x, y),
229
+ _ => (0, 0, 0),
230
+ },
231
+ _ => (0, 0, 0),
232
+ };
233
+ let (known, ox, oy) = if desktop == 1 {
234
+ (known, ox, oy)
235
+ } else {
236
+ (0, 0, 0)
237
+ };
238
+ unsafe { desklink_agent_overlay_wayland(width, height, desktop, known, ox, oy) }
216
239
  }
217
240
  }
218
241
  "capabilities" => {
@@ -542,6 +565,47 @@ async fn serve() -> Result<()> {
542
565
  Ok(())
543
566
  }
544
567
 
568
+ /// Splits the requested ICE servers into the configuration the peer gets and
569
+ /// the URLs this engine leaves out. Every URL the WebRTC stack can dial goes
570
+ /// through with its entry's username and credential — all of an entry's URLs,
571
+ /// so a fallback behind an unreachable primary still gathers candidates. A
572
+ /// TURN URL whose transport is not UDP is one the stack cannot dial, so it is
573
+ /// skipped here and reported once by the caller, never dropped silently. A
574
+ /// URL the parser cannot read is forwarded unchanged: the peer connection
575
+ /// owns that verdict and refuses the open in its own words.
576
+ #[cfg(any(
577
+ target_os = "linux",
578
+ all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
579
+ ))]
580
+ fn usable_ice_servers(
581
+ servers: Vec<protocol::IceServerParam>,
582
+ ) -> (
583
+ Vec<(Vec<String>, Option<String>, Option<String>)>,
584
+ Vec<String>,
585
+ ) {
586
+ use rtc_ice::url::{ProtoType, SchemeType, Url};
587
+ let mut usable = Vec::new();
588
+ let mut skipped = Vec::new();
589
+ for server in servers {
590
+ let mut urls = Vec::with_capacity(server.urls.len());
591
+ for url in server.urls {
592
+ let undialable = Url::parse_url(&url).is_ok_and(|parsed| {
593
+ matches!(parsed.scheme, SchemeType::Turn | SchemeType::Turns)
594
+ && parsed.proto != ProtoType::Udp
595
+ });
596
+ if undialable {
597
+ skipped.push(url);
598
+ } else {
599
+ urls.push(url);
600
+ }
601
+ }
602
+ if !urls.is_empty() {
603
+ usable.push((urls, server.username, server.credential));
604
+ }
605
+ }
606
+ (usable, skipped)
607
+ }
608
+
545
609
  #[cfg(any(
546
610
  target_os = "linux",
547
611
  all(any(target_os = "macos", target_os = "windows"), desklink_vpx)
@@ -604,6 +668,13 @@ fn render_event(notice: session::Notice) -> Option<String> {
604
668
  "session.input",
605
669
  serde_json::json!({ "sessionId": session_id, "input": input }),
606
670
  ),
671
+ session::SessionEvent::Observed {
672
+ timestamp_us,
673
+ input,
674
+ } => (
675
+ "session.input.observed",
676
+ serde_json::json!({ "sessionId": session_id, "timestamp_us": timestamp_us, "input": input }),
677
+ ),
607
678
  session::SessionEvent::KeyframeRequest { generation } => (
608
679
  "session.keyframeRequest",
609
680
  serde_json::json!({ "sessionId": session_id, "generation": generation }),
@@ -616,6 +687,10 @@ fn render_event(notice: session::Notice) -> Option<String> {
616
687
  "session.capture.stopped",
617
688
  serde_json::json!({ "sessionId": session_id, "reason": reason }),
618
689
  ),
690
+ session::SessionEvent::IceServerSkipped { url } => (
691
+ "session.iceServerSkipped",
692
+ serde_json::json!({ "sessionId": session_id, "url": url }),
693
+ ),
619
694
  session::SessionEvent::Revoked { reason, code } => (
620
695
  "session.revoked",
621
696
  serde_json::json!({ "sessionId": session_id, "reason": reason, "code": code }),
@@ -672,6 +747,7 @@ async fn dispatch(
672
747
  }
673
748
  let params: protocol::OpenParams = serde_json::from_value(request.params.clone())
674
749
  .map_err(|error| ErrorBody::new("malformed", error.to_string()))?;
750
+ let usable = usable_ice_servers(params.ice_servers);
675
751
  let open = session::OpenRequest {
676
752
  source: params.source,
677
753
  cursor: params.cursor,
@@ -685,16 +761,8 @@ async fn dispatch(
685
761
  agent_indicator: params.agent_indicator,
686
762
  local_frames: params.local_frames,
687
763
  audio: params.audio,
688
- ice_servers: params
689
- .ice_servers
690
- .into_iter()
691
- .filter_map(|server| {
692
- server
693
- .urls
694
- .first()
695
- .map(|url| (url.clone(), server.username, server.credential))
696
- })
697
- .collect(),
764
+ observe_input: params.observe_input,
765
+ ice_servers: usable.0,
698
766
  };
699
767
  let session = session::Session::open(open, events.clone())
700
768
  .await
@@ -712,7 +780,17 @@ async fn dispatch(
712
780
  "cursor": session.cursor_info(),
713
781
  "agentIndicator": session.agent_indicator(),
714
782
  "audio": session.audio_info(),
783
+ "inputObservation": session.observation_info(),
715
784
  });
785
+ // URLs the WebRTC stack cannot dial were left out of the peer's
786
+ // configuration; each skipped one is named once instead of
787
+ // vanishing from a multi-URL entry without a trace.
788
+ for url in usable.1 {
789
+ let _ = events.send(session::Notice {
790
+ session_id: session.id().to_owned(),
791
+ event: session::SessionEvent::IceServerSkipped { url },
792
+ });
793
+ }
716
794
  *current = Some(session);
717
795
  Ok(result)
718
796
  }
@@ -947,6 +1025,26 @@ mod tests {
947
1025
  );
948
1026
  }
949
1027
 
1028
+ #[test]
1029
+ fn the_skipped_ice_server_warning_names_the_url_once() {
1030
+ let line = render_event(session::Notice {
1031
+ session_id: String::from("session-1"),
1032
+ event: session::SessionEvent::IceServerSkipped {
1033
+ url: String::from("turn:turn.example.com:3478?transport=tcp"),
1034
+ },
1035
+ })
1036
+ .expect("a skipped-URL warning is renderable");
1037
+ let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
1038
+ assert_eq!(parsed["event"], "session.iceServerSkipped");
1039
+ assert_eq!(
1040
+ parsed["params"],
1041
+ serde_json::json!({
1042
+ "sessionId": "session-1",
1043
+ "url": "turn:turn.example.com:3478?transport=tcp"
1044
+ })
1045
+ );
1046
+ }
1047
+
950
1048
  #[tokio::test]
951
1049
  async fn the_state_notification_carries_exactly_what_the_document_promises() {
952
1050
  let line = render_event(session::Notice {
@@ -0,0 +1,94 @@
1
+ //! Passive observation of the desktop's own pointer buttons, cursor and keys.
2
+ //!
3
+ //! The engine only listens; events are the ones `docs/PROTOCOL.md` describes. A
4
+ //! platform that cannot observe says why with a typed reason and delivers nothing.
5
+
6
+ use crate::protocol::{CursorPosition, ObservedInput};
7
+ use std::sync::Arc;
8
+
9
+ #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
10
+ pub enum Observation {
11
+ Input {
12
+ timestamp_us: u64,
13
+ input: ObservedInput,
14
+ },
15
+ Cursor(CursorPosition),
16
+ }
17
+
18
+ pub type Sink = Arc<dyn Fn(Observation) + Send + Sync + 'static>;
19
+
20
+ /// Keeps an observer running; dropping it stops it.
21
+ #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
22
+ pub struct Tap {
23
+ pub mechanism: &'static str,
24
+ pub guard: Box<dyn Send>,
25
+ }
26
+
27
+ /// Why the desktop's input cannot be observed, and what the owner can do.
28
+ #[derive(Debug, Clone, PartialEq, Eq)]
29
+ pub struct Unavailable {
30
+ pub code: &'static str,
31
+ pub reason: String,
32
+ pub remedy: String,
33
+ }
34
+
35
+ #[cfg(target_os = "macos")]
36
+ pub fn start(display_id: u32, pixels: (usize, usize), sink: Sink) -> Result<Tap, Unavailable> {
37
+ crate::input::observe(display_id, pixels, sink)
38
+ }
39
+
40
+ #[cfg(not(target_os = "macos"))]
41
+ pub fn start(_: u32, _: (usize, usize), _: Sink) -> Result<Tap, Unavailable> {
42
+ Err(Unavailable {
43
+ code: "unsupported-platform",
44
+ reason: String::from("observing the desktop's own input is only implemented on macOS"),
45
+ remedy: String::from("none: this platform delivers no input events"),
46
+ })
47
+ }
48
+
49
+ /// The `inputObservation` member of the `session.open` reply.
50
+ pub fn status(result: Option<&Result<Tap, Unavailable>>) -> serde_json::Value {
51
+ match result {
52
+ None => serde_json::json!({"observing": false}),
53
+ Some(Ok(tap)) => serde_json::json!({"observing": true, "mechanism": tap.mechanism}),
54
+ Some(Err(error)) => serde_json::json!({
55
+ "observing": false,
56
+ "code": error.code,
57
+ "reason": error.reason,
58
+ "remedy": error.remedy,
59
+ }),
60
+ }
61
+ }
62
+
63
+ #[cfg(test)]
64
+ mod tests {
65
+ use super::*;
66
+
67
+ #[test]
68
+ #[cfg(not(target_os = "macos"))]
69
+ fn a_platform_that_cannot_observe_says_so_and_delivers_nothing() {
70
+ let delivered = Arc::new(std::sync::atomic::AtomicUsize::new(0));
71
+ let counted = delivered.clone();
72
+ let sink: Sink = Arc::new(move |_| {
73
+ counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
74
+ });
75
+ let refused = start(0, (1920, 1080), sink);
76
+ let status = status(Some(&refused));
77
+ assert_eq!(status["observing"], false);
78
+ assert_eq!(status["code"], "unsupported-platform");
79
+ assert_eq!(delivered.load(std::sync::atomic::Ordering::SeqCst), 0);
80
+ }
81
+
82
+ #[test]
83
+ fn a_refusal_carries_its_code_reason_and_remedy() {
84
+ let refused: Result<Tap, Unavailable> = Err(Unavailable {
85
+ code: "missing-input-monitoring",
86
+ reason: String::from("reason"),
87
+ remedy: String::from("remedy"),
88
+ });
89
+ assert_eq!(
90
+ status(Some(&refused)),
91
+ serde_json::json!({"observing": false, "code": "missing-input-monitoring", "reason": "reason", "remedy": "remedy"})
92
+ );
93
+ }
94
+ }
@@ -23,7 +23,7 @@ use rtc::peer_connection::configuration::media_engine::{
23
23
  use rtc::peer_connection::configuration::RTCConfigurationBuilder;
24
24
  use rtc::peer_connection::event::RTCPeerConnectionIceEvent;
25
25
  use rtc::peer_connection::sdp::RTCSessionDescription;
26
- use rtc::peer_connection::transport::{RTCIceCandidateInit, RTCIceServer};
26
+ use rtc::peer_connection::transport::{RTCIceCandidateInit, RTCIceProtocol, RTCIceServer};
27
27
  use rtc::rtcp::payload_feedbacks::full_intra_request::FullIntraRequest;
28
28
  use rtc::rtcp::payload_feedbacks::picture_loss_indication::PictureLossIndication;
29
29
  use rtc::rtcp::receiver_report::ReceiverReport;
@@ -47,7 +47,7 @@ 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.
50
+ // desklink scout trace: engine-side restart timeline.
51
51
  // Enabled with `DESKLINK_ICE_TRACE=1`; stamps microseconds since the Unix epoch
52
52
  // so these lines interleave with the vendored ICE agent's `[ice ...]` lines in
53
53
  // one order. Droppable: grep `desklink scout trace`.
@@ -258,7 +258,10 @@ pub enum PeerEvent {
258
258
  /// Transport settings the consumer chose for this session.
259
259
  #[derive(Debug, Clone)]
260
260
  pub struct TransportOptions {
261
- pub ice_servers: Vec<(String, Option<String>, Option<String>)>,
261
+ /// One entry per ICE server the consumer asked for, carrying every URL
262
+ /// that entry listed; the engine has already left out the ones the
263
+ /// WebRTC stack cannot dial and reported those to the consumer.
264
+ pub ice_servers: Vec<(Vec<String>, Option<String>, Option<String>)>,
262
265
  /// Listen for ICE over TCP on the loopback too. A phone that reaches this
263
266
  /// computer only through SSH has no UDP path; it forwards this port over
264
267
  /// the SSH connection it already has, and the picture rides that.
@@ -474,7 +477,7 @@ impl PeerConnectionEventHandler for Handler {
474
477
  }
475
478
 
476
479
  async fn on_connection_state_change(&self, state: RTCPeerConnectionState) {
477
- // desklink scout trace (dl-roam-postnom-attr): the engine's rtc-level
480
+ // desklink scout trace: the engine's rtc-level
478
481
  // transport state event, on the same microsecond clock as the vendored
479
482
  // agent's `[ice ...]` lines, so the rtc Connected gate is separable.
480
483
  if dl_trace_on() {
@@ -898,8 +901,8 @@ impl VideoPeer {
898
901
  options
899
902
  .ice_servers
900
903
  .iter()
901
- .map(|(url, username, credential)| RTCIceServer {
902
- urls: vec![url.clone()],
904
+ .map(|(urls, username, credential)| RTCIceServer {
905
+ urls: urls.clone(),
903
906
  username: username.clone().unwrap_or_default(),
904
907
  credential: credential.clone().unwrap_or_default(),
905
908
  })
@@ -1130,6 +1133,21 @@ impl VideoPeer {
1130
1133
  self.connected.load(Ordering::SeqCst)
1131
1134
  }
1132
1135
 
1136
+ /// Whether ICE selected a TCP candidate pair (the relay's ICE-TCP path).
1137
+ /// False until a pair is selected.
1138
+ pub async fn selected_pair_is_tcp(&self) -> bool {
1139
+ let Some(sender) = self.peer.get_senders().await.into_iter().next() else {
1140
+ return false;
1141
+ };
1142
+ let Ok(Some(dtls)) = sender.transport().await else {
1143
+ return false;
1144
+ };
1145
+ matches!(
1146
+ dtls.ice_transport().get_selected_candidate_pair().await,
1147
+ Ok(Some(pair)) if pair.local().protocol == RTCIceProtocol::Tcp
1148
+ )
1149
+ }
1150
+
1133
1151
  /// Stand in for a connected transport, for a pipeline under test.
1134
1152
  #[cfg(test)]
1135
1153
  pub fn assume_connected(&self) {
@@ -1172,6 +1190,12 @@ impl VideoPeer {
1172
1190
  lock(&self.path).take()
1173
1191
  }
1174
1192
 
1193
+ /// Stand in for transport-wide feedback that reports a path, for a pipeline under test.
1194
+ #[cfg(test)]
1195
+ pub fn report_path(&self, report: PathReport) {
1196
+ *lock(&self.path) = Some(report);
1197
+ }
1198
+
1175
1199
  /// Take the pending "the far end needs a reference frame" request, if any.
1176
1200
  pub fn take_keyframe_request(&self) -> bool {
1177
1201
  self.wants_keyframe.swap(false, Ordering::SeqCst)
@@ -1697,6 +1721,7 @@ pub(crate) mod tests {
1697
1721
  async fn media_receiver(
1698
1722
  receiver: Arc<MediaReceiver>,
1699
1723
  udp: Vec<std::net::SocketAddr>,
1724
+ tcp: Vec<std::net::SocketAddr>,
1700
1725
  ) -> Arc<dyn PeerConnection> {
1701
1726
  let mut media_engine = MediaEngine::default();
1702
1727
  media_engine
@@ -1710,6 +1735,7 @@ pub(crate) mod tests {
1710
1735
  .with_runtime(default_runtime().expect("a runtime"))
1711
1736
  .with_handler(receiver)
1712
1737
  .with_udp_addrs(udp)
1738
+ .with_tcp_addrs(tcp)
1713
1739
  .build()
1714
1740
  .await
1715
1741
  .expect("a receiving peer");
@@ -1719,17 +1745,27 @@ pub(crate) mod tests {
1719
1745
  /// An engine and a client receiving on `client_udp`, offered, answered and
1720
1746
  /// connected over ICE, with the client's RTP arrival times and the
1721
1747
  /// candidates the engine offered.
1722
- struct Connected {
1723
- engine: Arc<VideoPeer>,
1748
+ pub(crate) struct Connected {
1749
+ pub(crate) engine: Arc<VideoPeer>,
1724
1750
  offer: String,
1725
- client: Arc<dyn PeerConnection>,
1751
+ pub(crate) client: Arc<dyn PeerConnection>,
1726
1752
  arrivals: Arc<Mutex<Vec<Instant>>>,
1727
1753
  offered: Arc<Mutex<Vec<String>>>,
1728
1754
  }
1729
1755
 
1730
- async fn connected(client_udp: Vec<std::net::SocketAddr>) -> Connected {
1756
+ /// `client_tcp` and `loopback_tcp` give a pair that runs over ICE-TCP: a
1757
+ /// client with no UDP candidates can only reach the engine over TCP.
1758
+ pub(crate) async fn connected(
1759
+ client_udp: Vec<std::net::SocketAddr>,
1760
+ client_tcp: Vec<std::net::SocketAddr>,
1761
+ loopback_tcp: bool,
1762
+ ) -> Connected {
1731
1763
  let (events, mut events_rx) = tokio::sync::mpsc::unbounded_channel();
1732
- let (engine, offer) = VideoPeer::offer(transport(), events, VideoCodec::Vp9)
1764
+ let options = TransportOptions {
1765
+ loopback_tcp,
1766
+ ..transport()
1767
+ };
1768
+ let (engine, offer) = VideoPeer::offer(options, events, VideoCodec::Vp9)
1733
1769
  .await
1734
1770
  .expect("an offering peer");
1735
1771
  let engine = Arc::new(engine);
@@ -1743,7 +1779,7 @@ pub(crate) mod tests {
1743
1779
  arrivals: Arc::clone(&arrivals),
1744
1780
  connected: Arc::clone(&connected),
1745
1781
  });
1746
- let client = media_receiver(Arc::clone(&receiver), client_udp).await;
1782
+ let client = media_receiver(Arc::clone(&receiver), client_udp, client_tcp).await;
1747
1783
 
1748
1784
  client
1749
1785
  .set_remote_description(RTCSessionDescription::offer(offer.clone()).expect("an offer"))
@@ -1824,7 +1860,12 @@ pub(crate) mod tests {
1824
1860
  client,
1825
1861
  arrivals,
1826
1862
  offered,
1827
- } = connected(vec![std::net::SocketAddr::from(([0, 0, 0, 0], 0))]).await;
1863
+ } = connected(
1864
+ vec![std::net::SocketAddr::from(([0, 0, 0, 0], 0))],
1865
+ Vec::new(),
1866
+ false,
1867
+ )
1868
+ .await;
1828
1869
  eprintln!("restart: the engine offered {:?}", offered.lock().unwrap());
1829
1870
 
1830
1871
  // Any bytes the VP9 packetizer turns into one access unit.
@@ -1968,10 +2009,14 @@ pub(crate) mod tests {
1968
2009
  arrivals,
1969
2010
  offered,
1970
2011
  ..
1971
- } = connected(vec![std::net::SocketAddr::from((
1972
- std::net::Ipv6Addr::LOCALHOST,
1973
- 0,
1974
- ))])
2012
+ } = connected(
2013
+ vec![std::net::SocketAddr::from((
2014
+ std::net::Ipv6Addr::LOCALHOST,
2015
+ 0,
2016
+ ))],
2017
+ Vec::new(),
2018
+ false,
2019
+ )
1975
2020
  .await;
1976
2021
  let offered = offered.lock().unwrap().clone();
1977
2022
  eprintln!("ipv6: the engine offered {offered:?}");
@@ -2047,4 +2092,110 @@ pub(crate) mod tests {
2047
2092
  "no one else is offered a port on the loopback"
2048
2093
  );
2049
2094
  }
2095
+
2096
+ /// A STUN responder bound on the wildcard that answers every Binding
2097
+ /// request with the requester's own address, the way a real STUN server
2098
+ /// would. Reach it at the address `primary_ip` names, not at its bind
2099
+ /// address: the wildcard receives for every interface.
2100
+ async fn stun_responder() -> std::net::SocketAddr {
2101
+ use rtc::stun::message::Setter;
2102
+ let socket = tokio::net::UdpSocket::bind("0.0.0.0:0")
2103
+ .await
2104
+ .expect("a socket");
2105
+ let addr = socket.local_addr().expect("a bound port");
2106
+ tokio::spawn(async move {
2107
+ let mut buffer = vec![0u8; 1500];
2108
+ while let Ok((size, peer)) = socket.recv_from(&mut buffer).await {
2109
+ let mut request = rtc::stun::message::Message::new();
2110
+ request.raw = buffer[..size].to_vec();
2111
+ if request.decode().is_err() {
2112
+ continue;
2113
+ }
2114
+ let mut response = rtc::stun::message::Message::new();
2115
+ response.set_type(rtc::stun::message::BINDING_SUCCESS);
2116
+ response.transaction_id = request.transaction_id;
2117
+ let mapped = rtc::stun::xoraddr::XorMappedAddress {
2118
+ ip: peer.ip(),
2119
+ port: peer.port(),
2120
+ };
2121
+ if mapped.add_to(&mut response).is_err() {
2122
+ continue;
2123
+ }
2124
+ response.write_header();
2125
+ let _ = socket.send_to(&response.raw, peer).await;
2126
+ }
2127
+ });
2128
+ addr
2129
+ }
2130
+
2131
+ /// The interface address this machine sources its own traffic from — the
2132
+ /// address a socket bound to this machine's interfaces can reach. The
2133
+ /// peer binds one socket per non-loopback interface (loopback is skipped
2134
+ /// when host candidates are gathered), and Windows' strong-host model
2135
+ /// refuses to send an interface-bound datagram to 127.0.0.1, where the
2136
+ /// Linux weak-host model allows it — so a loopback STUN fixture gathers
2137
+ /// nothing on Windows. A real STUN server sits on the subnet, and the
2138
+ /// fixture does the same. The probe is a connected UDP socket: the OS
2139
+ /// resolves the route and its source address without sending a packet.
2140
+ fn primary_ip() -> std::net::IpAddr {
2141
+ let probe = std::net::UdpSocket::bind("0.0.0.0:0").and_then(|probe| {
2142
+ probe.connect("192.0.2.1:9")?; // TEST-NET-1: connected UDP sends nothing
2143
+ probe.local_addr()
2144
+ });
2145
+ match probe {
2146
+ Ok(addr) if !addr.ip().is_unspecified() && !addr.ip().is_loopback() => addr.ip(),
2147
+ _ => std::net::IpAddr::from([127, 0, 0, 1]),
2148
+ }
2149
+ }
2150
+
2151
+ /// One ice_servers entry listing two URLs must put both in front of the
2152
+ /// peer connection: the first responder here never answers, so any
2153
+ /// reflexive candidate can only have come from the second URL. The engine
2154
+ /// used to forward only the first URL of each entry, which lost the rest.
2155
+ #[tokio::test]
2156
+ async fn the_second_url_of_an_ice_server_entry_gathers_a_reflexive_candidate() {
2157
+ let ip = primary_ip();
2158
+ // A server that never answers: bound, so its port cannot be reused,
2159
+ // and silent, so it is as good as unreachable.
2160
+ let silent = tokio::net::UdpSocket::bind("0.0.0.0:0")
2161
+ .await
2162
+ .expect("a socket");
2163
+ let unreachable = silent.local_addr().expect("a port");
2164
+ let answering = stun_responder().await;
2165
+
2166
+ let (events, mut events_rx) = tokio::sync::mpsc::unbounded_channel();
2167
+ let (_peer, _offer) = VideoPeer::offer(
2168
+ TransportOptions {
2169
+ ice_servers: vec![(
2170
+ vec![
2171
+ format!("stun:{ip}:{}", unreachable.port()),
2172
+ format!("stun:{ip}:{}", answering.port()),
2173
+ ],
2174
+ None,
2175
+ None,
2176
+ )],
2177
+ loopback_tcp: false,
2178
+ pace_bps: 20_000_000.0,
2179
+ },
2180
+ events,
2181
+ VideoCodec::Vp9,
2182
+ )
2183
+ .await
2184
+ .expect("a peer connection");
2185
+
2186
+ // Host candidates arrive without the network; the reflexive one needs
2187
+ // the binding round trip through the second URL of the entry.
2188
+ let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
2189
+ loop {
2190
+ let event = tokio::time::timeout_at(deadline, events_rx.recv())
2191
+ .await
2192
+ .expect("no reflexive candidate was ever gathered")
2193
+ .expect("the peer event stream ended");
2194
+ if let PeerEvent::Candidate { candidate, .. } = event {
2195
+ if candidate.contains(" typ srflx ") {
2196
+ break;
2197
+ }
2198
+ }
2199
+ }
2200
+ }
2050
2201
  }