@desklink/host 0.5.3 → 0.5.6

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.
Files changed (76) hide show
  1. package/README.md +33 -19
  2. package/dist/bridge.js +3 -3
  3. package/dist/bridge.js.map +1 -1
  4. package/dist/engineProcess.d.ts +5 -0
  5. package/dist/engineProcess.d.ts.map +1 -1
  6. package/dist/engineProcess.js +6 -3
  7. package/dist/engineProcess.js.map +1 -1
  8. package/dist/protocol.d.ts +39 -0
  9. package/dist/protocol.d.ts.map +1 -1
  10. package/dist/protocol.js.map +1 -1
  11. package/docs/BOUNDARY.md +4 -2
  12. package/docs/PROTOCOL.md +70 -7
  13. package/engine/Cargo.lock +6 -8
  14. package/engine/Cargo.toml +13 -1
  15. package/engine/src/audio.rs +398 -0
  16. package/engine/src/clipboard.rs +0 -34
  17. package/engine/src/convert.rs +0 -170
  18. package/engine/src/cursor.rs +0 -23
  19. package/engine/src/input.rs +0 -65
  20. package/engine/src/input_mac.rs +0 -11
  21. package/engine/src/keeper.rs +0 -137
  22. package/engine/src/keymap.rs +0 -107
  23. package/engine/src/keymap_mac.rs +0 -25
  24. package/engine/src/mac.rs +1 -28
  25. package/engine/src/main.rs +22 -0
  26. package/engine/src/peer.rs +297 -0
  27. package/engine/src/portal.rs +0 -18
  28. package/engine/src/protocol.rs +22 -162
  29. package/engine/src/session.rs +176 -3
  30. package/engine/src/win.rs +1 -0
  31. package/engine/src/x11.rs +0 -13
  32. package/engine/vendor/rtc-ice/Cargo.toml +137 -0
  33. package/engine/vendor/rtc-ice/DESKLINK-PATCH.md +111 -0
  34. package/engine/vendor/rtc-ice/LICENSE-APACHE +201 -0
  35. package/engine/vendor/rtc-ice/LICENSE-MIT +21 -0
  36. package/engine/vendor/rtc-ice/README.md +30 -0
  37. package/engine/vendor/rtc-ice/examples/ping_pong.rs +376 -0
  38. package/engine/vendor/rtc-ice/src/agent/agent_config.rs +126 -0
  39. package/engine/vendor/rtc-ice/src/agent/agent_proto.rs +172 -0
  40. package/engine/vendor/rtc-ice/src/agent/agent_selector.rs +647 -0
  41. package/engine/vendor/rtc-ice/src/agent/agent_stats.rs +292 -0
  42. package/engine/vendor/rtc-ice/src/agent/agent_test.rs +3594 -0
  43. package/engine/vendor/rtc-ice/src/agent/mod.rs +1753 -0
  44. package/engine/vendor/rtc-ice/src/attributes/control/control_test.rs +168 -0
  45. package/engine/vendor/rtc-ice/src/attributes/control/mod.rs +142 -0
  46. package/engine/vendor/rtc-ice/src/attributes/mod.rs +6 -0
  47. package/engine/vendor/rtc-ice/src/attributes/priority/mod.rs +37 -0
  48. package/engine/vendor/rtc-ice/src/attributes/priority/priority_test.rs +39 -0
  49. package/engine/vendor/rtc-ice/src/attributes/use_candidate/mod.rs +33 -0
  50. package/engine/vendor/rtc-ice/src/attributes/use_candidate/use_candidate_test.rs +19 -0
  51. package/engine/vendor/rtc-ice/src/candidate/candidate_host.rs +53 -0
  52. package/engine/vendor/rtc-ice/src/candidate/candidate_pair.rs +215 -0
  53. package/engine/vendor/rtc-ice/src/candidate/candidate_pair_test.rs +201 -0
  54. package/engine/vendor/rtc-ice/src/candidate/candidate_peer_reflexive.rs +49 -0
  55. package/engine/vendor/rtc-ice/src/candidate/candidate_relay.rs +53 -0
  56. package/engine/vendor/rtc-ice/src/candidate/candidate_relay_test.rs +114 -0
  57. package/engine/vendor/rtc-ice/src/candidate/candidate_server_reflexive.rs +53 -0
  58. package/engine/vendor/rtc-ice/src/candidate/candidate_server_reflexive_test.rs +91 -0
  59. package/engine/vendor/rtc-ice/src/candidate/candidate_test.rs +540 -0
  60. package/engine/vendor/rtc-ice/src/candidate/mod.rs +679 -0
  61. package/engine/vendor/rtc-ice/src/dl_trace.rs +49 -0
  62. package/engine/vendor/rtc-ice/src/lib.rs +88 -0
  63. package/engine/vendor/rtc-ice/src/mdns/mdns_test.rs +150 -0
  64. package/engine/vendor/rtc-ice/src/mdns/mod.rs +69 -0
  65. package/engine/vendor/rtc-ice/src/network_type/mod.rs +162 -0
  66. package/engine/vendor/rtc-ice/src/network_type/network_type_test.rs +95 -0
  67. package/engine/vendor/rtc-ice/src/rand/mod.rs +91 -0
  68. package/engine/vendor/rtc-ice/src/rand/rand_test.rs +115 -0
  69. package/engine/vendor/rtc-ice/src/state/mod.rs +106 -0
  70. package/engine/vendor/rtc-ice/src/state/state_test.rs +46 -0
  71. package/engine/vendor/rtc-ice/src/stats/mod.rs +166 -0
  72. package/engine/vendor/rtc-ice/src/tcp_type/mod.rs +47 -0
  73. package/engine/vendor/rtc-ice/src/tcp_type/tcp_type_test.rs +18 -0
  74. package/engine/vendor/rtc-ice/src/url/mod.rs +266 -0
  75. package/engine/vendor/rtc-ice/src/url/url_test.rs +142 -0
  76. package/package.json +8 -3
@@ -93,6 +93,10 @@ pub struct Metrics {
93
93
  /// Control messages forwarded back to the consumer that owns the device,
94
94
  /// instead of being applied to a desktop this engine holds.
95
95
  pub input_forwarded: u64,
96
+ pub audio_frames: u64,
97
+ pub audio_bytes: u64,
98
+ pub audio_dropped_bytes: u64,
99
+ pub audio_gap_micros: u64,
96
100
  }
97
101
 
98
102
  fn tile_hashes(raw: &[u8], width: usize, height: usize) -> Vec<u64> {
@@ -708,6 +712,8 @@ pub struct OpenRequest {
708
712
  /// Keep the latest frame and its tile hashes for `session.frame` and
709
713
  /// `session.frame.changed`; off, a frame is converted once, for the encoder.
710
714
  pub local_frames: bool,
715
+ /// Capture the desktop's own system audio for `session.audio`.
716
+ pub audio: bool,
711
717
  }
712
718
 
713
719
  /// Events a session raises for its consumer.
@@ -736,6 +742,14 @@ pub enum SessionEvent {
736
742
  seq: u64,
737
743
  damage: Vec<[usize; 4]>,
738
744
  },
745
+ /// One chunk was captured, even if the full queue dropped it, on the same
746
+ /// clock `session.frame.changed` uses.
747
+ Audio {
748
+ seq: u64,
749
+ timestamp_us: u64,
750
+ bytes: u64,
751
+ gap_micros: u64,
752
+ },
739
753
  /// One control-channel message from the client, for a source whose device
740
754
  /// the consumer drives. The engine applied nothing locally.
741
755
  Input {
@@ -817,6 +831,9 @@ struct Inner {
817
831
  encoded: Mutex<Option<Encoded>>,
818
832
  /// `OpenRequest::local_frames`: whether `latest` is kept at all.
819
833
  local_frames: bool,
834
+ /// Dropped on close, stopping the PipeWire stream.
835
+ #[cfg(target_os = "linux")]
836
+ audio: Mutex<Option<crate::audio::AudioCapture>>,
820
837
  cursor_info: serde_json::Value,
821
838
  }
822
839
 
@@ -869,6 +886,9 @@ pub fn capabilities() -> serde_json::Value {
869
886
  .map(|desktop| serde_json::json!([desktop.screen_size().0, desktop.screen_size().1]))
870
887
  .unwrap_or(serde_json::Value::Null);
871
888
  let clipboard = wayland_clipboard_available();
889
+ // Asked, not taken: the probe connects to this session's PipeWire, reads the
890
+ // registry and leaves. No portal, no prompt, no capture.
891
+ let audio = crate::audio::probe();
872
892
  let (input, grant_state, unavailable) = match crate::input::probe() {
873
893
  Ok(()) => (serde_json::json!(true), "granted", serde_json::Value::Null),
874
894
  Err(unavailable) => (
@@ -896,7 +916,9 @@ pub fn capabilities() -> serde_json::Value {
896
916
  "cursor": "embedded",
897
917
  "cursor_modes": ["embedded", "hidden"],
898
918
  "cursor_positions": {"x11": true, "wayland": "ext-image-copy-capture-v1"},
899
- "audio": false,
919
+ "audio": audio.is_some(),
920
+ "audio_formats": crate::audio::FORMATS,
921
+ "audio_source": audio.map(|source| source.name),
900
922
  },
901
923
  "encode": encode_capabilities(),
902
924
  "input": {
@@ -949,6 +971,7 @@ pub fn capabilities() -> serde_json::Value {
949
971
  "formats": ["bgra"],
950
972
  "cursor": "embedded",
951
973
  "cursor_modes": ["embedded"],
974
+ // ScreenCaptureKit audio is not wired up; macOS is out of scope.
952
975
  "audio": false,
953
976
  "displays": crate::mac::displays(),
954
977
  "grant": if capture_granted { "granted" } else { "missing-screen-recording" },
@@ -1007,6 +1030,13 @@ impl Session {
1007
1030
  request: OpenRequest,
1008
1031
  events: tokio_mpsc::UnboundedSender<Notice>,
1009
1032
  ) -> std::result::Result<Self, SessionError> {
1033
+ #[cfg(not(target_os = "linux"))]
1034
+ if request.audio {
1035
+ return Err(SessionError::new(
1036
+ "audio-unavailable",
1037
+ "system audio capture requires Linux PipeWire",
1038
+ ));
1039
+ }
1010
1040
  if request.cursor == crate::protocol::CursorMode::Hidden {
1011
1041
  #[cfg(not(target_os = "linux"))]
1012
1042
  return Err(SessionError::new(
@@ -1370,6 +1400,14 @@ impl Session {
1370
1400
  SessionError::new("input", format!("no keyboard layout: {error:#}"))
1371
1401
  })?;
1372
1402
 
1403
+ // Refuse before allocating the peer; never silently downgrade.
1404
+ #[cfg(target_os = "linux")]
1405
+ let audio = if request.audio {
1406
+ Some(start_audio(&id, &events)?)
1407
+ } else {
1408
+ None
1409
+ };
1410
+
1373
1411
  // H.264 is offered beside VP9 only when an encoder for it starts on
1374
1412
  // this machine; the answer then decides which one is coded.
1375
1413
  let codec = if !crate::h264::fits(width, height) {
@@ -1433,6 +1471,8 @@ impl Session {
1433
1471
  },
1434
1472
  capture: Mutex::new(Some(capture)),
1435
1473
  layout: Mutex::new(Some(layout)),
1474
+ #[cfg(target_os = "linux")]
1475
+ audio: Mutex::new(audio),
1436
1476
  last_seq: Mutex::new(0),
1437
1477
  control_open: AtomicBool::new(false),
1438
1478
  closed: AtomicBool::new(false),
@@ -1474,6 +1514,12 @@ impl Session {
1474
1514
  height: usize,
1475
1515
  events: tokio_mpsc::UnboundedSender<Notice>,
1476
1516
  ) -> std::result::Result<Self, SessionError> {
1517
+ if request.audio {
1518
+ return Err(SessionError::new(
1519
+ "operation",
1520
+ "an encoded source carries video the consumer already encoded; there is no local desktop audio to capture",
1521
+ ));
1522
+ }
1477
1523
  if width == 0 || height == 0 || width > 7680 || height > 4320 {
1478
1524
  return Err(SessionError::new(
1479
1525
  "source",
@@ -1523,6 +1569,8 @@ impl Session {
1523
1569
  point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
1524
1570
  capture: Mutex::new(None),
1525
1571
  layout: Mutex::new(None),
1572
+ #[cfg(target_os = "linux")]
1573
+ audio: Mutex::new(None),
1526
1574
  last_seq: Mutex::new(0),
1527
1575
  control_open: AtomicBool::new(false),
1528
1576
  closed: AtomicBool::new(false),
@@ -1850,11 +1898,92 @@ impl Session {
1850
1898
  }
1851
1899
 
1852
1900
  pub fn metrics(&self) -> Metrics {
1853
- self.inner
1901
+ let mut metrics = self
1902
+ .inner
1854
1903
  .metrics
1855
1904
  .lock()
1856
1905
  .map(|m| m.clone())
1857
- .unwrap_or_default()
1906
+ .unwrap_or_default();
1907
+ #[cfg(target_os = "linux")]
1908
+ if let Some(audio) = lock(&self.inner.audio).as_ref() {
1909
+ let (frames, bytes, dropped_bytes, gap_micros) = audio.totals();
1910
+ metrics.audio_frames = frames;
1911
+ metrics.audio_bytes = bytes;
1912
+ metrics.audio_dropped_bytes = dropped_bytes;
1913
+ metrics.audio_gap_micros = gap_micros;
1914
+ }
1915
+ metrics
1916
+ }
1917
+
1918
+ pub fn audio_info(&self) -> Option<serde_json::Value> {
1919
+ #[cfg(target_os = "linux")]
1920
+ {
1921
+ lock(&self.inner.audio).as_ref()?; // start() accepted the fixed format.
1922
+ return Some(serde_json::json!({
1923
+ "source": "pipewire-monitor", "format": crate::audio::FORMAT,
1924
+ "sample_rate": crate::audio::SAMPLE_RATE, "channels": crate::audio::CHANNELS,
1925
+ }));
1926
+ }
1927
+ #[cfg(not(target_os = "linux"))]
1928
+ None
1929
+ }
1930
+
1931
+ /// Consume one queued PCM chunk; samples never travel through JSON.
1932
+ #[cfg(target_os = "linux")]
1933
+ pub fn audio(
1934
+ &self,
1935
+ path: &str,
1936
+ max_bytes: usize,
1937
+ ) -> std::result::Result<serde_json::Value, SessionError> {
1938
+ let no_audio = if lock(&self.inner.encoded).is_some() {
1939
+ "this session forwards an encoded stream and has no local audio"
1940
+ } else {
1941
+ "this session was opened without audio"
1942
+ };
1943
+ let held = lock(&self.inner.audio);
1944
+ let capture = held
1945
+ .as_ref()
1946
+ .ok_or_else(|| SessionError::new("operation", no_audio))?;
1947
+ let frame_bytes = crate::audio::BYTES_PER_FRAME as usize;
1948
+ if max_bytes < frame_bytes {
1949
+ return Err(SessionError::new(
1950
+ "malformed",
1951
+ "max_bytes must hold at least one sample frame",
1952
+ ));
1953
+ }
1954
+ let chunk = capture
1955
+ .take()
1956
+ .ok_or_else(|| SessionError::new("audio", "no audio chunk has arrived yet"))?;
1957
+ let wanted = (max_bytes / frame_bytes * frame_bytes).min(chunk.bytes.len());
1958
+ let written = if path.is_empty() { 0 } else { wanted };
1959
+ let result = serde_json::json!({
1960
+ "seq": chunk.seq,
1961
+ "timestamp_us": crate::protocol::timestamp_us(chunk.at),
1962
+ "frames": written as u64 / crate::audio::BYTES_PER_FRAME,
1963
+ "bytes": written,
1964
+ "format": crate::audio::FORMAT,
1965
+ "sample_rate": crate::audio::SAMPLE_RATE,
1966
+ "channels": crate::audio::CHANNELS,
1967
+ "gap_ms": chunk.gap_micros / 1000,
1968
+ "written": !path.is_empty(),
1969
+ });
1970
+ if written > 0 {
1971
+ std::fs::write(path, &chunk.bytes[..written])
1972
+ .map_err(|error| SessionError::new("audio", error.to_string()))?;
1973
+ }
1974
+ Ok(result)
1975
+ }
1976
+
1977
+ #[cfg(not(target_os = "linux"))]
1978
+ pub fn audio(
1979
+ &self,
1980
+ _path: &str,
1981
+ _max_bytes: usize,
1982
+ ) -> std::result::Result<serde_json::Value, SessionError> {
1983
+ Err(SessionError::new(
1984
+ "operation",
1985
+ "this platform has no system audio capture",
1986
+ ))
1858
1987
  }
1859
1988
 
1860
1989
  pub async fn accept_answer(&self, sdp: String) -> Result<()> {
@@ -1987,6 +2116,8 @@ impl Inner {
1987
2116
  // out of scope, so the consumer can truthfully say the desktop stopped.
1988
2117
  let capture = self.capture.lock().ok().and_then(|mut held| held.take());
1989
2118
  drop(capture);
2119
+ #[cfg(target_os = "linux")]
2120
+ drop(self.audio.lock().ok().and_then(|mut held| held.take()));
1990
2121
  self.notify(SessionEvent::State {
1991
2122
  capture: "ended",
1992
2123
  transport: String::from("closed"),
@@ -2112,6 +2243,15 @@ impl Inner {
2112
2243
  self.forward(message);
2113
2244
  return;
2114
2245
  }
2246
+ // A shared window's place on the desktop is unknown (the portal reports
2247
+ // no origin for it), while the pointer device spans the whole desktop:
2248
+ // a tap would land somewhere the person never pointed.
2249
+ if self.source.source_type.as_deref() == Some("Window")
2250
+ && matches!(&message, ControlMessage::Pointer { phase, .. } if !matches!(phase, PointerPhase::Cancel))
2251
+ {
2252
+ self.reject(seq, "input-unavailable", WINDOW_POINTER_REFUSAL);
2253
+ return;
2254
+ }
2115
2255
  if !matches!(
2116
2256
  &message,
2117
2257
  ControlMessage::ClipboardRead { .. } | ControlMessage::ClipboardWrite { .. }
@@ -2445,6 +2585,27 @@ fn frame_interval(max_fps: u32) -> Duration {
2445
2585
  Duration::from_secs_f64(1.0 / max_fps.max(1) as f64)
2446
2586
  }
2447
2587
 
2588
+ #[cfg(target_os = "linux")]
2589
+ fn start_audio(
2590
+ id: &str,
2591
+ events: &tokio_mpsc::UnboundedSender<Notice>,
2592
+ ) -> std::result::Result<crate::audio::AudioCapture, SessionError> {
2593
+ let events = events.clone();
2594
+ let id = id.to_owned();
2595
+ crate::audio::AudioCapture::start(Box::new(move |notice| {
2596
+ let _ = events.send(Notice {
2597
+ session_id: id.clone(),
2598
+ event: SessionEvent::Audio {
2599
+ seq: notice.seq,
2600
+ timestamp_us: crate::protocol::timestamp_us(notice.at),
2601
+ bytes: notice.bytes,
2602
+ gap_micros: notice.gap_micros,
2603
+ },
2604
+ });
2605
+ }))
2606
+ .map_err(|reason| SessionError::new("audio-unavailable", reason))
2607
+ }
2608
+
2448
2609
  /// The newest captured frame and when it was captured, handed from the capture
2449
2610
  /// thread to the encoder. A newer frame replaces one the encoder has not taken,
2450
2611
  /// so however far behind it falls, what it codes next is the desktop as it is
@@ -3243,6 +3404,11 @@ fn spawn_peer_events(inner: &Arc<Inner>, mut events: tokio_mpsc::UnboundedReceiv
3243
3404
  first_frame: inner.metrics.lock().map(|m| m.encoded_frames).unwrap_or(0)
3244
3405
  > 0,
3245
3406
  });
3407
+ // desklink scout trace: the session's transport emit, one
3408
+ // line after the peer event that caused it.
3409
+ if crate::peer::dl_trace_on() {
3410
+ crate::peer::dl_eng!("session_transport_emit {}", format!("{state}").to_lowercase());
3411
+ }
3246
3412
  }
3247
3413
  PeerEvent::ControlOpen => {
3248
3414
  inner.control_open.store(true, Ordering::SeqCst);
@@ -3301,6 +3467,8 @@ fn available_parallelism() -> usize {
3301
3467
  .unwrap_or(4)
3302
3468
  }
3303
3469
 
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";
3471
+
3304
3472
  /// Encoded-surface pixels to the source's own pixels. The client aims at what it
3305
3473
  /// sees; the applier is sized and clamped to the source, so the layout origin
3306
3474
  /// must not be added here.
@@ -3417,6 +3585,8 @@ mod tests {
3417
3585
  point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
3418
3586
  capture: Mutex::new(None),
3419
3587
  layout: Mutex::new(None),
3588
+ #[cfg(target_os = "linux")]
3589
+ audio: Mutex::new(None),
3420
3590
  last_seq: Mutex::new(0),
3421
3591
  control_open: AtomicBool::new(false),
3422
3592
  closed: AtomicBool::new(false),
@@ -3673,6 +3843,8 @@ mod tests {
3673
3843
  #[cfg(target_os = "linux")]
3674
3844
  point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
3675
3845
  layout: Mutex::new(Some(Layout::from_environment().expect("a keymap"))),
3846
+ #[cfg(target_os = "linux")]
3847
+ audio: Mutex::new(None),
3676
3848
  last_seq: Mutex::new(0),
3677
3849
  control_open: AtomicBool::new(true),
3678
3850
  closed: AtomicBool::new(false),
@@ -4080,6 +4252,7 @@ mod tests {
4080
4252
  loopback_tcp: false,
4081
4253
  agent_indicator: false,
4082
4254
  local_frames: true,
4255
+ audio: false,
4083
4256
  }
4084
4257
  }
4085
4258
 
package/engine/src/win.rs CHANGED
@@ -15,6 +15,7 @@ pub fn capabilities() -> Value {
15
15
  "formats": [],
16
16
  "cursor": "unavailable",
17
17
  "cursor_modes": [],
18
+ // No Windows capture path exists to carry audio from; out of scope.
18
19
  "audio": false,
19
20
  "displays": crate::portal::displays(),
20
21
  "grant": "unavailable",
package/engine/src/x11.rs CHANGED
@@ -465,16 +465,3 @@ impl X11Desktop {
465
465
  )
466
466
  }
467
467
  }
468
-
469
- #[cfg(test)]
470
- mod tests {
471
- use super::*;
472
-
473
- #[test]
474
- fn fitting_never_upscales_and_keeps_even_dimensions() {
475
- assert_eq!(fit(800, 600, 1280, 800), (800, 600));
476
- assert_eq!(fit(1920, 1080, 1280, 720), (1280, 720));
477
- let (w, h) = fit(1000, 1000, 600, 600);
478
- assert_eq!((w, h), (600, 600));
479
- }
480
- }
@@ -0,0 +1,137 @@
1
+ # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
2
+ #
3
+ # When uploading crates to the registry Cargo will automatically
4
+ # "normalize" Cargo.toml files for maximal compatibility
5
+ # with all versions of Cargo and also rewrite `path` dependencies
6
+ # to registry (e.g., crates.io) dependencies.
7
+ #
8
+ # If you are reading this file be aware that the original Cargo.toml
9
+ # will likely look very different (and much more reasonable).
10
+ # See Cargo.toml.orig for the original contents.
11
+
12
+ [package]
13
+ edition = "2024"
14
+ name = "rtc-ice"
15
+ version = "0.21.0"
16
+ authors = ["Rain Liu <yliu@webrtc.rs>"]
17
+ build = false
18
+ autolib = false
19
+ autobins = false
20
+ autoexamples = false
21
+ autotests = false
22
+ autobenches = false
23
+ description = "RTC ICE in Rust"
24
+ homepage = "https://webrtc.rs"
25
+ documentation = "https://docs.rs/rtc-ice"
26
+ readme = "README.md"
27
+ keywords = [
28
+ "sansio",
29
+ "networking",
30
+ "protocols",
31
+ ]
32
+ categories = ["network-programming"]
33
+ license = "MIT/Apache-2.0"
34
+ repository = "https://github.com/webrtc-rs/rtc"
35
+ resolver = "2"
36
+
37
+ [features]
38
+ crypto-aws-lc-rs = [
39
+ "crypto/crypto-aws-lc-rs",
40
+ "stun/crypto-aws-lc-rs",
41
+ ]
42
+ crypto-ring = [
43
+ "crypto/crypto-ring",
44
+ "stun/crypto-ring",
45
+ ]
46
+ default = ["crypto-ring"]
47
+
48
+ [lib]
49
+ name = "rtc_ice"
50
+ path = "src/lib.rs"
51
+
52
+ [[example]]
53
+ name = "ping_pong"
54
+ path = "examples/ping_pong.rs"
55
+ bench = false
56
+
57
+ [dependencies.bytes]
58
+ version = "1.12.0"
59
+
60
+ [dependencies.crc]
61
+ version = "3.0.1"
62
+
63
+ [dependencies.crypto]
64
+ version = "0.21.0"
65
+ default-features = false
66
+ package = "rtc-crypto"
67
+
68
+ [dependencies.log]
69
+ version = "0.4.33"
70
+
71
+ [dependencies.mdns]
72
+ version = "0.21.0"
73
+ package = "rtc-mdns"
74
+
75
+ [dependencies.sansio]
76
+ version = "1"
77
+
78
+ [dependencies.serde]
79
+ version = "1.0.228"
80
+ features = ["derive"]
81
+
82
+ [dependencies.shared]
83
+ version = "0.21.0"
84
+ features = []
85
+ default-features = false
86
+ package = "rtc-shared"
87
+
88
+ [dependencies.stun]
89
+ version = "0.21.0"
90
+ default-features = false
91
+ package = "rtc-stun"
92
+
93
+ [dependencies.url]
94
+ version = "2.5.0"
95
+
96
+ [dependencies.uuid]
97
+ version = "1"
98
+ features = ["v4"]
99
+
100
+ [dev-dependencies.chrono]
101
+ version = "0.4.45"
102
+
103
+ [dev-dependencies.clap]
104
+ version = "4.6.1"
105
+ features = ["derive"]
106
+
107
+ [dev-dependencies.ctrlc]
108
+ version = "3.5.2"
109
+
110
+ [dev-dependencies.env_logger]
111
+ version = "0.11.11"
112
+
113
+ [dev-dependencies.futures]
114
+ version = "0.3.30"
115
+
116
+ [dev-dependencies.hyper]
117
+ version = "0.14.28"
118
+ features = ["full"]
119
+
120
+ [dev-dependencies.ipnet]
121
+ version = "2.9.0"
122
+
123
+ [dev-dependencies.lazy_static]
124
+ version = "1.4.0"
125
+
126
+ [dev-dependencies.regex]
127
+ version = "1.10.3"
128
+
129
+ [dev-dependencies.serde_json]
130
+ version = "1.0.114"
131
+
132
+ [dev-dependencies.tokio]
133
+ version = "1.52.3"
134
+ features = ["full"]
135
+
136
+ [dev-dependencies.waitgroup]
137
+ version = "0.1.2"
@@ -0,0 +1,111 @@
1
+ # desklink patch to vendored `rtc-ice` 0.21.0
2
+
3
+ Two shapes, both for the roam-persistence goal: keep media flowing across an
4
+ ICE restart, and nominate the restarted generation without waiting for a check
5
+ tick.
6
+
7
+ ## What
8
+
9
+ ### 1. Media keeps flowing on the previous pair across a restart
10
+
11
+ `Agent::apply_restart` (src/agent/mod.rs) no longer interrupts media that is
12
+ already flowing on the current selected ICE pair. When a restart is applied, the
13
+ pair media was using is remembered (`restart_media_pair`), and
14
+ `get_selected_candidate_pair` keeps returning it until the restarted generation
15
+ nominates a new pair. Everything else about the restart is unchanged: the new
16
+ local credentials are installed, remote credentials are dropped, binding
17
+ requests and candidate pairs are cleared, the agent re-checks, and the
18
+ connection state goes to `Checking`.
19
+
20
+ ### 2. The nomination does not wait for the next check tick
21
+
22
+ `ControllingSelector::handle_success_response` (src/agent/agent_selector.rs)
23
+ requests an immediate check pass when the response makes a pair `Succeeded` and
24
+ that pair is the one the next pass would nominate. Nomination is decided in
25
+ `contact_candidates`, which the driver only reaches on a `check_interval` tick
26
+ (200 ms) or when an inbound Binding request happens to arrive while a valid pair
27
+ already exists; a pair that becomes valid just after a tick therefore waits a
28
+ full tick for its `USE-CANDIDATE`. The request uses
29
+ `Agent::request_connectivity_check()`, the deferred-check mechanism added for
30
+ issue #88, so `contact()` is not called from the read path and `poll_timeout`
31
+ reports an already-due deadline instead.
32
+
33
+ Every changed spot is marked with a `// desklink patch:` comment.
34
+
35
+ ## Why
36
+
37
+ An ICE restart on the engine (a `session.restart_ice` from the client) used to
38
+ drop the selected pair before the client had even seen the offer. The engine's
39
+ RTP/DTLS send path stamps outgoing packets from
40
+ `Agent::get_selected_candidate_pair`, and drops them when there is none, so
41
+ media stopped until the new generation re-nominated a pair — 1.3–2.5 s on real
42
+ networks, which is inside the roam-persistence 2 s bar.
43
+
44
+ RFC 8445 §9 and libwebrtc keep media on the **previous selected pair** while a
45
+ restart's connectivity checks run, and move to the new pair once it is
46
+ nominated. rtc-ice 0.21.0 did not; this patch makes it do so. It is needed for
47
+ the desklink roam goal: after a network path change, the live picture must be
48
+ advancing again within 2 s of path-back on the same session, including the
49
+ client's 300 ms reattach grace being removed later.
50
+
51
+ The behavior is pinned by the engine integration test
52
+ `a_restart_keeps_media_flowing_on_the_selected_pair_until_the_new_one_nominates`
53
+ in `packages/desktop-host/engine/src/peer.rs`.
54
+
55
+ Shape 2 exists because the tick is the engine's largest single bounded cost
56
+ between a restart offer and `transport=connected`: in the routed roam leg the
57
+ engine's first pair became valid 332 ms after the answer and its `USE-CANDIDATE`
58
+ left 197 ms later — one 200 ms tick. A peer that runs no connectivity checks of
59
+ its own leaves an inbound request unavailable, and the nomination then waits for
60
+ the tick. The engine integration test asserts the reconnect bound; the loopback
61
+ harness cannot grade sub-tick timing on its own (its client answers checks and
62
+ sends triggered checks in the same burst, so an inbound request is available
63
+ almost immediately), which is why the field measurement is the reference.
64
+
65
+ ## Scope of the change
66
+
67
+ ### Shape 1
68
+
69
+ - `Agent` gains `restart_media_pair: Option<(Candidate, Candidate)>`, initialised
70
+ to `None`.
71
+ - `apply_restart` clones the current `(local, remote)` into it before the
72
+ teardown.
73
+ - `get_selected_candidate_pair` returns that clone when there is no live
74
+ `selected_pair`.
75
+ - `set_selected_pair(.., Some(..))` clears it once a new pair is nominated.
76
+ - `update_connection_state` clears it on `Failed` / `Closed`, so a restart that
77
+ never nominates does not keep media pointed at an unreachable pair.
78
+
79
+ ### Shape 2
80
+
81
+ - `ControllingSelector::handle_success_response` calls
82
+ `request_connectivity_check()` after a pair becomes `Succeeded`, when no pair is
83
+ selected or nominated yet, this pair is the best valid pair, and both of its
84
+ candidates have passed their acceptance wait (`is_nominatable`).
85
+ - Nothing else changes: the same `contact_candidates` pass decides the
86
+ nomination, the acceptance waits still gate it, and a nomination already in
87
+ flight is not re-requested, so the extra pass cannot repeat per response.
88
+
89
+ No public API changed; no signalling or wire change.
90
+
91
+ ## How to drop the patch
92
+
93
+ 1. Remove the `[patch.crates-io] rtc-ice = { path = "vendor/rtc-ice" }` entry from
94
+ `packages/desktop-host/engine/Cargo.toml`.
95
+ 2. Delete `packages/desktop-host/engine/vendor/rtc-ice`.
96
+ 3. Update `packages/desktop-host/engine/Cargo.lock` (`cargo update -p rtc-ice`).
97
+
98
+ Do this once upstream rtc-ice keeps the previous selected pair across a restart
99
+ on its own and nominates a freshly validated pair without waiting for the next
100
+ check tick (an upstream PR is a follow-up; it needs the captain's word first,
101
+ since it would carry his name).
102
+
103
+ ## Dev-only measurement instrumentation (not part of the patch)
104
+
105
+ `src/dl_trace.rs` and its call sites — marked `// desklink scout trace:` — are
106
+ a private-lab instrument, not a shipped behaviour change. They are off unless
107
+ `DESKLINK_ICE_TRACE=1` is set, and when on they only write `[ice <us>]` lines to
108
+ stderr, which the bridge forwards untouched. To drop them: delete
109
+ `src/dl_trace.rs`, remove `pub(crate) mod dl_trace;` from `src/lib.rs`, and
110
+ remove the marked blocks from `src/agent/mod.rs` and
111
+ `src/agent/agent_selector.rs` (grep `desklink scout trace`).