@desklink/host 0.5.2 → 0.5.4

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 (77) hide show
  1. package/README.md +45 -18
  2. package/bin/desklink-host.mjs +5 -1
  3. package/dist/bridge.d.ts +7 -0
  4. package/dist/bridge.d.ts.map +1 -1
  5. package/dist/bridge.js +55 -7
  6. package/dist/bridge.js.map +1 -1
  7. package/dist/engineProcess.d.ts +9 -2
  8. package/dist/engineProcess.d.ts.map +1 -1
  9. package/dist/engineProcess.js +26 -15
  10. package/dist/engineProcess.js.map +1 -1
  11. package/dist/protocol.d.ts +1 -1
  12. package/dist/protocol.d.ts.map +1 -1
  13. package/dist/protocol.js +1 -1
  14. package/dist/protocol.js.map +1 -1
  15. package/docs/BOUNDARY.md +4 -1
  16. package/docs/PROTOCOL.md +20 -5
  17. package/engine/Cargo.lock +6 -8
  18. package/engine/Cargo.toml +13 -1
  19. package/engine/src/clipboard.rs +0 -34
  20. package/engine/src/convert.rs +0 -170
  21. package/engine/src/cursor.rs +0 -23
  22. package/engine/src/input.rs +0 -65
  23. package/engine/src/input_mac.rs +0 -11
  24. package/engine/src/keeper.rs +0 -137
  25. package/engine/src/keymap.rs +0 -107
  26. package/engine/src/keymap_mac.rs +0 -25
  27. package/engine/src/mac.rs +0 -28
  28. package/engine/src/main.rs +3 -2
  29. package/engine/src/peer.rs +231 -1
  30. package/engine/src/portal.rs +0 -18
  31. package/engine/src/protocol.rs +0 -162
  32. package/engine/src/session.rs +37 -1
  33. package/engine/src/x11.rs +0 -13
  34. package/engine/vendor/rtc-ice/Cargo.toml +137 -0
  35. package/engine/vendor/rtc-ice/DESKLINK-PATCH.md +59 -0
  36. package/engine/vendor/rtc-ice/LICENSE-APACHE +201 -0
  37. package/engine/vendor/rtc-ice/LICENSE-MIT +21 -0
  38. package/engine/vendor/rtc-ice/README.md +30 -0
  39. package/engine/vendor/rtc-ice/examples/ping_pong.rs +376 -0
  40. package/engine/vendor/rtc-ice/src/agent/agent_config.rs +126 -0
  41. package/engine/vendor/rtc-ice/src/agent/agent_proto.rs +172 -0
  42. package/engine/vendor/rtc-ice/src/agent/agent_selector.rs +566 -0
  43. package/engine/vendor/rtc-ice/src/agent/agent_stats.rs +292 -0
  44. package/engine/vendor/rtc-ice/src/agent/agent_test.rs +3388 -0
  45. package/engine/vendor/rtc-ice/src/agent/mod.rs +1662 -0
  46. package/engine/vendor/rtc-ice/src/attributes/control/control_test.rs +168 -0
  47. package/engine/vendor/rtc-ice/src/attributes/control/mod.rs +142 -0
  48. package/engine/vendor/rtc-ice/src/attributes/mod.rs +6 -0
  49. package/engine/vendor/rtc-ice/src/attributes/priority/mod.rs +37 -0
  50. package/engine/vendor/rtc-ice/src/attributes/priority/priority_test.rs +39 -0
  51. package/engine/vendor/rtc-ice/src/attributes/use_candidate/mod.rs +33 -0
  52. package/engine/vendor/rtc-ice/src/attributes/use_candidate/use_candidate_test.rs +19 -0
  53. package/engine/vendor/rtc-ice/src/candidate/candidate_host.rs +53 -0
  54. package/engine/vendor/rtc-ice/src/candidate/candidate_pair.rs +215 -0
  55. package/engine/vendor/rtc-ice/src/candidate/candidate_pair_test.rs +201 -0
  56. package/engine/vendor/rtc-ice/src/candidate/candidate_peer_reflexive.rs +49 -0
  57. package/engine/vendor/rtc-ice/src/candidate/candidate_relay.rs +53 -0
  58. package/engine/vendor/rtc-ice/src/candidate/candidate_relay_test.rs +114 -0
  59. package/engine/vendor/rtc-ice/src/candidate/candidate_server_reflexive.rs +53 -0
  60. package/engine/vendor/rtc-ice/src/candidate/candidate_server_reflexive_test.rs +91 -0
  61. package/engine/vendor/rtc-ice/src/candidate/candidate_test.rs +540 -0
  62. package/engine/vendor/rtc-ice/src/candidate/mod.rs +679 -0
  63. package/engine/vendor/rtc-ice/src/lib.rs +85 -0
  64. package/engine/vendor/rtc-ice/src/mdns/mdns_test.rs +150 -0
  65. package/engine/vendor/rtc-ice/src/mdns/mod.rs +69 -0
  66. package/engine/vendor/rtc-ice/src/network_type/mod.rs +162 -0
  67. package/engine/vendor/rtc-ice/src/network_type/network_type_test.rs +95 -0
  68. package/engine/vendor/rtc-ice/src/rand/mod.rs +91 -0
  69. package/engine/vendor/rtc-ice/src/rand/rand_test.rs +115 -0
  70. package/engine/vendor/rtc-ice/src/state/mod.rs +106 -0
  71. package/engine/vendor/rtc-ice/src/state/state_test.rs +46 -0
  72. package/engine/vendor/rtc-ice/src/stats/mod.rs +166 -0
  73. package/engine/vendor/rtc-ice/src/tcp_type/mod.rs +47 -0
  74. package/engine/vendor/rtc-ice/src/tcp_type/tcp_type_test.rs +18 -0
  75. package/engine/vendor/rtc-ice/src/url/mod.rs +266 -0
  76. package/engine/vendor/rtc-ice/src/url/url_test.rs +142 -0
  77. package/package.json +8 -3
@@ -254,110 +254,3 @@ impl Layout {
254
254
  (plan, unreachable)
255
255
  }
256
256
  }
257
-
258
- #[cfg(test)]
259
- mod tests {
260
- use super::*;
261
-
262
- #[test]
263
- fn a_layout_that_comes_from_the_environment_is_the_one_compiled() {
264
- let names = LayoutNames::from_lookup(|key| match key {
265
- "XKB_DEFAULT_LAYOUT" => Some(String::from("de")),
266
- _ => None,
267
- });
268
- assert_eq!(names.identity(), "de");
269
-
270
- let layout = Layout::from_names(&names).expect("the German keymap should compile");
271
- let (plan, unreachable) = layout.plan_text("z");
272
- assert!(
273
- unreachable.is_empty(),
274
- "z should be reachable: {unreachable:?}"
275
- );
276
- // QWERTZ puts z where a US layout puts y, so the compiled keymap is the
277
- // environment's, not the library default.
278
- assert_eq!(plan[0][0].code, 21, "z must sit on the y key position");
279
- }
280
-
281
- #[test]
282
- fn a_session_with_no_layout_variable_reports_the_library_default() {
283
- assert_eq!(LayoutNames::from_lookup(|_| None).identity(), "us");
284
- }
285
-
286
- #[test]
287
- fn a_character_is_planned_as_a_real_key_event_on_the_compiled_layout() {
288
- let layout = Layout::from_names(&LayoutNames::from_lookup(|_| None))
289
- .expect("a keymap should compile");
290
- let (plan, unreachable) = layout.plan_text("aA.\n");
291
- assert!(
292
- unreachable.is_empty(),
293
- "ASCII should be reachable: {unreachable:?}"
294
- );
295
- assert_eq!(plan.len(), 4);
296
- assert!(!plan[0][0].shift, "lower-case a needs no shift");
297
- assert!(
298
- plan[1][0].shift,
299
- "upper-case A needs shift on any normal layout"
300
- );
301
- assert_eq!(plan[0][0].code, plan[1][0].code, "both letters share a key");
302
- // The plan is evdev, but the real injector expects Moonlight/VK codes.
303
- // Passing Linux N (49) straight through instead presses VK_1.
304
- let native: Vec<_> = plan
305
- .iter()
306
- .flatten()
307
- .map(|stroke| crate::input::native_keycode(stroke.code).unwrap())
308
- .collect();
309
- assert_eq!(native, [0x41, 0x41, 0xBE, 0x0D]);
310
- assert_eq!(
311
- crate::input::native_keycode(plan[1][0].modifiers()[0]).unwrap(),
312
- 0x10
313
- );
314
- assert_eq!(
315
- crate::input::native_keycode(modifier_key("Control").unwrap()).unwrap(),
316
- 0x11
317
- );
318
- }
319
-
320
- #[test]
321
- fn named_keys_and_modifiers_resolve_to_linux_key_codes() {
322
- assert_eq!(named_key("Enter").unwrap().code, keycode::ENTER);
323
- assert_eq!(named_key("ArrowLeft").unwrap().code, keycode::LEFT);
324
- for (name, code) in [
325
- ("Delete", keycode::DELETE), ("Home", keycode::HOME), ("End", keycode::END),
326
- ("PageUp", keycode::PAGE_UP), ("PageDown", keycode::PAGE_DOWN),
327
- ("F1", keycode::F1), ("F2", keycode::F2), ("F3", keycode::F3),
328
- ("F4", keycode::F4), ("F5", keycode::F5), ("F6", keycode::F6),
329
- ("F7", keycode::F7), ("F8", keycode::F8), ("F9", keycode::F9),
330
- ("F10", keycode::F10), ("F11", keycode::F11), ("F12", keycode::F12),
331
- ] {
332
- assert_eq!(named_key(name).unwrap().code, code);
333
- assert!(crate::input::native_keycode(code).is_ok(), "{name} must reach inputtino");
334
- }
335
- assert_eq!(modifier_key("Control"), Some(keycode::LEFT_CTRL));
336
- assert_eq!(modifier_key("Alt"), Some(keycode::LEFT_ALT));
337
- assert_eq!(modifier_key("Super"), Some(keycode::LEFT_META));
338
- assert!(named_key("Frobnicate").is_none());
339
- assert!(modifier_key("Hyper").is_none());
340
- }
341
-
342
- #[test]
343
- fn text_this_layout_cannot_produce_is_reported_not_invented() {
344
- let layout = Layout::from_environment().unwrap();
345
- let (_, unreachable) = layout.plan_text("\u{1F600}");
346
- assert_eq!(unreachable, "\u{1F600}");
347
- }
348
-
349
- #[test]
350
- fn a_symbol_only_reachable_beyond_four_levels_is_refused() {
351
- let names = LayoutNames::from_lookup(|key| match key {
352
- "XKB_DEFAULT_LAYOUT" => Some(String::from("de")),
353
- "XKB_DEFAULT_VARIANT" => Some(String::from("neo")),
354
- _ => None,
355
- });
356
- let layout = Layout::from_names(&names).expect("the Neo keymap should compile");
357
- assert!(
358
- layout.keystroke_for_char('\u{a3}').is_none(),
359
- "the pound sign sits on a level this engine cannot press"
360
- );
361
- assert!(layout.keystroke_for_char('6').is_some());
362
- }
363
- }
@@ -182,28 +182,3 @@ fn us_ansi_key(character: char) -> Option<(i16, bool)> {
182
182
  _ => return None,
183
183
  })
184
184
  }
185
-
186
- #[cfg(test)]
187
- mod tests {
188
- use super::*;
189
-
190
- #[test]
191
- fn maps_common_ascii_to_ansi_virtual_keys() {
192
- let layout = Layout::from_environment().unwrap();
193
- let lower = layout.keystroke_for_char('a').unwrap();
194
- let upper = layout.keystroke_for_char('A').unwrap();
195
- assert_eq!(lower.code, 0);
196
- assert_eq!(upper.code, lower.code);
197
- assert!(upper.shift);
198
- assert_eq!(layout.keystroke_for_char('!').unwrap().code, 18);
199
- assert!(layout.keystroke_for_char('\u{1f600}').is_none());
200
- }
201
-
202
- #[test]
203
- fn resolves_mac_named_keys_and_modifiers() {
204
- assert_eq!(named_key("Return").unwrap().code, 36);
205
- assert_eq!(named_key("ArrowLeft").unwrap().code, 123);
206
- assert_eq!(modifier_key("Command"), Some(55));
207
- assert!(named_key("Frobnicate").is_none());
208
- }
209
- }
package/engine/src/mac.rs CHANGED
@@ -717,31 +717,3 @@ pub fn run() -> i32 {
717
717
  }
718
718
  0
719
719
  }
720
-
721
- #[cfg(test)]
722
- mod tests {
723
- use super::*;
724
-
725
- #[cfg(desklink_vpx)]
726
- #[test]
727
- fn session_capabilities_are_non_prompting_and_expose_mac_features() {
728
- let value = crate::session::capabilities();
729
- assert_eq!(value["protocol"], crate::protocol::PROTOCOL_VERSION);
730
- assert_eq!(value["platform"], "macos");
731
- assert_eq!(value["capture"]["mechanism"], "screencapturekit");
732
- assert_eq!(value["encode"]["codecs"][0], "vp9", "VP9 is always offered");
733
- // Every Mac this engine runs on has a hardware H.264 encoder.
734
- assert_eq!(value["encode"]["codecs"][1], "h264");
735
- assert_eq!(value["encode"]["h264"]["encoder"], "videotoolbox");
736
- assert!(value["capture"]["displays"].is_array());
737
- assert!(value["clipboard"]["read"].as_bool().unwrap());
738
- assert!(matches!(
739
- value["capture"]["grant"].as_str(),
740
- Some("granted" | "missing-screen-recording")
741
- ));
742
- assert!(matches!(
743
- value["input"]["grant"].as_str(),
744
- Some("granted" | "missing-accessibility")
745
- ));
746
- }
747
- }
@@ -728,8 +728,9 @@ async fn dispatch(
728
728
  Ok(serde_json::json!({ "accepted": true }))
729
729
  }
730
730
  "session.restart_ice" => {
731
- // The client asks; the engine offers. The fresh offer travels as a
732
- // new `session.description` event and the client answers it as
731
+ // The client asks; the engine offers. The offer — fresh, or the
732
+ // still-pending one when restarts queue — travels as a new
733
+ // `session.description` event and the client answers it as
733
734
  // usual, so no new event shape is needed and the session,
734
735
  // generation and control channel survive.
735
736
  let session = require_session(current)?;
@@ -1008,11 +1008,17 @@ impl VideoPeer {
1008
1008
  Ok(applied)
1009
1009
  }
1010
1010
 
1011
- /// Restart ICE on this peer: a fresh offer with a new ICE generation,
1011
+ /// Restart ICE on this peer, reusing any unanswered local offer; otherwise
1012
+ /// create a fresh offer with a new ICE generation,
1012
1013
  /// for the client to answer as usual. The engine stays the offerer, so
1013
1014
  /// this is the same local-offer path as the initial negotiation.
1014
1015
  pub async fn restart_ice(&self) -> Result<String> {
1015
1016
  use rtc::peer_connection::configuration::RTCOfferOptions;
1017
+ // The stdio dispatcher serializes requests; overlapping carriers must
1018
+ // answer this offer before another restart can turn over credentials.
1019
+ if let Some(offer) = self.peer.pending_local_description().await {
1020
+ return Ok(offer.sdp);
1021
+ }
1016
1022
  let offer = self
1017
1023
  .peer
1018
1024
  .create_offer(Some(RTCOfferOptions {
@@ -1147,6 +1153,7 @@ fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1147
1153
  #[cfg(test)]
1148
1154
  pub(crate) mod tests {
1149
1155
  use super::*;
1156
+ use webrtc::media_stream::track_remote::{TrackRemote, TrackRemoteEvent};
1150
1157
 
1151
1158
  #[test]
1152
1159
  fn a_standing_queue_is_measured_and_one_frames_burst_is_not() {
@@ -1583,6 +1590,229 @@ pub(crate) mod tests {
1583
1590
  .expect("the restart answer is accepted");
1584
1591
  }
1585
1592
 
1593
+ /// A receiver peer that records when each inbound RTP packet arrives and
1594
+ /// reports its own ICE candidates, so two peers can really connect.
1595
+ struct MediaReceiver {
1596
+ candidates: tokio::sync::mpsc::UnboundedSender<RTCIceCandidateInit>,
1597
+ arrivals: Arc<Mutex<Vec<Instant>>>,
1598
+ connected: Arc<AtomicBool>,
1599
+ }
1600
+
1601
+ #[async_trait::async_trait]
1602
+ impl PeerConnectionEventHandler for MediaReceiver {
1603
+ async fn on_ice_candidate(&self, event: RTCPeerConnectionIceEvent) {
1604
+ if let Ok(init) = event.candidate.to_json() {
1605
+ let _ = self.candidates.send(init);
1606
+ }
1607
+ }
1608
+
1609
+ async fn on_connection_state_change(&self, state: RTCPeerConnectionState) {
1610
+ self.connected.store(
1611
+ matches!(state, RTCPeerConnectionState::Connected),
1612
+ Ordering::SeqCst,
1613
+ );
1614
+ }
1615
+
1616
+ async fn on_track(&self, track: Arc<dyn TrackRemote>) {
1617
+ let arrivals = Arc::clone(&self.arrivals);
1618
+ tokio::spawn(async move {
1619
+ while let Some(event) = track.poll().await {
1620
+ if matches!(event, TrackRemoteEvent::OnRtpPacket(_)) {
1621
+ arrivals.lock().unwrap().push(Instant::now());
1622
+ }
1623
+ }
1624
+ });
1625
+ }
1626
+ }
1627
+
1628
+ async fn media_receiver(receiver: Arc<MediaReceiver>) -> Arc<dyn PeerConnection> {
1629
+ let mut media_engine = MediaEngine::default();
1630
+ media_engine
1631
+ .register_codec(vp9_codec(), RtpCodecKind::Video)
1632
+ .expect("the codec registers");
1633
+ let registry = register_default_interceptors(Registry::new(), &mut media_engine)
1634
+ .expect("interceptors register");
1635
+ let peer = PeerConnectionBuilder::<std::net::SocketAddr>::new()
1636
+ .with_media_engine(media_engine)
1637
+ .with_interceptor_registry(registry)
1638
+ .with_runtime(default_runtime().expect("a runtime"))
1639
+ .with_handler(receiver)
1640
+ .with_udp_addrs(vec![std::net::SocketAddr::from(([0, 0, 0, 0], 0))])
1641
+ .build()
1642
+ .await
1643
+ .expect("a receiving peer");
1644
+ Arc::new(peer)
1645
+ }
1646
+
1647
+ /// A restart offer/answer round must not interrupt media already flowing on
1648
+ /// the selected pair: the picture keeps advancing (no gap over 200 ms) while
1649
+ /// the old pair stays reachable, and the ICE generation still turns over.
1650
+ #[tokio::test]
1651
+ async fn a_restart_keeps_media_flowing_on_the_selected_pair_until_the_new_one_nominates() {
1652
+ let (events, mut events_rx) = tokio::sync::mpsc::unbounded_channel();
1653
+ let (engine, offer) = VideoPeer::offer(transport(), events, VideoCodec::Vp9)
1654
+ .await
1655
+ .expect("an offering peer");
1656
+ let engine = Arc::new(engine);
1657
+
1658
+ let arrivals: Arc<Mutex<Vec<Instant>>> = Arc::new(Mutex::new(Vec::new()));
1659
+ let connected = Arc::new(AtomicBool::new(false));
1660
+ let (client_candidates_tx, mut client_candidates_rx) =
1661
+ tokio::sync::mpsc::unbounded_channel();
1662
+ let receiver = Arc::new(MediaReceiver {
1663
+ candidates: client_candidates_tx,
1664
+ arrivals: Arc::clone(&arrivals),
1665
+ connected: Arc::clone(&connected),
1666
+ });
1667
+ let client = media_receiver(Arc::clone(&receiver)).await;
1668
+
1669
+ client
1670
+ .set_remote_description(RTCSessionDescription::offer(offer.clone()).expect("an offer"))
1671
+ .await
1672
+ .expect("the offer applies");
1673
+ let answer = client.create_answer(None).await.expect("an answer");
1674
+ client
1675
+ .set_local_description(answer.clone())
1676
+ .await
1677
+ .expect("a local answer");
1678
+ engine
1679
+ .accept_answer(answer.sdp.clone())
1680
+ .await
1681
+ .expect("the answer is accepted");
1682
+
1683
+ // The engine's candidates reach the client.
1684
+ let client_for_engine_candidates = Arc::clone(&client);
1685
+ tokio::spawn(async move {
1686
+ while let Some(event) = events_rx.recv().await {
1687
+ if let PeerEvent::Candidate {
1688
+ candidate,
1689
+ sdp_mid,
1690
+ sdp_m_line_index,
1691
+ } = event
1692
+ {
1693
+ let _ = client_for_engine_candidates
1694
+ .add_ice_candidate(RTCIceCandidateInit {
1695
+ candidate,
1696
+ sdp_mid,
1697
+ sdp_mline_index: sdp_m_line_index,
1698
+ username_fragment: None,
1699
+ url: None,
1700
+ })
1701
+ .await;
1702
+ }
1703
+ }
1704
+ });
1705
+ // The client's candidates reach the engine.
1706
+ let engine_for_client_candidates = Arc::clone(&engine);
1707
+ tokio::spawn(async move {
1708
+ while let Some(init) = client_candidates_rx.recv().await {
1709
+ let _ = engine_for_client_candidates
1710
+ .add_candidate(init.candidate, init.sdp_mid, init.sdp_mline_index)
1711
+ .await;
1712
+ }
1713
+ });
1714
+
1715
+ // Wait for the ICE path to come up on both ends.
1716
+ let deadline = Instant::now() + Duration::from_secs(5);
1717
+ while !(engine.is_connected() && connected.load(Ordering::SeqCst)) {
1718
+ assert!(Instant::now() < deadline, "the peers never connected");
1719
+ tokio::time::sleep(Duration::from_millis(10)).await;
1720
+ }
1721
+
1722
+ // Any bytes the VP9 packetizer turns into one access unit.
1723
+ let frame: Vec<u8> = (0..200u32).map(|i| (i % 251) as u8).collect();
1724
+ let sending = Arc::new(AtomicBool::new(true));
1725
+ let engine_for_sending = Arc::clone(&engine);
1726
+ let sending_for_task = Arc::clone(&sending);
1727
+ let frame_for_task = frame.clone();
1728
+ tokio::spawn(async move {
1729
+ while sending_for_task.load(Ordering::SeqCst) {
1730
+ let _ = engine_for_sending
1731
+ .send_frame(&frame_for_task, true, Instant::now())
1732
+ .await;
1733
+ tokio::time::sleep(Duration::from_millis(5)).await;
1734
+ }
1735
+ });
1736
+
1737
+ // Let the picture flow, then take a clean baseline.
1738
+ tokio::time::sleep(Duration::from_millis(300)).await;
1739
+ arrivals.lock().unwrap().clear();
1740
+ tokio::time::sleep(Duration::from_millis(200)).await;
1741
+ let flowing = arrivals.lock().unwrap().len();
1742
+ assert!(
1743
+ flowing >= 5,
1744
+ "media was not flowing before the restart: {flowing} packets"
1745
+ );
1746
+
1747
+ // The restart a client drives when its carrier comes back on a roam.
1748
+ let restart = engine.restart_ice().await.expect("a restart offer");
1749
+ let ufrag = |sdp: &str| {
1750
+ sdp.lines()
1751
+ .find_map(|line| line.strip_prefix("a=ice-ufrag:"))
1752
+ .expect("ICE credentials")
1753
+ .to_owned()
1754
+ };
1755
+ assert_ne!(
1756
+ ufrag(&restart),
1757
+ ufrag(&offer),
1758
+ "the restart must turn the ICE generation over"
1759
+ );
1760
+
1761
+ let restart_at = Instant::now();
1762
+ arrivals.lock().unwrap().clear();
1763
+
1764
+ // The restart offer crosses the consumer's signaling channel before the
1765
+ // client answers, and the engine applies the restart at
1766
+ // `set_local_description` — inside `restart_ice` above, before the offer
1767
+ // has even left. It must keep feeding media on the old pair for that
1768
+ // whole round trip, not only until its own teardown. A real channel
1769
+ // costs more than a loopback hop, so hold this window for the bar's
1770
+ // length: a teardown shows up here, a preserved pair does not.
1771
+ tokio::time::sleep(Duration::from_millis(300)).await;
1772
+
1773
+ client
1774
+ .set_remote_description(RTCSessionDescription::offer(restart).expect("a restart offer"))
1775
+ .await
1776
+ .expect("the restart offer applies");
1777
+ let reanswer = client.create_answer(None).await.expect("an answer");
1778
+ client
1779
+ .set_local_description(reanswer.clone())
1780
+ .await
1781
+ .expect("a local answer");
1782
+ engine
1783
+ .accept_answer(reanswer.sdp)
1784
+ .await
1785
+ .expect("the restart answer is accepted");
1786
+
1787
+ // Keep the picture advancing across and after the restart.
1788
+ tokio::time::sleep(Duration::from_millis(800)).await;
1789
+ sending.store(false, Ordering::SeqCst);
1790
+
1791
+ let stamps = arrivals.lock().unwrap().clone();
1792
+ assert!(
1793
+ !stamps.is_empty(),
1794
+ "no media arrived at all after the restart"
1795
+ );
1796
+ let mut previous = restart_at;
1797
+ let mut worst = Duration::ZERO;
1798
+ for stamp in &stamps {
1799
+ worst = worst.max(stamp.duration_since(previous));
1800
+ previous = *stamp;
1801
+ }
1802
+ eprintln!(
1803
+ "restart: {} packets after the offer, worst media gap {worst:?}",
1804
+ stamps.len()
1805
+ );
1806
+ assert!(
1807
+ worst < Duration::from_millis(200),
1808
+ "media stalled for {worst:?} through the restart: {:?}",
1809
+ stamps
1810
+ );
1811
+
1812
+ engine.close().await;
1813
+ let _ = client.close().await;
1814
+ }
1815
+
1586
1816
  #[tokio::test]
1587
1817
  async fn a_forwarded_client_is_offered_ice_over_tcp_on_the_loopback() {
1588
1818
  let offered = |loopback_tcp: bool| async move {
@@ -142,21 +142,3 @@ fn choose_cursor_mode(available: BitFlags<CursorMode>) -> Result<CursorMode> {
142
142
  .find(|mode| available.contains(*mode))
143
143
  .context("cursor-unavailable: portal cannot leave the cursor out of capture")
144
144
  }
145
-
146
- #[cfg(test)]
147
- mod tests {
148
- use super::*;
149
- #[test]
150
- fn hidden_takes_any_cursor_free_mode_and_refuses_embedded_only_portals() {
151
- assert_eq!(
152
- choose_cursor_mode(CursorMode::Metadata | CursorMode::Hidden).unwrap(),
153
- CursorMode::Hidden
154
- );
155
- assert_eq!(
156
- choose_cursor_mode(CursorMode::Metadata.into()).unwrap(),
157
- CursorMode::Metadata
158
- );
159
- let error = choose_cursor_mode(CursorMode::Embedded.into()).unwrap_err();
160
- assert!(error.to_string().contains("cursor-unavailable:"));
161
- }
162
- }
@@ -395,168 +395,6 @@ pub enum ControlReply<'a> {
395
395
  },
396
396
  }
397
397
 
398
- #[cfg(test)]
399
- mod tests {
400
- use super::*;
401
-
402
- #[test]
403
- fn cursor_option_defaults_and_refuses_unknown_modes() {
404
- let default: OpenParams = serde_json::from_str("{}").unwrap();
405
- assert_eq!(default.cursor, CursorMode::Embedded);
406
- let hidden: OpenParams = serde_json::from_str(r#"{"cursor":"hidden"}"#).unwrap();
407
- assert_eq!(hidden.cursor, CursorMode::Hidden);
408
- assert!(serde_json::from_str::<OpenParams>(r#"{"cursor":"metadata"}"#).is_err());
409
- assert!(serde_json::from_str::<OpenParams>(r#"{"cursor":false}"#).is_err());
410
- }
411
-
412
- #[test]
413
- fn a_pointer_message_round_trips_with_its_sequence() {
414
- let parsed: ControlMessage =
415
- serde_json::from_str(r#"{"kind":"pointer","phase":"down","x":10,"y":20,"seq":3}"#)
416
- .unwrap();
417
- match parsed {
418
- ControlMessage::Pointer {
419
- phase,
420
- x,
421
- y,
422
- seq,
423
- button,
424
- } => {
425
- assert!(matches!(phase, PointerPhase::Down));
426
- assert_eq!((x, y, seq, button), (10, 20, 3, 1));
427
- }
428
- other => panic!("wrong variant: {other:?}"),
429
- }
430
- }
431
-
432
- #[test]
433
- fn an_unknown_action_is_refused_rather_than_ignored() {
434
- assert!(serde_json::from_str::<ControlMessage>(r#"{"kind":"teleport"}"#).is_err());
435
- }
436
-
437
- #[test]
438
- fn display_source_round_trips_its_explicit_id() {
439
- let request: SourceRequest = serde_json::from_value(serde_json::json!({
440
- "kind": "display",
441
- "display_id": 42
442
- }))
443
- .unwrap();
444
- assert!(matches!(
445
- request,
446
- SourceRequest::Display {
447
- display_id: Some(42)
448
- }
449
- ));
450
- }
451
-
452
- #[test]
453
- fn an_encoded_source_names_its_codec_and_its_own_size() {
454
- let request: SourceRequest = serde_json::from_value(serde_json::json!({
455
- "kind": "encoded",
456
- "codec": "h264",
457
- "width": 486,
458
- "height": 1080
459
- }))
460
- .unwrap();
461
- assert!(matches!(
462
- request,
463
- SourceRequest::Encoded {
464
- codec: EncodedCodec::H264,
465
- width: 486,
466
- height: 1080
467
- }
468
- ));
469
- }
470
-
471
- #[test]
472
- fn an_unknown_encoded_codec_is_refused_rather_than_guessed() {
473
- assert!(serde_json::from_value::<SourceRequest>(serde_json::json!({
474
- "kind": "encoded",
475
- "codec": "av1",
476
- "width": 486,
477
- "height": 1080
478
- }))
479
- .is_err());
480
- }
481
-
482
- #[test]
483
- fn a_feed_request_is_one_access_unit_with_its_own_keyframe_flag() {
484
- let params: FeedParams =
485
- serde_json::from_str(r#"{"session_id":"s","keyframe":true,"data_b64":"AAAA"}"#)
486
- .unwrap();
487
- assert_eq!(params.session_id, "s");
488
- assert!(params.keyframe, "a feed says whether its unit is an IDR");
489
- assert_eq!(params.data_b64, "AAAA");
490
- assert_eq!(params.access_unit().unwrap(), vec![0x00, 0x00, 0x00]);
491
- }
492
-
493
- #[test]
494
- fn a_feed_request_that_is_not_base64_is_refused() {
495
- let params = FeedParams {
496
- session_id: String::from("s"),
497
- keyframe: false,
498
- data_b64: String::from("not base64!!"),
499
- };
500
- assert!(params.access_unit().is_err());
501
- }
502
-
503
- /// The reason this protocol carries bytes in base64 rather than adding a
504
- /// second inherited fd: at 2 Mbit/s it costs a fraction of a per cent of a
505
- /// core, against a whole second channel to keep ordered and bounded. The
506
- /// bound below is deliberately loose — it is a sanity check on the order of
507
- /// magnitude, not a benchmark, so it does not flake on a loaded machine.
508
- #[test]
509
- fn decoding_a_two_megabit_stream_is_nowhere_near_a_core() {
510
- // One second of a 2 Mbit/s stream, in access units.
511
- let unit: Vec<u8> = (0..8_000u32).map(|index| index as u8).collect();
512
- let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &unit);
513
- let params = FeedParams {
514
- session_id: String::from("s"),
515
- keyframe: false,
516
- data_b64: encoded,
517
- };
518
- let started = std::time::Instant::now();
519
- for _ in 0..(250_000 / unit.len()) {
520
- assert_eq!(params.access_unit().unwrap().len(), unit.len());
521
- }
522
- let elapsed = started.elapsed();
523
- assert!(
524
- elapsed < std::time::Duration::from_millis(500),
525
- "a second of 2 Mbit/s base64 took {elapsed:?}, which is far past 5 % of a core",
526
- );
527
- println!("a second of 2 Mbit/s stream decoded in {elapsed:?}");
528
- }
529
-
530
- #[test]
531
- fn open_defaults_keep_the_desktops_own_pixels_up_to_4k() {
532
- for payload in [
533
- r#"{}"#,
534
- r#"{"permissions":["view"]}"#,
535
- r#"{"permissions":["view","unknown-legacy-scope"]}"#,
536
- ] {
537
- let _: OpenParams = serde_json::from_str(payload).unwrap();
538
- }
539
- let params: OpenParams =
540
- serde_json::from_str(r#"{"permissions":["view","control"]}"#).unwrap();
541
- assert_eq!(params.max_width, 3840);
542
- assert_eq!(params.max_height, 2160);
543
- assert_eq!(
544
- params.bitrate_kbps, 0,
545
- "the engine sizes the rate to the surface"
546
- );
547
- assert_eq!(params.max_fps, 60);
548
- assert!(params.local_frames, "session.frame works unless turned off");
549
- assert!(
550
- !params.agent_indicator,
551
- "human control has no overlay by default"
552
- );
553
- let agent: OpenParams =
554
- serde_json::from_str(r#"{"permissions":["view","control"],"agent_indicator":true}"#)
555
- .unwrap();
556
- assert!(agent.agent_indicator);
557
- }
558
- }
559
-
560
398
  /// Monotonic engine-process clock shared by local frame and cursor notices.
561
399
  pub fn timestamp_us(at: std::time::Instant) -> u64 {
562
400
  static EPOCH: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
@@ -1875,7 +1875,7 @@ impl Session {
1875
1875
  .ok_or_else(|| anyhow::anyhow!("no offer has been made for this session yet"))
1876
1876
  }
1877
1877
 
1878
- /// Restart ICE: a fresh offer on the same peer, announced as a new
1878
+ /// Restart ICE: a pending or fresh offer on the same peer, announced as a new
1879
1879
  /// `session.description` event for the client to answer as usual.
1880
1880
  pub async fn restart_ice(&self) -> Result<()> {
1881
1881
  let offer = self.current_peer()?.restart_ice().await?;
@@ -4243,6 +4243,42 @@ mod tests {
4243
4243
  );
4244
4244
  }
4245
4245
 
4246
+ #[tokio::test]
4247
+ async fn three_queued_ice_restarts_reemit_one_offer_and_accept_its_answer() {
4248
+ let codec = crate::peer::tests::h264_receiver_codec();
4249
+ let (peer, _) = crate::peer::tests::answered(vec![codec.clone()]).await;
4250
+ let (events, mut received) = tokio_mpsc::unbounded_channel();
4251
+ let (inner, _) = test_inner_with(events, peer, 64, 64);
4252
+ let session = Session { inner };
4253
+ let mut offers = Vec::new();
4254
+ for _ in 0..3 {
4255
+ session.restart_ice().await.expect("restart accepted");
4256
+ match received.recv().await.expect("offer event").event {
4257
+ SessionEvent::Description { sdp, .. } => offers.push(sdp),
4258
+ _ => panic!("expected the pending offer"),
4259
+ }
4260
+ }
4261
+ let ufrag = |sdp: &str| {
4262
+ sdp.lines()
4263
+ .find_map(|line| line.strip_prefix("a=ice-ufrag:"))
4264
+ .expect("ICE credentials")
4265
+ .to_owned()
4266
+ };
4267
+ assert!(offers.iter().all(|sdp| ufrag(sdp) == ufrag(&offers[0])));
4268
+ let client = crate::peer::tests::answerer_with(&offers[0], vec![codec]).await;
4269
+ let answer = client.create_answer(None).await.expect("client answer");
4270
+ client
4271
+ .set_local_description(answer.clone())
4272
+ .await
4273
+ .expect("local answer");
4274
+ session
4275
+ .accept_answer(answer.sdp)
4276
+ .await
4277
+ .expect("pending answer accepted");
4278
+ client.close().await.expect("close client");
4279
+ session.current_peer().unwrap().close().await;
4280
+ }
4281
+
4246
4282
  #[tokio::test]
4247
4283
  async fn a_lost_transport_releases_input_then_ends_the_session_without_sctp_close() {
4248
4284
  use webrtc::peer_connection::RTCPeerConnectionState as State;