@desklink/host 0.1.0 → 0.2.0

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 (55) hide show
  1. package/NOTICE +3 -0
  2. package/README.md +72 -16
  3. package/bin/desklink-host.mjs +14 -3
  4. package/dist/bridge.d.ts.map +1 -1
  5. package/dist/bridge.js +5 -1
  6. package/dist/bridge.js.map +1 -1
  7. package/dist/engineProcess.d.ts.map +1 -1
  8. package/dist/engineProcess.js +17 -5
  9. package/dist/engineProcess.js.map +1 -1
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/keeper.d.ts +52 -0
  15. package/dist/keeper.d.ts.map +1 -0
  16. package/dist/keeper.js +101 -0
  17. package/dist/keeper.js.map +1 -0
  18. package/dist/protocol.d.ts +42 -5
  19. package/dist/protocol.d.ts.map +1 -1
  20. package/dist/protocol.js +1 -1
  21. package/dist/protocol.js.map +1 -1
  22. package/dist/resolveEngine.d.ts +3 -2
  23. package/dist/resolveEngine.d.ts.map +1 -1
  24. package/dist/resolveEngine.js +20 -5
  25. package/dist/resolveEngine.js.map +1 -1
  26. package/docs/BOUNDARY.md +11 -6
  27. package/docs/PROTOCOL.md +297 -33
  28. package/engine/Cargo.lock +196 -1
  29. package/engine/Cargo.toml +26 -5
  30. package/engine/build.rs +98 -23
  31. package/engine/native/agent_overlay_mac.m +133 -0
  32. package/engine/native/agent_overlay_wayland.c +192 -0
  33. package/engine/native/agent_overlay_x11.c +103 -0
  34. package/engine/native/mac_stream.mm +188 -0
  35. package/engine/src/capture.rs +169 -19
  36. package/engine/src/capture_mac.rs +145 -0
  37. package/engine/src/clipboard_mac.rs +182 -0
  38. package/engine/src/convert.rs +57 -1
  39. package/engine/src/indicator.rs +162 -0
  40. package/engine/src/input.rs +12 -0
  41. package/engine/src/input_mac.rs +312 -0
  42. package/engine/src/keeper.rs +622 -0
  43. package/engine/src/keymap.rs +25 -0
  44. package/engine/src/keymap_mac.rs +209 -0
  45. package/engine/src/mac.rs +722 -0
  46. package/engine/src/main.rs +158 -2
  47. package/engine/src/peer.rs +429 -22
  48. package/engine/src/portal.rs +4 -0
  49. package/engine/src/portal_mac.rs +68 -0
  50. package/engine/src/protocol.rs +186 -3
  51. package/engine/src/session.rs +1307 -100
  52. package/engine/src/x11.rs +23 -3
  53. package/engine/src/x11_mac.rs +40 -0
  54. package/examples/reference-client.html +33 -4
  55. package/package.json +2 -2
@@ -16,7 +16,9 @@ use rtc::interceptor::{
16
16
  };
17
17
  use rtc::media_stream::MediaStreamTrack;
18
18
  use rtc::peer_connection::configuration::interceptor_registry::register_default_interceptors;
19
- use rtc::peer_connection::configuration::media_engine::{MediaEngine, MIME_TYPE_VP9};
19
+ use rtc::peer_connection::configuration::media_engine::{
20
+ MediaEngine, MIME_TYPE_H264, MIME_TYPE_VP9,
21
+ };
20
22
  use rtc::peer_connection::configuration::RTCConfigurationBuilder;
21
23
  use rtc::peer_connection::event::RTCPeerConnectionIceEvent;
22
24
  use rtc::peer_connection::sdp::RTCSessionDescription;
@@ -25,8 +27,8 @@ use rtc::rtcp::payload_feedbacks::full_intra_request::FullIntraRequest;
25
27
  use rtc::rtcp::payload_feedbacks::picture_loss_indication::PictureLossIndication;
26
28
  use rtc::rtcp::receiver_report::ReceiverReport;
27
29
  use rtc::rtp_transceiver::rtp_sender::{
28
- RTCRtpCodec, RTCRtpCodecParameters, RTCRtpCodingParameters, RTCRtpEncodingParameters,
29
- RtpCodecKind,
30
+ RTCPFeedback, RTCRtpCodec, RTCRtpCodecParameters, RTCRtpCodingParameters,
31
+ RTCRtpEncodingParameters, RtpCodecKind,
30
32
  };
31
33
  use rtc::rtp_transceiver::PayloadType;
32
34
  use rtc::sansio::Protocol;
@@ -49,6 +51,23 @@ const VP9_CLOCK_RATE: u32 = 90_000;
49
51
  /// this one is the common convention and keeps packet captures readable.
50
52
  pub const VP9_PAYLOAD_TYPE: PayloadType = 98;
51
53
 
54
+ /// H.264's RTP clock is 90 kHz (RFC 6184), the same as VP9's.
55
+ const H264_CLOCK_RATE: u32 = 90_000;
56
+
57
+ /// Payload type the engine offers H.264 on, again a dynamic-range convention.
58
+ pub const H264_PAYLOAD_TYPE: PayloadType = 102;
59
+
60
+ /// Which video track this peer carries.
61
+ #[derive(Debug, Clone, PartialEq, Eq)]
62
+ pub enum VideoCodec {
63
+ /// VP9 in flexible mode, encoded by this engine from captured frames.
64
+ Vp9,
65
+ /// H.264 the consumer encoded and feeds. The profile-level-id is read from
66
+ /// the fed stream's own SPS, so a receiver's decoder is configured for the
67
+ /// profile actually arriving rather than for one the engine guessed.
68
+ H264 { profile_level_id: String },
69
+ }
70
+
52
71
  fn vp9_codec() -> RTCRtpCodecParameters {
53
72
  RTCRtpCodecParameters {
54
73
  rtp_codec: RTCRtpCodec {
@@ -63,6 +82,91 @@ fn vp9_codec() -> RTCRtpCodecParameters {
63
82
  }
64
83
  }
65
84
 
85
+ /// H.264 with `packetization-mode=1`, which is what the device helpers emit and
86
+ /// what every WebRTC receiver implements. Key frame requests are negotiated so
87
+ /// the receiver has a defined way to ask; the engine turns one into a
88
+ /// `session.keyframeRequest` for the consumer that owns the encoder.
89
+ fn h264_codec(profile_level_id: &str) -> RTCRtpCodecParameters {
90
+ RTCRtpCodecParameters {
91
+ rtp_codec: RTCRtpCodec {
92
+ mime_type: MIME_TYPE_H264.to_owned(),
93
+ clock_rate: H264_CLOCK_RATE,
94
+ channels: 0,
95
+ sdp_fmtp_line: format!(
96
+ "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id={profile_level_id}"
97
+ ),
98
+ rtcp_feedback: [("nack", ""), ("nack", "pli"), ("ccm", "fir")]
99
+ .into_iter()
100
+ .map(|(typ, parameter)| RTCPFeedback {
101
+ typ: typ.to_owned(),
102
+ parameter: parameter.to_owned(),
103
+ })
104
+ .collect(),
105
+ },
106
+ payload_type: H264_PAYLOAD_TYPE,
107
+ ..Default::default()
108
+ }
109
+ }
110
+
111
+ /// The `profile-level-id` an H.264 Annex-B stream declares, as the three bytes
112
+ /// after an SPS NAL header: `profile_idc`, the constraint flags, `level_idc`.
113
+ ///
114
+ /// This is the value the offer must carry, and it can only come from the stream
115
+ /// itself, so a peer for a fed stream is built after the first access unit
116
+ /// rather than at `session.open`.
117
+ pub fn h264_profile_level_id(annex_b: &[u8]) -> Option<String> {
118
+ for nalu in AnnexBNalUnits::new(annex_b) {
119
+ let Some(&first) = nalu.first() else {
120
+ continue;
121
+ };
122
+ if first & 0x1f == SPS_NAL_TYPE && nalu.len() >= 4 {
123
+ return Some(format!("{:02x}{:02x}{:02x}", nalu[1], nalu[2], nalu[3]));
124
+ }
125
+ }
126
+ None
127
+ }
128
+
129
+ /// NAL type 7: sequence parameter set.
130
+ const SPS_NAL_TYPE: u8 = 7;
131
+
132
+ /// A byte stream's NAL units, delimited by the Annex-B start code
133
+ /// (`00 00 01`, optionally preceded by zero bytes).
134
+ struct AnnexBNalUnits<'a> {
135
+ rest: &'a [u8],
136
+ }
137
+
138
+ impl<'a> AnnexBNalUnits<'a> {
139
+ fn new(stream: &'a [u8]) -> Self {
140
+ Self { rest: stream }
141
+ }
142
+
143
+ fn start_code(stream: &[u8]) -> Option<usize> {
144
+ stream
145
+ .windows(3)
146
+ .position(|window| window == [0x00, 0x00, 0x01])
147
+ }
148
+ }
149
+
150
+ impl<'a> Iterator for AnnexBNalUnits<'a> {
151
+ type Item = &'a [u8];
152
+
153
+ fn next(&mut self) -> Option<Self::Item> {
154
+ let start = Self::start_code(self.rest)? + 3;
155
+ let body = &self.rest[start..];
156
+ let end = Self::start_code(body).map_or(body.len(), |next| {
157
+ // A four-byte start code includes the zero the three-byte search
158
+ // left at the end of the previous unit.
159
+ if next > 0 && body[next - 1] == 0x00 {
160
+ next - 1
161
+ } else {
162
+ next
163
+ }
164
+ });
165
+ self.rest = &body[end..];
166
+ Some(&body[..end])
167
+ }
168
+ }
169
+
66
170
  /// What the consumer needs to know about a live peer.
67
171
  #[derive(Debug, Clone)]
68
172
  pub enum PeerEvent {
@@ -80,9 +184,13 @@ pub enum PeerEvent {
80
184
  ControlClosed,
81
185
  /// A control message arrived from the client.
82
186
  ControlMessage(String),
187
+ /// The far end asked for a fresh reference frame (RTCP PLI or FIR) on a
188
+ /// track this engine does not encode itself, so the consumer must supply one.
189
+ KeyframeRequest,
83
190
  }
84
191
 
85
192
  /// Transport settings the consumer chose for this session.
193
+ #[derive(Debug, Clone)]
86
194
  pub struct TransportOptions {
87
195
  pub ice_servers: Vec<(String, Option<String>, Option<String>)>,
88
196
  /// Listen for ICE over TCP on the loopback too. A phone that reaches this
@@ -116,21 +224,33 @@ async fn serve_control(
116
224
  channel: Arc<dyn DataChannel>,
117
225
  events: tokio::sync::mpsc::UnboundedSender<PeerEvent>,
118
226
  ) {
119
- while let Some(event) = channel.poll().await {
120
- match event {
121
- DataChannelEvent::OnOpen => {
122
- let _ = events.send(PeerEvent::ControlOpen);
123
- }
124
- DataChannelEvent::OnMessage(message) => {
125
- if let Ok(text) = String::from_utf8(message.data.to_vec()) {
126
- let _ = events.send(PeerEvent::ControlMessage(text));
227
+ // A heartbeat the client watches: ICE consent timers take seconds to call
228
+ // a dead path disconnected, while a missing heartbeat reads as stalled
229
+ // within about a second. Cheap on purpose — seventeen bytes twice a second.
230
+ let mut ping = tokio::time::interval(std::time::Duration::from_millis(500));
231
+ loop {
232
+ tokio::select! {
233
+ event = channel.poll() => {
234
+ match event {
235
+ Some(DataChannelEvent::OnOpen) => {
236
+ let _ = events.send(PeerEvent::ControlOpen);
237
+ }
238
+ Some(DataChannelEvent::OnMessage(message)) => {
239
+ if let Ok(text) = String::from_utf8(message.data.to_vec()) {
240
+ let _ = events.send(PeerEvent::ControlMessage(text));
241
+ }
242
+ }
243
+ Some(DataChannelEvent::OnClose) => {
244
+ let _ = events.send(PeerEvent::ControlClosed);
245
+ break;
246
+ }
247
+ Some(_) => {}
248
+ None => break,
127
249
  }
128
250
  }
129
- DataChannelEvent::OnClose => {
130
- let _ = events.send(PeerEvent::ControlClosed);
131
- break;
251
+ _ = ping.tick() => {
252
+ let _ = channel.send_text(r#"{"kind":"ping"}"#).await;
132
253
  }
133
- _ => {}
134
254
  }
135
255
  }
136
256
  let _ = events.send(PeerEvent::ControlClosed);
@@ -231,12 +351,14 @@ impl Interceptor for FeedbackForwarder {
231
351
 
232
352
  /// Read the video sender's feedback until the peer closes it (by aborting this
233
353
  /// task: the track's feedback channel outlives a closed peer): key frame
234
- /// requests set `wants_keyframe`, receiver reports queue their loss fraction.
354
+ /// requests set `wants_keyframe`, receiver reports queue their loss fraction,
355
+ /// and a track whose encoder lives in the consumer is told to ask for one.
235
356
  async fn serve_feedback(
236
357
  track: Arc<TrackLocalStaticRTP>,
237
358
  ssrc: u32,
238
359
  wants_keyframe: Arc<AtomicBool>,
239
360
  loss: Arc<Mutex<Vec<u8>>>,
361
+ keyframe_requests: Option<tokio::sync::mpsc::UnboundedSender<PeerEvent>>,
240
362
  ) {
241
363
  loop {
242
364
  // The track is bound once the answer is applied; until then, and after
@@ -249,6 +371,9 @@ async fn serve_feedback(
249
371
  let packet = packet.as_any();
250
372
  if packet.is::<PictureLossIndication>() || packet.is::<FullIntraRequest>() {
251
373
  wants_keyframe.store(true, Ordering::SeqCst);
374
+ if let Some(requests) = &keyframe_requests {
375
+ let _ = requests.send(PeerEvent::KeyframeRequest);
376
+ }
252
377
  } else if let Some(report) = packet.downcast_ref::<ReceiverReport>() {
253
378
  let mut queue = lock(&loss);
254
379
  queue.extend(
@@ -270,6 +395,102 @@ async fn serve_feedback(
270
395
  const RTP_MTU: usize = 1200;
271
396
  const RTP_HEADER: usize = 12;
272
397
 
398
+ /// One encoded track's packetizer. The engine builds RTP headers itself either
399
+ /// way — the payloaders below only decide how a frame's bytes are split — so the
400
+ /// codec only changes which ones run.
401
+ enum Packetizer {
402
+ Vp9(Vp9Packetizer),
403
+ H264(H264Packetizer),
404
+ }
405
+
406
+ impl Packetizer {
407
+ fn packetize(
408
+ &mut self,
409
+ frame: &[u8],
410
+ keyframe: bool,
411
+ captured: Instant,
412
+ ssrc: u32,
413
+ payload_type: PayloadType,
414
+ ) -> Result<Vec<rtc::rtp::Packet>> {
415
+ match self {
416
+ Self::Vp9(packetizer) => {
417
+ Ok(packetizer.packetize(frame, keyframe, captured, ssrc, payload_type))
418
+ }
419
+ Self::H264(packetizer) => packetizer.packetize(frame, captured, ssrc, payload_type),
420
+ }
421
+ }
422
+ }
423
+
424
+ /// H.264 over RTP (RFC 6184), packetization mode 1: single NAL units, STAP-A for
425
+ /// the parameter sets, FU-A for anything over the MTU. The fragmentation itself
426
+ /// is webrtc-rs's own H.264 payloader (`RTCRtpCodec::payloader`), which already
427
+ /// implements those three payload shapes; only the RTP header is built here, so
428
+ /// the timestamp is the arrival time of the access unit rather than the moment
429
+ /// each fragment happened to be written.
430
+ struct H264Packetizer {
431
+ sequence: u16,
432
+ timestamp_base: u32,
433
+ started: Instant,
434
+ payloader: Box<dyn rtc::rtp::packetizer::Payloader>,
435
+ }
436
+
437
+ impl H264Packetizer {
438
+ fn new(payloader: Box<dyn rtc::rtp::packetizer::Payloader>) -> Self {
439
+ Self {
440
+ sequence: rand::random(),
441
+ timestamp_base: rand::random(),
442
+ started: Instant::now(),
443
+ payloader,
444
+ }
445
+ }
446
+
447
+ fn packetize(
448
+ &mut self,
449
+ access_unit: &[u8],
450
+ arrived: Instant,
451
+ ssrc: u32,
452
+ payload_type: PayloadType,
453
+ ) -> Result<Vec<rtc::rtp::Packet>> {
454
+ let payloads = self
455
+ .payloader
456
+ .payload(
457
+ RTP_MTU - RTP_HEADER,
458
+ &bytes::Bytes::copy_from_slice(access_unit),
459
+ )
460
+ .map_err(|error| {
461
+ anyhow::anyhow!("the H.264 payloader refused an access unit: {error}")
462
+ })?;
463
+ let ticks = (arrived
464
+ .saturating_duration_since(self.started)
465
+ .as_secs_f64()
466
+ * H264_CLOCK_RATE as f64) as u64 as u32;
467
+ let timestamp = self.timestamp_base.wrapping_add(ticks);
468
+ let last = payloads.len().saturating_sub(1);
469
+ Ok(payloads
470
+ .into_iter()
471
+ .enumerate()
472
+ .map(|(index, payload)| {
473
+ let sequence_number = self.sequence;
474
+ self.sequence = self.sequence.wrapping_add(1);
475
+ rtc::rtp::Packet {
476
+ header: rtc::rtp::Header {
477
+ version: 2,
478
+ // The marker ends an access unit, which is what a video
479
+ // receiver uses to hand a complete picture to its decoder.
480
+ marker: index == last,
481
+ payload_type,
482
+ sequence_number,
483
+ timestamp,
484
+ ssrc,
485
+ ..Default::default()
486
+ },
487
+ payload,
488
+ }
489
+ })
490
+ .collect())
491
+ }
492
+ }
493
+
273
494
  /// VP9 over RTP (RFC 9628) in flexible mode, one layer.
274
495
  ///
275
496
  /// The payload descriptor is what a receiver builds its reference graph from,
@@ -377,7 +598,7 @@ pub struct VideoPeer {
377
598
  /// True while the transport is connected: frames sent before it are lost.
378
599
  connected: Arc<AtomicBool>,
379
600
  track: Arc<TrackLocalStaticRTP>,
380
- packetizer: Mutex<Vp9Packetizer>,
601
+ packetizer: Mutex<Packetizer>,
381
602
  ssrc: u32,
382
603
  control: Arc<dyn DataChannel>,
383
604
  payload_type: PayloadType,
@@ -401,14 +622,18 @@ impl VideoPeer {
401
622
  pub async fn offer(
402
623
  options: TransportOptions,
403
624
  events: tokio::sync::mpsc::UnboundedSender<PeerEvent>,
625
+ codec: VideoCodec,
404
626
  ) -> Result<(Self, String)> {
405
627
  let runtime = default_runtime().context("no WebRTC runtime is enabled")?;
406
628
 
407
629
  let mut media_engine = MediaEngine::default();
408
- let video_codec = vp9_codec();
630
+ let video_codec = match &codec {
631
+ VideoCodec::Vp9 => vp9_codec(),
632
+ VideoCodec::H264 { profile_level_id } => h264_codec(profile_level_id),
633
+ };
409
634
  media_engine
410
635
  .register_codec(video_codec.clone(), RtpCodecKind::Video)
411
- .context("failed to offer VP9")?;
636
+ .with_context(|| format!("failed to offer {codec:?}"))?;
412
637
  let registry = register_default_interceptors(Registry::new(), &mut media_engine)?
413
638
  .with(
414
639
  Slot::Pacer,
@@ -437,6 +662,10 @@ impl VideoPeer {
437
662
  let wants_keyframe = Arc::new(AtomicBool::new(true));
438
663
  let connected = Arc::new(AtomicBool::new(false));
439
664
  let control_events = events.clone();
665
+ // A track this engine encodes itself needs no notification: the pipeline
666
+ // reads `wants_keyframe` directly. A fed track's encoder lives in the
667
+ // consumer, so a key frame request has to travel back as an event.
668
+ let keyframe_requests = matches!(codec, VideoCodec::H264 { .. }).then(|| events.clone());
440
669
  let peer = PeerConnectionBuilder::<std::net::SocketAddr>::new()
441
670
  .with_configuration(builder.build())
442
671
  .with_media_engine(media_engine)
@@ -502,8 +731,19 @@ impl VideoPeer {
502
731
  ssrc,
503
732
  wants_keyframe.clone(),
504
733
  loss.clone(),
734
+ keyframe_requests,
505
735
  ));
506
736
 
737
+ let packetizer = match &codec {
738
+ VideoCodec::Vp9 => Packetizer::Vp9(Vp9Packetizer::new()),
739
+ VideoCodec::H264 { .. } => Packetizer::H264(H264Packetizer::new(
740
+ video_codec
741
+ .rtp_codec
742
+ .payloader()
743
+ .context("the media engine has no H.264 payloader")?,
744
+ )),
745
+ };
746
+
507
747
  let offer = peer.create_offer(None).await?;
508
748
  peer.set_local_description(offer.clone()).await?;
509
749
 
@@ -513,7 +753,7 @@ impl VideoPeer {
513
753
  wants_keyframe,
514
754
  connected,
515
755
  track,
516
- packetizer: Mutex::new(Vp9Packetizer::new()),
756
+ packetizer: Mutex::new(packetizer),
517
757
  ssrc,
518
758
  control,
519
759
  payload_type,
@@ -547,6 +787,26 @@ impl VideoPeer {
547
787
  Ok(applied)
548
788
  }
549
789
 
790
+ /// Restart ICE on this peer: a fresh offer with a new ICE generation,
791
+ /// for the client to answer as usual. The engine stays the offerer, so
792
+ /// this is the same local-offer path as the initial negotiation.
793
+ pub async fn restart_ice(&self) -> Result<String> {
794
+ use rtc::peer_connection::configuration::RTCOfferOptions;
795
+ let offer = self
796
+ .peer
797
+ .create_offer(Some(RTCOfferOptions {
798
+ ice_restart: true,
799
+ ..Default::default()
800
+ }))
801
+ .await
802
+ .context("the peer would not offer an ICE restart")?;
803
+ self.peer
804
+ .set_local_description(offer.clone())
805
+ .await
806
+ .context("the peer refused its restart offer")?;
807
+ Ok(offer.sdp)
808
+ }
809
+
550
810
  pub async fn add_candidate(
551
811
  &self,
552
812
  candidate: String,
@@ -585,7 +845,8 @@ impl VideoPeer {
585
845
  }
586
846
 
587
847
  /// Packetize one encoded frame and hand it to the track, paced by the
588
- /// interceptor chain. `captured` is when its picture was taken.
848
+ /// interceptor chain. `captured` is when its picture was taken — for a fed
849
+ /// stream, when its access unit arrived.
589
850
  pub async fn send_frame(&self, data: &[u8], keyframe: bool, captured: Instant) -> Result<()> {
590
851
  let packets = lock(&self.packetizer).packetize(
591
852
  data,
@@ -593,7 +854,7 @@ impl VideoPeer {
593
854
  captured,
594
855
  self.ssrc,
595
856
  self.payload_type,
596
- );
857
+ )?;
597
858
  for packet in packets {
598
859
  self.track
599
860
  .write_rtp_with_extensions(packet, &[])
@@ -672,6 +933,98 @@ mod tests {
672
933
  Arc::new(peer)
673
934
  }
674
935
 
936
+ #[tokio::test]
937
+ async fn an_encoded_offer_carries_the_streams_own_profile_and_level() {
938
+ let (events, _events_rx) = tokio::sync::mpsc::unbounded_channel();
939
+ let (_peer, offer) = VideoPeer::offer(
940
+ TransportOptions {
941
+ ice_servers: Vec::new(),
942
+ loopback_tcp: false,
943
+ pace_bps: 20_000_000.0,
944
+ },
945
+ events,
946
+ VideoCodec::H264 {
947
+ profile_level_id: String::from("42c029"),
948
+ },
949
+ )
950
+ .await
951
+ .expect("a peer connection");
952
+ assert!(
953
+ offer.contains("H264/90000"),
954
+ "the encoded track must be offered as H.264: {offer}",
955
+ );
956
+ assert!(
957
+ offer.contains("packetization-mode=1;profile-level-id=42c029"),
958
+ "the offer must name the fed SPS's own profile-level-id: {offer}",
959
+ );
960
+ assert!(
961
+ !offer.contains("VP9"),
962
+ "an encoded source must not also offer the codec it cannot encode: {offer}",
963
+ );
964
+ }
965
+
966
+ #[test]
967
+ fn the_profile_level_id_comes_from_the_sps_in_the_stream() {
968
+ // A leading zero byte, a four-byte start code and a three-byte one, so
969
+ // the scanner is exercised on both forms.
970
+ let mut stream = vec![0x00, 0x00, 0x00, 0x01];
971
+ stream.extend_from_slice(&[0x67, 0x42, 0xc0, 0x29, 0x8c, 0x8d]);
972
+ stream.extend_from_slice(&[0x00, 0x00, 0x01, 0x68, 0xce, 0x3c, 0x80]);
973
+ stream.extend_from_slice(&[0x00, 0x00, 0x01, 0x65, 0x88, 0x84, 0x00]);
974
+ assert_eq!(
975
+ h264_profile_level_id(&stream).as_deref(),
976
+ Some("42c029"),
977
+ "profile_idc, constraint flags and level_idc, as the SDP needs them",
978
+ );
979
+ assert_eq!(
980
+ h264_profile_level_id(&[0x00, 0x00, 0x01, 0x65, 0x88]),
981
+ None,
982
+ "a stream with no SPS cannot start a session",
983
+ );
984
+ let mut adjacent = vec![0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01];
985
+ adjacent.extend_from_slice(&[0x67, 0x42, 0xc0, 0x29]);
986
+ assert_eq!(
987
+ h264_profile_level_id(&adjacent).as_deref(),
988
+ Some("42c029"),
989
+ "an empty NAL between start codes is skipped, not fatal to the scan",
990
+ );
991
+ }
992
+
993
+ #[test]
994
+ fn a_fed_keyframe_is_packetized_as_parameter_sets_then_a_slice() {
995
+ let mut stream = vec![0x00, 0x00, 0x00, 0x01];
996
+ stream.extend_from_slice(&[0x67, 0x42, 0xc0, 0x29, 0x8c, 0x8d]);
997
+ stream.extend_from_slice(&[0x00, 0x00, 0x01, 0x68, 0xce, 0x3c, 0x80]);
998
+ stream.extend_from_slice(&[0x00, 0x00, 0x01, 0x65, 0x88, 0x84, 0x00]);
999
+ let payloader = h264_codec("42c029")
1000
+ .rtp_codec
1001
+ .payloader()
1002
+ .expect("the media engine has an H.264 payloader");
1003
+ let mut packetizer = H264Packetizer::new(payloader);
1004
+ let packets = packetizer
1005
+ .packetize(&stream, Instant::now(), 7, H264_PAYLOAD_TYPE)
1006
+ .expect("a keyframe packetizes");
1007
+ assert_eq!(
1008
+ packets.len(),
1009
+ 2,
1010
+ "one STAP-A for the parameter sets, one slice"
1011
+ );
1012
+ assert_eq!(
1013
+ packets[0].payload[0], 0x78,
1014
+ "the parameter sets travel as a STAP-A",
1015
+ );
1016
+ assert_eq!(packets[1].payload[0], 0x65, "then the IDR slice itself");
1017
+ assert!(
1018
+ !packets[0].header.marker && packets[1].header.marker,
1019
+ "the marker ends the access unit, not the first fragment of it",
1020
+ );
1021
+ assert_eq!(
1022
+ packets[0].header.timestamp, packets[1].header.timestamp,
1023
+ "an access unit is one instant",
1024
+ );
1025
+ assert_eq!(packets[0].header.payload_type, H264_PAYLOAD_TYPE);
1026
+ }
1027
+
675
1028
  #[tokio::test]
676
1029
  async fn a_candidate_that_arrives_before_the_answer_is_not_refused() {
677
1030
  let (events, _events_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -682,6 +1035,7 @@ mod tests {
682
1035
  pace_bps: 20_000_000.0,
683
1036
  },
684
1037
  events,
1038
+ VideoCodec::Vp9,
685
1039
  )
686
1040
  .await
687
1041
  .expect("a peer connection");
@@ -707,6 +1061,7 @@ mod tests {
707
1061
  pace_bps: 20_000_000.0,
708
1062
  },
709
1063
  events,
1064
+ VideoCodec::Vp9,
710
1065
  )
711
1066
  .await
712
1067
  .expect("a peer connection");
@@ -733,6 +1088,57 @@ mod tests {
733
1088
  assert_eq!(applied, 1, "the held candidate must reach the peer");
734
1089
  }
735
1090
 
1091
+ #[tokio::test]
1092
+ async fn an_ice_restart_offers_fresh_credentials_on_the_same_peer() {
1093
+ let (events, _events_rx) = tokio::sync::mpsc::unbounded_channel();
1094
+ let (peer, offer) = VideoPeer::offer(
1095
+ TransportOptions {
1096
+ ice_servers: Vec::new(),
1097
+ loopback_tcp: false,
1098
+ pace_bps: 20_000_000.0,
1099
+ },
1100
+ events,
1101
+ VideoCodec::Vp9,
1102
+ )
1103
+ .await
1104
+ .expect("a peer connection");
1105
+
1106
+ let other = answerer(&offer).await;
1107
+ let answer = other.create_answer(None).await.expect("an answer");
1108
+ other
1109
+ .set_local_description(answer.clone())
1110
+ .await
1111
+ .expect("a local description");
1112
+ peer.accept_answer(answer.sdp)
1113
+ .await
1114
+ .expect("the answer is accepted");
1115
+
1116
+ let ufrag = |sdp: &str| {
1117
+ sdp.lines()
1118
+ .find_map(|line| line.strip_prefix("a=ice-ufrag:"))
1119
+ .expect("an ICE username fragment")
1120
+ .to_owned()
1121
+ };
1122
+ let restart = peer.restart_ice().await.expect("a restart offer");
1123
+ assert!(
1124
+ !restart.is_empty() && ufrag(&restart) != ufrag(&offer),
1125
+ "the restart must turn over the ICE generation"
1126
+ );
1127
+ // The far end answers the restart as usual, on the same peer.
1128
+ other
1129
+ .set_remote_description(RTCSessionDescription::offer(restart).expect("a valid offer"))
1130
+ .await
1131
+ .expect("the restart offer applies");
1132
+ let reanswer = other.create_answer(None).await.expect("an answer");
1133
+ other
1134
+ .set_local_description(reanswer.clone())
1135
+ .await
1136
+ .expect("a local description");
1137
+ peer.accept_answer(reanswer.sdp)
1138
+ .await
1139
+ .expect("the restart answer is accepted");
1140
+ }
1141
+
736
1142
  #[tokio::test]
737
1143
  async fn a_forwarded_client_is_offered_ice_over_tcp_on_the_loopback() {
738
1144
  let offered = |loopback_tcp: bool| async move {
@@ -744,6 +1150,7 @@ mod tests {
744
1150
  pace_bps: 20_000_000.0,
745
1151
  },
746
1152
  events,
1153
+ VideoCodec::Vp9,
747
1154
  )
748
1155
  .await
749
1156
  .expect("a peer connection");
@@ -42,6 +42,10 @@ pub struct PortalSession {
42
42
  /// `restore_token` is a token from an earlier session; supplying it lets a
43
43
  /// backend skip the picker when its policy allows it. The returned token must be
44
44
  /// persisted by the consumer and is single-use.
45
+ pub async fn open_display(_: Option<u32>) -> Result<PortalSession> {
46
+ anyhow::bail!("display selection is only supported on macOS")
47
+ }
48
+
45
49
  pub async fn open(restore_token: Option<&str>) -> Result<PortalSession> {
46
50
  let proxy = Screencast::new()
47
51
  .await
@@ -0,0 +1,68 @@
1
+ use anyhow::{bail, Result};
2
+ use serde_json::Value;
3
+
4
+ /// A selected macOS display. `node_id` is its CoreGraphics display identifier.
5
+ #[derive(Debug, Clone, serde::Serialize)]
6
+ pub struct SelectedSource {
7
+ pub node_id: u32,
8
+ pub width: i32,
9
+ pub height: i32,
10
+ #[serde(skip_serializing_if = "Option::is_none")]
11
+ pub position: Option<(i32, i32)>,
12
+ #[serde(skip_serializing_if = "Option::is_none")]
13
+ pub source_type: Option<String>,
14
+ pub origin_x: i32,
15
+ pub origin_y: i32,
16
+ }
17
+
18
+ pub struct PortalSession {
19
+ pub source: SelectedSource,
20
+ pub restore_token: Option<String>,
21
+ }
22
+
23
+ pub async fn open(_restore_token: Option<&str>) -> Result<PortalSession> {
24
+ open_display_inner(None)
25
+ }
26
+
27
+ pub async fn open_display(display_id: Option<u32>) -> Result<PortalSession> {
28
+ open_display_inner(display_id)
29
+ }
30
+
31
+ fn open_display_inner(display_id: Option<u32>) -> Result<PortalSession> {
32
+ if !unsafe { crate::mac::CGPreflightScreenCaptureAccess() } {
33
+ crate::mac::request_screen_capture_content().map_err(anyhow::Error::msg)?;
34
+ if !unsafe { crate::mac::CGPreflightScreenCaptureAccess() } {
35
+ bail!("capture-permission: allow {} in System Settings › Privacy & Security › Screen & System Audio Recording, then reconnect", crate::mac::tcc_responsible_app_name());
36
+ }
37
+ }
38
+ let displays = crate::mac::displays();
39
+ let selected = displays
40
+ .iter()
41
+ .find(|display| {
42
+ display_id.is_some_and(|id| display["id"].as_u64() == Some(id as u64))
43
+ || (display_id.is_none() && display["main"] == true)
44
+ })
45
+ .or_else(|| display_id.is_none().then(|| displays.first()).flatten())
46
+ .ok_or_else(|| anyhow::anyhow!("no active display is available on the Mac"))?;
47
+ let origin_x = selected
48
+ .pointer("/origin/x")
49
+ .and_then(Value::as_f64)
50
+ .unwrap_or(0.0) as i32;
51
+ let origin_y = selected
52
+ .pointer("/origin/y")
53
+ .and_then(Value::as_f64)
54
+ .unwrap_or(0.0) as i32;
55
+ let source = SelectedSource {
56
+ node_id: selected["id"].as_u64().unwrap_or_default() as u32,
57
+ width: selected["width"].as_u64().unwrap_or_default() as i32,
58
+ height: selected["height"].as_u64().unwrap_or_default() as i32,
59
+ position: Some((origin_x, origin_y)),
60
+ source_type: Some(String::from("display")),
61
+ origin_x,
62
+ origin_y,
63
+ };
64
+ Ok(PortalSession {
65
+ source,
66
+ restore_token: None,
67
+ })
68
+ }