@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
@@ -11,9 +11,11 @@ use crate::convert::{fit, I420};
11
11
  use crate::encoder::Encoder;
12
12
  use crate::input::{Button, HeldState, InputDevices};
13
13
  use crate::keymap::{self, Layout};
14
- use crate::peer::{PeerEvent, TransportOptions, VideoPeer};
14
+ use crate::peer::{h264_profile_level_id, PeerEvent, TransportOptions, VideoCodec, VideoPeer};
15
15
  use crate::portal::{self, SelectedSource};
16
- use crate::protocol::{ControlMessage, ControlReply, Permission, PointerPhase, SourceRequest};
16
+ use crate::protocol::{
17
+ ControlMessage, ControlReply, EncodedCodec, Permission, PointerPhase, SourceRequest,
18
+ };
17
19
  use crate::x11::X11Desktop;
18
20
  use anyhow::Result;
19
21
  use std::sync::atomic::{AtomicBool, Ordering};
@@ -59,13 +61,88 @@ pub struct Metrics {
59
61
  pub target_kbps: u32,
60
62
  pub input_applied: u64,
61
63
  pub input_rejected: u64,
64
+ /// Access units the consumer fed to an encoded source.
65
+ pub fed_frames: u64,
66
+ /// Control messages forwarded back to the consumer that owns the device,
67
+ /// instead of being applied to a desktop this engine holds.
68
+ pub input_forwarded: u64,
62
69
  }
63
70
 
64
- /// Which desktop is being captured.
65
- ///
66
- /// Dropping either variant stops its capture: the portal variant drops the
67
- /// PipeWire stream, which is what releases the compositor's consent, and the X11
68
- /// variant stops reading the server.
71
+ fn tile_hashes(raw: &[u8], width: usize, height: usize) -> Vec<u64> {
72
+ use std::hash::{Hash, Hasher};
73
+ let mut hashes = Vec::new();
74
+ for y in (0..height).step_by(32) {
75
+ for x in (0..width).step_by(32) {
76
+ let mut hash = std::collections::hash_map::DefaultHasher::new();
77
+ for row in y..(y + 32).min(height) {
78
+ raw[(row * width + x) * 4..(row * width + (x + 32).min(width)) * 4].hash(&mut hash);
79
+ }
80
+ hashes.push(hash.finish());
81
+ }
82
+ }
83
+ hashes
84
+ }
85
+
86
+ /// Group changed 32-pixel tiles without merging unrelated regions.
87
+ fn dirty_regions(hashes: &[u64], previous: &[u64], width: usize, height: usize) -> Vec<[usize; 4]> {
88
+ if hashes.len() != previous.len() {
89
+ return vec![[0, 0, width, height]];
90
+ }
91
+ let columns = width.div_ceil(32);
92
+ let mut dirty: Vec<bool> = hashes
93
+ .iter()
94
+ .zip(previous)
95
+ .map(|(now, before)| now != before)
96
+ .collect();
97
+ let mut regions = Vec::new();
98
+ for index in 0..dirty.len() {
99
+ if !dirty[index] {
100
+ continue;
101
+ }
102
+ dirty[index] = false;
103
+ let mut stack = vec![index];
104
+ let (mut left, mut top, mut right, mut bottom) = (
105
+ index % columns,
106
+ index / columns,
107
+ index % columns,
108
+ index / columns,
109
+ );
110
+ while let Some(tile) = stack.pop() {
111
+ let col = tile % columns;
112
+ let row = tile / columns;
113
+ left = left.min(col);
114
+ right = right.max(col);
115
+ top = top.min(row);
116
+ bottom = bottom.max(row);
117
+ for neighbor in [
118
+ tile.checked_sub(columns),
119
+ (row + 1 < height.div_ceil(32)).then_some(tile + columns),
120
+ (col > 0).then(|| tile - 1),
121
+ (col + 1 < columns).then_some(tile + 1),
122
+ ]
123
+ .into_iter()
124
+ .flatten()
125
+ {
126
+ if dirty[neighbor] {
127
+ dirty[neighbor] = false;
128
+ stack.push(neighbor);
129
+ }
130
+ }
131
+ }
132
+ let x = left * 32;
133
+ let y = top * 32;
134
+ regions.push([
135
+ x,
136
+ y,
137
+ ((right + 1) * 32).min(width) - x,
138
+ ((bottom + 1) * 32).min(height) - y,
139
+ ]);
140
+ }
141
+ regions
142
+ }
143
+
144
+ /// Dropping either variant stops its capture: the portal releases consent and
145
+ /// the X11 variant stops reading its server.
69
146
  enum FrameSource {
70
147
  Portal(Capture),
71
148
  X11(X11Capture),
@@ -103,6 +180,7 @@ fn select_x11(
103
180
  max_fps: u32,
104
181
  metrics: Arc<Mutex<Metrics>>,
105
182
  sink: capture::FrameSink,
183
+ on_stop: Box<dyn Fn(String) + Send>,
106
184
  ) -> Result<Selected> {
107
185
  let desktop = Arc::new(Mutex::new(X11Desktop::connect(display)?));
108
186
  let (width, height) = {
@@ -137,17 +215,17 @@ fn select_x11(
137
215
  let started = Instant::now();
138
216
  let frame = {
139
217
  let mut desktop = lock(&desktop);
140
- desktop.capture(max_width, max_height)
218
+ desktop.capture_with_pixels(max_width, max_height)
141
219
  };
142
220
  match frame {
143
- Ok(frame) => {
221
+ Ok((frame, raw)) => {
144
222
  use std::hash::{Hash, Hasher};
145
223
  let mut hasher = std::collections::hash_map::DefaultHasher::new();
146
- frame.data.hash(&mut hasher);
224
+ raw.hash(&mut hasher);
147
225
  let hash = hasher.finish();
148
226
  if last_hash != Some(hash) {
149
227
  last_hash = Some(hash);
150
- sink(frame, sequence);
228
+ sink(frame, sequence, raw);
151
229
  sequence += 1;
152
230
  }
153
231
  }
@@ -158,6 +236,7 @@ fn select_x11(
158
236
  // A display that has gone away is not recoverable by
159
237
  // retrying, so stop rather than spin on the error.
160
238
  if error.to_string().contains("the X11 connection dropped") {
239
+ on_stop(format!("X11 capture stopped: {error}"));
161
240
  break;
162
241
  }
163
242
  }
@@ -312,6 +391,19 @@ impl InputTarget {
312
391
  Ok(())
313
392
  }
314
393
 
394
+ #[cfg(target_os = "macos")]
395
+ fn unicode_text(&mut self, text: &str) -> Result<()> {
396
+ match &mut self.applier {
397
+ #[cfg(target_os = "macos")]
398
+ Applier::Uinput(devices) => devices.unicode_text(text),
399
+ #[cfg(target_os = "linux")]
400
+ Applier::Uinput(_) => anyhow::bail!("Unicode text input is unavailable on Linux"),
401
+ Applier::X11(_) => anyhow::bail!("Unicode text input is unavailable for X11"),
402
+ #[cfg(test)]
403
+ Applier::Recording(_) => Ok(()),
404
+ }
405
+ }
406
+
315
407
  fn chord(&mut self, code: i16, modifiers: Vec<i16>, down: bool) -> Result<()> {
316
408
  if down {
317
409
  if self.chord_keys.iter().any(|(held, _)| *held == code) {
@@ -375,7 +467,7 @@ fn x11_button(button: Button) -> u8 {
375
467
  }
376
468
  }
377
469
 
378
- fn lock<T>(mutex: &Arc<Mutex<T>>) -> std::sync::MutexGuard<'_, T> {
470
+ fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
379
471
  // A poisoned lock means a previous input call panicked while applying; the
380
472
  // desktop is then in unknown state, so the session is closed rather than
381
473
  // continuing to drive it.
@@ -385,6 +477,7 @@ fn lock<T>(mutex: &Arc<Mutex<T>>) -> std::sync::MutexGuard<'_, T> {
385
477
  }
386
478
 
387
479
  /// Why a session could not do what was asked, in the protocol's own vocabulary.
480
+ #[derive(Debug)]
388
481
  pub struct SessionError {
389
482
  pub code: &'static str,
390
483
  pub message: String,
@@ -412,6 +505,7 @@ pub struct OpenRequest {
412
505
  pub ttl: Option<Duration>,
413
506
  /// Offer a passive ICE-TCP candidate on the loopback, for a forwarded client.
414
507
  pub loopback_tcp: bool,
508
+ pub agent_indicator: bool,
415
509
  }
416
510
 
417
511
  /// Events a session raises for its consumer.
@@ -432,7 +526,26 @@ pub enum SessionEvent {
432
526
  transport: String,
433
527
  first_frame: bool,
434
528
  },
529
+ Frame {
530
+ seq: u64,
531
+ damage: Vec<[usize; 4]>,
532
+ },
533
+ /// One control-channel message from the client, for a source whose device
534
+ /// the consumer drives. The engine applied nothing locally.
535
+ Input {
536
+ input: serde_json::Value,
537
+ },
538
+ /// The far end needs a fresh reference frame and only the consumer can make
539
+ /// one: the encoder for this track is on its side. Raised by an RTCP PLI or
540
+ /// FIR, and once when the transport connects, because everything sent before
541
+ /// that was dropped.
542
+ KeyframeRequest {
543
+ generation: u64,
544
+ },
435
545
  RestoreToken(String),
546
+ CaptureStopped {
547
+ reason: String,
548
+ },
436
549
  Revoked {
437
550
  reason: String,
438
551
  },
@@ -447,6 +560,8 @@ pub struct Notice {
447
560
  pub event: SessionEvent,
448
561
  }
449
562
 
563
+ type FrameSnapshot = (u64, Instant, usize, usize, Vec<u8>, Vec<u64>, Vec<u64>);
564
+
450
565
  /// Everything a session's background tasks need, shared rather than borrowed so
451
566
  /// the consumer can own the `Session` handle while the pipeline runs.
452
567
  struct Inner {
@@ -456,11 +571,21 @@ struct Inner {
456
571
  source: SelectedSource,
457
572
  geometry: serde_json::Value,
458
573
  metrics: Arc<Mutex<Metrics>>,
459
- peer: Arc<VideoPeer>,
460
- encoder: Mutex<Encoder>,
574
+ latest: Arc<Mutex<Option<FrameSnapshot>>>,
575
+ capture_error: Arc<Mutex<Option<String>>>,
576
+ frame_changes: tokio::sync::watch::Receiver<u64>,
577
+ /// Built when the codec is known: with `session.open` for a captured
578
+ /// desktop, and with the first fed access unit for an encoded source, whose
579
+ /// offer has to name the profile the stream's own SPS declares.
580
+ peer: Mutex<Option<Arc<VideoPeer>>>,
581
+ /// Absent for an encoded source: the consumer is the encoder.
582
+ encoder: Mutex<Option<Encoder>>,
461
583
  input: Mutex<Option<InputTarget>>,
584
+ indicator: Mutex<Option<crate::indicator::Indicator>>,
462
585
  capture: Mutex<Option<FrameSource>>,
463
- layout: Mutex<Layout>,
586
+ /// Absent for an encoded source: there is no local keyboard to translate a
587
+ /// character through, because the client's keys are forwarded instead.
588
+ layout: Mutex<Option<Layout>>,
464
589
  last_seq: Mutex<u64>,
465
590
  control_open: AtomicBool,
466
591
  closed: AtomicBool,
@@ -468,18 +593,47 @@ struct Inner {
468
593
  /// from the lease as well as from the consumer.
469
594
  pipeline: Arc<AtomicBool>,
470
595
  events: tokio_mpsc::UnboundedSender<Notice>,
596
+ /// Present only for an encoded source.
597
+ encoded: Mutex<Option<Encoded>>,
471
598
  }
472
599
 
600
+ /// A source whose video arrives already encoded.
601
+ struct Encoded {
602
+ /// What the consumer said it would feed. Only H.264 exists today; the offer
603
+ /// is built from this rather than assumed, so a second codec is one arm here.
604
+ codec: EncodedCodec,
605
+ /// The transport settings the consumer chose in `session.open`, kept until
606
+ /// the peer that uses them can be built.
607
+ transport: TransportOptions,
608
+ /// Where the peer's events go once it exists.
609
+ peer_events: tokio_mpsc::UnboundedSender<PeerEvent>,
610
+ /// Access units waiting to be packetized.
611
+ feed: tokio_mpsc::Sender<FedAccessUnit>,
612
+ }
613
+
614
+ /// One access unit a consumer fed, with the keyframe flag it declared.
615
+ struct FedAccessUnit {
616
+ keyframe: bool,
617
+ data: Vec<u8>,
618
+ }
619
+
620
+ /// How many fed access units may wait. A live stream drops the newest rather
621
+ /// than queue: the packetizer is the only consumer, and a backlog of
622
+ /// milliseconds-old pictures is worse than a gap the receiver asks to repair.
623
+ const FEED_BACKLOG: usize = 8;
624
+
473
625
  pub struct Session {
474
626
  inner: Arc<Inner>,
475
627
  }
476
628
 
629
+ #[cfg(target_os = "linux")]
477
630
  fn wayland_clipboard_available() -> bool {
478
631
  std::env::var("XDG_SESSION_TYPE").as_deref() == Ok("wayland")
479
632
  && std::env::var_os("WAYLAND_DISPLAY").is_some()
480
633
  }
481
634
 
482
635
  /// What the engine can actually do on this machine right now.
636
+ #[cfg(target_os = "linux")]
483
637
  pub fn capabilities() -> serde_json::Value {
484
638
  let session_kind = if std::env::var_os("WAYLAND_DISPLAY").is_some() {
485
639
  "wayland"
@@ -534,6 +688,65 @@ pub fn capabilities() -> serde_json::Value {
534
688
  },
535
689
  "clipboard": { "read": clipboard, "write": clipboard && clipboard::writer_available(), "mime": ["text/plain;charset=utf-8"],
536
690
  "maxBytes": clipboard::MAX_CLIPBOARD_BYTES },
691
+ // Video this engine does not capture or encode but can carry, so a
692
+ // consumer can tell an old engine from one that takes `session.feed`
693
+ // without guessing from a version string.
694
+ "encoded": { "codecs": ["h264"] },
695
+ })
696
+ }
697
+
698
+ #[cfg(target_os = "macos")]
699
+ fn wayland_clipboard_available() -> bool {
700
+ true
701
+ }
702
+
703
+ #[cfg(target_os = "macos")]
704
+ pub fn capabilities() -> serde_json::Value {
705
+ let capture_granted = unsafe { crate::mac::CGPreflightScreenCaptureAccess() };
706
+ let (input, input_grant, unavailable) = match crate::input::probe() {
707
+ Ok(()) => (true, "granted", serde_json::Value::Null),
708
+ Err(error) => (
709
+ false,
710
+ "missing-accessibility",
711
+ serde_json::json!({
712
+ "reason": error.reason,
713
+ "remedy": error.remedy,
714
+ }),
715
+ ),
716
+ };
717
+ serde_json::json!({
718
+ "protocol": crate::protocol::PROTOCOL_VERSION,
719
+ "engine": format!("desklink-host/{}", env!("CARGO_PKG_VERSION")),
720
+ "platform": "macos",
721
+ "session": { "kind": "quartz", "on_console": crate::mac::on_console() },
722
+ "x11": { "available": false, "size": null },
723
+ "capture": {
724
+ "mechanism": "screencapturekit",
725
+ "backends": ["screencapturekit"],
726
+ "formats": ["bgra"],
727
+ "cursor": "embedded",
728
+ "audio": false,
729
+ "displays": crate::mac::displays(),
730
+ "grant": if capture_granted { "granted" } else { "missing-screen-recording" },
731
+ "unavailable_reason": if capture_granted { serde_json::Value::Null } else { serde_json::json!({
732
+ "reason": format!("Screen Recording permission has not been granted to {}.", crate::mac::tcc_responsible_app_name()),
733
+ "remedy": format!("Allow {} in System Settings › Privacy & Security › Screen & System Audio Recording, then reconnect.", crate::mac::tcc_responsible_app_name())
734
+ }) }
735
+ },
736
+ "encode": { "codecs": ["vp9"], "hardware": false },
737
+ "input": {
738
+ "mechanism": "quartz-cgevent",
739
+ "pointer": input,
740
+ "wheel": input,
741
+ "keyboard": input,
742
+ "text": ["unicode", "us-ansi-keymap"],
743
+ "layout": keymap::LayoutNames::from_environment().identity(),
744
+ "unavailable_reason": unavailable,
745
+ "grant": input_grant,
746
+ },
747
+ "clipboard": { "read": true, "write": true, "mime": ["text/plain;charset=utf-8"],
748
+ "maxBytes": clipboard::MAX_CLIPBOARD_BYTES },
749
+ "encoded": { "codecs": ["h264"] },
537
750
  })
538
751
  }
539
752
 
@@ -542,27 +755,114 @@ impl Session {
542
755
  request: OpenRequest,
543
756
  events: tokio_mpsc::UnboundedSender<Notice>,
544
757
  ) -> std::result::Result<Self, SessionError> {
758
+ if !request.permissions.contains(&Permission::View) {
759
+ return Err(SessionError::new(
760
+ "permission",
761
+ "session.open requires the view permission",
762
+ ));
763
+ }
545
764
  let id = opaque_id();
546
765
  let wants_control = request.permissions.contains(&Permission::Control);
547
766
  let wants_x11 = matches!(request.source, Some(SourceRequest::X11 { .. }));
767
+ let max_fps = request.max_fps.clamp(1, 60);
768
+ // A source the consumer feeds needs no capture backend, no encoder and
769
+ // no local input device: the device the client drives is the consumer's,
770
+ // and the offer has to wait for the stream's own SPS.
771
+ if let Some(SourceRequest::Encoded {
772
+ codec,
773
+ width,
774
+ height,
775
+ }) = request.source.clone()
776
+ {
777
+ return Self::open_encoded(request, codec, width, height, events).await;
778
+ }
548
779
  // Which desktop decides which input path is even available: an X display
549
780
  // takes XTest, which cannot reach any other session, while a portal
550
781
  // desktop needs kernel input access.
551
782
  if wants_control && !wants_x11 {
552
783
  // Refuse up front rather than presenting a control surface that
553
784
  // silently does nothing.
554
- if let Err(unavailable) = crate::input::probe() {
555
- return Err(SessionError::new(
556
- "input-unavailable",
557
- format!("{}; {}", unavailable.reason, unavailable.remedy),
558
- ));
785
+ if crate::input::probe().is_err() {
786
+ #[cfg(target_os = "macos")]
787
+ let _ = crate::input::request_access();
788
+ if let Err(unavailable) = crate::input::probe() {
789
+ return Err(SessionError::new(
790
+ "input-unavailable",
791
+ format!("{}; {}", unavailable.reason, unavailable.remedy),
792
+ ));
793
+ }
559
794
  }
560
795
  }
561
796
 
562
797
  let metrics = Arc::new(Mutex::new(Metrics::default()));
798
+ let mut indicator = None;
563
799
  let (frame_tx, frame_rx) = latest_frame();
800
+ let latest = Arc::new(Mutex::new(None::<FrameSnapshot>));
801
+ let observed = latest.clone();
802
+ let frame_sequence = Arc::new(std::sync::atomic::AtomicU64::new(0));
803
+ let capture_error = Arc::new(Mutex::new(None::<String>));
804
+ let stopped_error = capture_error.clone();
805
+ let stopped_latest = latest.clone();
806
+ let stopped_events = events.clone();
807
+ let stopped_id = id.clone();
808
+ let (frame_tx_signal, frame_changes) = tokio::sync::watch::channel(0u64);
564
809
  let captured = metrics.clone();
565
- let sink = Box::new(move |frame: I420, _seq: u64| {
810
+ let stop_signal = frame_tx_signal.clone();
811
+ let on_capture_status = move |running: bool, reason: String| {
812
+ if let Ok(mut error) = stopped_error.lock() {
813
+ *error = if running { None } else { Some(reason.clone()) };
814
+ }
815
+ if let Ok(mut frame) = stopped_latest.lock() {
816
+ *frame = None;
817
+ }
818
+ stop_signal.send_replace(0);
819
+ let _ = stopped_events.send(Notice {
820
+ session_id: stopped_id.clone(),
821
+ event: if running {
822
+ SessionEvent::State {
823
+ capture: "streaming",
824
+ transport: String::from("connected"),
825
+ first_frame: false,
826
+ }
827
+ } else {
828
+ SessionEvent::CaptureStopped { reason }
829
+ },
830
+ });
831
+ };
832
+ let capture_status = Arc::new(on_capture_status);
833
+ let frame_events = events.clone();
834
+ let frame_session_id = id.clone();
835
+ let sink = Box::new(move |frame: I420, _seq: u64, raw: Vec<u8>| {
836
+ let hashes = tile_hashes(&raw, frame.width, frame.height);
837
+ if let Ok(mut held) = observed.lock() {
838
+ let unchanged =
839
+ held.as_ref()
840
+ .is_some_and(|(_, _, width, height, _, current, _)| {
841
+ *width == frame.width && *height == frame.height && current == &hashes
842
+ });
843
+ if !unchanged {
844
+ let seq = frame_sequence.fetch_add(1, Ordering::Relaxed) + 1;
845
+ let previous = held
846
+ .as_ref()
847
+ .map(|(_, _, _, _, _, current, _)| current.clone())
848
+ .unwrap_or_default();
849
+ let damage = dirty_regions(&hashes, &previous, frame.width, frame.height);
850
+ *held = Some((
851
+ seq,
852
+ Instant::now(),
853
+ frame.width,
854
+ frame.height,
855
+ raw,
856
+ hashes,
857
+ previous,
858
+ ));
859
+ frame_tx_signal.send_replace(seq);
860
+ let _ = frame_events.send(Notice {
861
+ session_id: frame_session_id.clone(),
862
+ event: SessionEvent::Frame { seq, damage },
863
+ });
864
+ }
865
+ }
566
866
  if let Ok(mut m) = captured.lock() {
567
867
  m.captured_frames += 1;
568
868
  }
@@ -584,15 +884,23 @@ impl Session {
584
884
  display.as_deref(),
585
885
  request.max_width,
586
886
  request.max_height,
587
- request.max_fps,
887
+ max_fps,
588
888
  metrics.clone(),
589
889
  sink,
890
+ Box::new({
891
+ let status = capture_status.clone();
892
+ move |reason| status(false, reason)
893
+ }),
590
894
  )
591
895
  .map_err(|error| SessionError::new("source", format!("{error:#}")))?,
592
896
  _ => {
593
- let portal = portal::open(request.restore_token.as_deref())
594
- .await
595
- .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
897
+ let portal = match request.source.as_ref() {
898
+ Some(SourceRequest::Display { display_id }) => {
899
+ portal::open_display(*display_id).await
900
+ }
901
+ _ => portal::open(request.restore_token.as_deref()).await,
902
+ }
903
+ .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
596
904
  if let Some(token) = &portal.restore_token {
597
905
  let _ = events.send(Notice {
598
906
  session_id: id.clone(),
@@ -604,8 +912,59 @@ impl Session {
604
912
  let source_h = source.height.max(1) as usize;
605
913
  let (width, height) =
606
914
  fit(source_w, source_h, request.max_width, request.max_height);
607
- let capture = capture::start(portal, width, height, request.max_fps, sink)
608
- .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
915
+ #[cfg(target_os = "linux")]
916
+ if request.agent_indicator && wants_control {
917
+ indicator = Some(
918
+ crate::indicator::Indicator::start_wayland(source_w, source_h).map_err(
919
+ |error| {
920
+ SessionError::new("indicator-unavailable", format!("{error:#}"))
921
+ },
922
+ )?,
923
+ );
924
+ }
925
+ #[cfg(target_os = "macos")]
926
+ if request.agent_indicator && wants_control {
927
+ indicator = Some(
928
+ crate::indicator::Indicator::start(source.node_id, source_w, source_h)
929
+ .map_err(|error| {
930
+ SessionError::new("indicator", format!("{error:#}"))
931
+ })?,
932
+ );
933
+ }
934
+ #[cfg(target_os = "macos")]
935
+ let capture = capture::start(
936
+ portal,
937
+ width,
938
+ height,
939
+ max_fps,
940
+ sink,
941
+ indicator.as_ref().map(crate::indicator::Indicator::pid),
942
+ Box::new({
943
+ let status = capture_status.clone();
944
+ move |running, reason| status(running, reason)
945
+ }),
946
+ )
947
+ .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
948
+ #[cfg(target_os = "linux")]
949
+ let capture = capture::start(
950
+ portal,
951
+ width,
952
+ height,
953
+ max_fps,
954
+ sink,
955
+ indicator
956
+ .as_ref()
957
+ .map(crate::indicator::Indicator::position),
958
+ Box::new({
959
+ let status = capture_status.clone();
960
+ move |reason| status(false, reason)
961
+ }),
962
+ )
963
+ .map_err(|error| SessionError::new("source", format!("{error:#}")))?;
964
+ #[cfg(target_os = "linux")]
965
+ if let Some(indicator) = indicator.as_mut() {
966
+ indicator.event('A', -1, -1);
967
+ }
609
968
  Selected {
610
969
  source,
611
970
  capture: FrameSource::Portal(capture),
@@ -616,6 +975,20 @@ impl Session {
616
975
 
617
976
  let source_w = source.width.max(1) as usize;
618
977
  let source_h = source.height.max(1) as usize;
978
+ #[cfg(target_os = "linux")]
979
+ if request.agent_indicator && wants_control && wants_x11 {
980
+ let display = match &request.source {
981
+ Some(SourceRequest::X11 {
982
+ display: Some(display),
983
+ }) => display.clone(),
984
+ _ => std::env::var("DISPLAY")
985
+ .map_err(|_| SessionError::new("indicator-unavailable", "no X display"))?,
986
+ };
987
+ indicator = Some(
988
+ crate::indicator::Indicator::start(&display, source_w, source_h)
989
+ .map_err(|error| SessionError::new("indicator", format!("{error:#}")))?,
990
+ );
991
+ }
619
992
  let (width, height) = fit(source_w, source_h, request.max_width, request.max_height);
620
993
 
621
994
  let bitrate_kbps = if request.bitrate_kbps == 0 {
@@ -627,7 +1000,7 @@ impl Session {
627
1000
  width,
628
1001
  height,
629
1002
  bitrate_kbps,
630
- request.max_fps,
1003
+ max_fps,
631
1004
  available_parallelism().min(8) as u32,
632
1005
  )
633
1006
  .map_err(|error| SessionError::new("encode", format!("{error:#}")))?;
@@ -647,11 +1020,19 @@ impl Session {
647
1020
  wheel_rest: (0.0, 0.0),
648
1021
  },
649
1022
  None => InputTarget {
650
- applier: Applier::Uinput(
651
- InputDevices::create(source_w as i32, source_h as i32).map_err(
652
- |error| SessionError::new("input-unavailable", format!("{error:#}")),
653
- )?,
654
- ),
1023
+ applier: Applier::Uinput({
1024
+ #[cfg(target_os = "linux")]
1025
+ let devices = InputDevices::create(source_w as i32, source_h as i32);
1026
+ #[cfg(target_os = "macos")]
1027
+ let devices = InputDevices::create_for_display(
1028
+ source_w as i32,
1029
+ source_h as i32,
1030
+ source.node_id,
1031
+ );
1032
+ devices.map_err(|error| {
1033
+ SessionError::new("input-unavailable", format!("{error:#}"))
1034
+ })?
1035
+ }),
655
1036
  held: HeldState::default(),
656
1037
  explicit_modifiers: Vec::new(),
657
1038
  chord_modifiers: Vec::new(),
@@ -675,9 +1056,11 @@ impl Session {
675
1056
  pace_bps: (bitrate_kbps as f64 * 3_000.0).max(20_000_000.0),
676
1057
  },
677
1058
  peer_events_tx,
1059
+ VideoCodec::Vp9,
678
1060
  )
679
1061
  .await
680
1062
  .map_err(|error| SessionError::new("transport", format!("{error:#}")))?;
1063
+ let peer = Arc::new(peer);
681
1064
 
682
1065
  let generation = 1u64;
683
1066
  let geometry = serde_json::json!({
@@ -694,16 +1077,21 @@ impl Session {
694
1077
  source,
695
1078
  geometry,
696
1079
  metrics,
697
- peer: Arc::new(peer),
698
- encoder: Mutex::new(encoder),
1080
+ latest,
1081
+ capture_error,
1082
+ frame_changes,
1083
+ peer: Mutex::new(Some(peer.clone())),
1084
+ encoder: Mutex::new(Some(encoder)),
699
1085
  input: Mutex::new(input),
1086
+ indicator: Mutex::new(indicator),
700
1087
  capture: Mutex::new(Some(capture)),
701
- layout: Mutex::new(layout),
1088
+ layout: Mutex::new(Some(layout)),
702
1089
  last_seq: Mutex::new(0),
703
1090
  control_open: AtomicBool::new(false),
704
1091
  closed: AtomicBool::new(false),
705
1092
  pipeline: pipeline.clone(),
706
1093
  events: events.clone(),
1094
+ encoded: Mutex::new(None),
707
1095
  });
708
1096
 
709
1097
  inner.notify(SessionEvent::Description {
@@ -716,13 +1104,140 @@ impl Session {
716
1104
  first_frame: false,
717
1105
  });
718
1106
 
719
- spawn_pipeline(&inner, frame_rx, pipeline, request.max_fps, bitrate_kbps);
1107
+ spawn_pipeline(&inner, peer, frame_rx, pipeline, max_fps, bitrate_kbps);
720
1108
  spawn_peer_events(&inner, peer_events_rx);
721
1109
  spawn_lease(&inner, request.ttl.unwrap_or(Duration::from_secs(3600)));
722
1110
 
723
1111
  Ok(Self { inner })
724
1112
  }
725
1113
 
1114
+ /// Open a session over video the consumer already encoded.
1115
+ ///
1116
+ /// Nothing is captured, encoded or driven locally: the engine packetizes
1117
+ /// what it is fed and sends the client's control messages back to the
1118
+ /// consumer, which owns the device. The peer is deliberately absent here —
1119
+ /// the offer must name the profile-level-id of the stream's own SPS, and
1120
+ /// that only exists once the first access unit arrives.
1121
+ async fn open_encoded(
1122
+ request: OpenRequest,
1123
+ codec: EncodedCodec,
1124
+ width: usize,
1125
+ height: usize,
1126
+ events: tokio_mpsc::UnboundedSender<Notice>,
1127
+ ) -> std::result::Result<Self, SessionError> {
1128
+ if width == 0 || height == 0 || width > 7680 || height > 4320 {
1129
+ return Err(SessionError::new(
1130
+ "source",
1131
+ format!(
1132
+ "an encoded surface must be 1..7680 by 1..4320 pixels, not {width}x{height}"
1133
+ ),
1134
+ ));
1135
+ }
1136
+ let id = opaque_id();
1137
+ let generation = 1u64;
1138
+ let metrics = Arc::new(Mutex::new(Metrics::default()));
1139
+ // Nothing raises it, and nothing waits for it: an encoded source has no
1140
+ // frame to read, only an encoded stream to forward.
1141
+ let (_frame_signal, frame_changes) = tokio::sync::watch::channel(0u64);
1142
+ let (peer_events, peer_events_rx) = tokio_mpsc::unbounded_channel::<PeerEvent>();
1143
+ let (feed, feed_rx) = tokio_mpsc::channel::<FedAccessUnit>(FEED_BACKLOG);
1144
+ let geometry = serde_json::json!({
1145
+ "source": { "width": width, "height": height },
1146
+ // Nothing is scaled: the client aims at the stream's own pixels, and
1147
+ // the consumer maps them to its device.
1148
+ "encoded": { "width": width, "height": height },
1149
+ "origin": { "x": 0, "y": 0 },
1150
+ });
1151
+ let inner = Arc::new(Inner {
1152
+ id,
1153
+ generation,
1154
+ permissions: request.permissions,
1155
+ source: SelectedSource {
1156
+ node_id: 0,
1157
+ width: width as i32,
1158
+ height: height as i32,
1159
+ position: Some((0, 0)),
1160
+ source_type: Some(String::from("encoded")),
1161
+ origin_x: 0,
1162
+ origin_y: 0,
1163
+ },
1164
+ geometry,
1165
+ metrics,
1166
+ latest: Arc::new(Mutex::new(None)),
1167
+ capture_error: Arc::new(Mutex::new(None)),
1168
+ frame_changes,
1169
+ peer: Mutex::new(None),
1170
+ encoder: Mutex::new(None),
1171
+ input: Mutex::new(None),
1172
+ indicator: Mutex::new(None),
1173
+ capture: Mutex::new(None),
1174
+ layout: Mutex::new(None),
1175
+ last_seq: Mutex::new(0),
1176
+ control_open: AtomicBool::new(false),
1177
+ closed: AtomicBool::new(false),
1178
+ pipeline: Arc::new(AtomicBool::new(false)),
1179
+ events: events.clone(),
1180
+ encoded: Mutex::new(Some(Encoded {
1181
+ codec,
1182
+ transport: TransportOptions {
1183
+ ice_servers: request.ice_servers,
1184
+ loopback_tcp: request.loopback_tcp,
1185
+ // A device stream is a few megabits; pacing exists to spread
1186
+ // a large frame, not to shape a rate this engine does not set.
1187
+ pace_bps: 20_000_000.0,
1188
+ },
1189
+ peer_events,
1190
+ feed,
1191
+ })),
1192
+ });
1193
+ inner.notify(SessionEvent::State {
1194
+ capture: "consented",
1195
+ transport: String::from("new"),
1196
+ first_frame: false,
1197
+ });
1198
+ spawn_peer_events(&inner, peer_events_rx);
1199
+ spawn_encoded(&inner, feed_rx);
1200
+ spawn_lease(&inner, request.ttl.unwrap_or(Duration::from_secs(3600)));
1201
+ Ok(Self { inner })
1202
+ }
1203
+
1204
+ /// Take one access unit of an encoded source.
1205
+ ///
1206
+ /// The reply acknowledges the hand-off, not the picture: the unit is
1207
+ /// packetized behind this request, and `session.metrics` reports what was
1208
+ /// sent and what was dropped.
1209
+ pub fn feed(&self, keyframe: bool, data: Vec<u8>) -> std::result::Result<(), SessionError> {
1210
+ if self.inner.closed.load(Ordering::Relaxed) {
1211
+ return Err(SessionError::new("session", "the session has ended"));
1212
+ }
1213
+ let encoded = lock(&self.inner.encoded);
1214
+ let Some(encoded) = encoded.as_ref() else {
1215
+ return Err(SessionError::new(
1216
+ "operation",
1217
+ "this session has no encoded source to feed",
1218
+ ));
1219
+ };
1220
+ if data.is_empty() {
1221
+ return Err(SessionError::new(
1222
+ "operation",
1223
+ "an access unit cannot be empty",
1224
+ ));
1225
+ }
1226
+ if let Ok(mut m) = self.inner.metrics.lock() {
1227
+ m.fed_frames += 1;
1228
+ }
1229
+ if encoded
1230
+ .feed
1231
+ .try_send(FedAccessUnit { keyframe, data })
1232
+ .is_err()
1233
+ {
1234
+ if let Ok(mut m) = self.inner.metrics.lock() {
1235
+ m.dropped_frames += 1;
1236
+ }
1237
+ }
1238
+ Ok(())
1239
+ }
1240
+
726
1241
  pub fn id(&self) -> &str {
727
1242
  &self.inner.id
728
1243
  }
@@ -739,6 +1254,118 @@ impl Session {
739
1254
  &self.inner.source
740
1255
  }
741
1256
 
1257
+ pub async fn wait_frame(
1258
+ &self,
1259
+ after_seq: Option<u64>,
1260
+ still_ms: Option<u64>,
1261
+ timeout_ms: u64,
1262
+ ) -> std::result::Result<(), SessionError> {
1263
+ if lock(&self.inner.encoded).is_some() {
1264
+ return Err(SessionError::new(
1265
+ "operation",
1266
+ "this session forwards an encoded stream and keeps no frame",
1267
+ ));
1268
+ }
1269
+ let mut changes = self.inner.frame_changes.clone();
1270
+ let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms.min(120_000));
1271
+ loop {
1272
+ if let Some(reason) = lock(&self.inner.capture_error).clone() {
1273
+ return Err(SessionError::new("stream-stopped", reason));
1274
+ }
1275
+ let (seq, still) = self
1276
+ .inner
1277
+ .latest
1278
+ .lock()
1279
+ .ok()
1280
+ .and_then(|held| held.as_ref().map(|(seq, at, ..)| (*seq, at.elapsed())))
1281
+ .unwrap_or((0, Duration::ZERO));
1282
+ let target = still_ms.or(if after_seq.is_none() { Some(150) } else { None });
1283
+ let advanced = after_seq.is_none_or(|after| seq > after);
1284
+ if seq > 0 && advanced && target.is_none_or(|ms| still >= Duration::from_millis(ms)) {
1285
+ return Ok(());
1286
+ }
1287
+ let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1288
+ if remaining.is_zero() {
1289
+ return Err(SessionError::new(
1290
+ "frame-timeout",
1291
+ "frame condition not met before deadline",
1292
+ ));
1293
+ }
1294
+ let until_still = if seq > 0 && advanced {
1295
+ target.map_or(remaining, |ms| {
1296
+ Duration::from_millis(ms).saturating_sub(still)
1297
+ })
1298
+ } else {
1299
+ remaining
1300
+ };
1301
+ let _ = tokio::time::timeout(remaining.min(until_still), changes.changed()).await;
1302
+ }
1303
+ }
1304
+
1305
+ /// Write only the requested lossless frame bytes; JSON carries metadata, never pixels.
1306
+ pub fn frame(
1307
+ &self,
1308
+ since: Option<u64>,
1309
+ path: &str,
1310
+ region: Option<[usize; 4]>,
1311
+ ) -> std::result::Result<serde_json::Value, SessionError> {
1312
+ if !self.inner.permissions.contains(&Permission::View) {
1313
+ return Err(SessionError::new(
1314
+ "permission",
1315
+ "view permission is required",
1316
+ ));
1317
+ }
1318
+ if lock(&self.inner.encoded).is_some() {
1319
+ return Err(SessionError::new(
1320
+ "operation",
1321
+ "this session forwards an encoded stream and keeps no frame",
1322
+ ));
1323
+ }
1324
+ if let Some(reason) = lock(&self.inner.capture_error).clone() {
1325
+ return Err(SessionError::new("stream-stopped", reason));
1326
+ }
1327
+ let held = lock(&self.inner.latest);
1328
+ let (seq, at, width, height, raw, hashes, previous) = held
1329
+ .as_ref()
1330
+ .ok_or_else(|| SessionError::new("frame", "the first frame has not arrived"))?;
1331
+ let [x, y, w, h] = region.unwrap_or([0, 0, *width, *height]);
1332
+ if w == 0
1333
+ || h == 0
1334
+ || x.checked_add(w).is_none_or(|end| end > *width)
1335
+ || y.checked_add(h).is_none_or(|end| end > *height)
1336
+ {
1337
+ return Err(SessionError::new(
1338
+ "coordinates",
1339
+ "region exceeds the encoded surface",
1340
+ ));
1341
+ }
1342
+ let damage = if since == Some(*seq) {
1343
+ Vec::new()
1344
+ } else if since == seq.checked_sub(1) && previous.len() == hashes.len() {
1345
+ dirty_regions(hashes, previous, *width, *height)
1346
+ } else {
1347
+ vec![[0, 0, *width, *height]]
1348
+ };
1349
+ // An empty path asks for metadata only; no pixels are copied or written.
1350
+ let pixels = if path.is_empty() {
1351
+ None
1352
+ } else {
1353
+ let mut pixels = Vec::with_capacity(w * h * 4);
1354
+ for row in y..y + h {
1355
+ pixels.extend_from_slice(&raw[(row * width + x) * 4..(row * width + x + w) * 4]);
1356
+ }
1357
+ Some(pixels)
1358
+ };
1359
+ let result = serde_json::json!({ "seq": seq, "still_ms": at.elapsed().as_millis() as u64,
1360
+ "width": w, "height": h, "format": "bgrx", "damage": damage, "written": !path.is_empty() });
1361
+ drop(held);
1362
+ if let Some(pixels) = pixels {
1363
+ std::fs::write(path, pixels)
1364
+ .map_err(|error| SessionError::new("frame", error.to_string()))?;
1365
+ }
1366
+ Ok(result)
1367
+ }
1368
+
742
1369
  pub fn metrics(&self) -> Metrics {
743
1370
  self.inner
744
1371
  .metrics
@@ -748,7 +1375,32 @@ impl Session {
748
1375
  }
749
1376
 
750
1377
  pub async fn accept_answer(&self, sdp: String) -> Result<()> {
751
- self.inner.peer.accept_answer(sdp).await.map(|_applied| ())
1378
+ self.current_peer()?
1379
+ .accept_answer(sdp)
1380
+ .await
1381
+ .map(|_applied| ())
1382
+ }
1383
+
1384
+ /// The peer for this session, which an encoded source does not have until
1385
+ /// its first access unit has named the codec.
1386
+ fn current_peer(&self) -> Result<Arc<VideoPeer>> {
1387
+ self.inner
1388
+ .peer
1389
+ .lock()
1390
+ .ok()
1391
+ .and_then(|peer| peer.clone())
1392
+ .ok_or_else(|| anyhow::anyhow!("no offer has been made for this session yet"))
1393
+ }
1394
+
1395
+ /// Restart ICE: a fresh offer on the same peer, announced as a new
1396
+ /// `session.description` event for the client to answer as usual.
1397
+ pub async fn restart_ice(&self) -> Result<()> {
1398
+ let offer = self.current_peer()?.restart_ice().await?;
1399
+ self.inner.notify(SessionEvent::Description {
1400
+ generation: self.inner.generation,
1401
+ sdp: offer,
1402
+ });
1403
+ Ok(())
752
1404
  }
753
1405
 
754
1406
  pub async fn add_candidate(
@@ -757,8 +1409,7 @@ impl Session {
757
1409
  sdp_mid: Option<String>,
758
1410
  sdp_m_line_index: Option<u16>,
759
1411
  ) -> Result<()> {
760
- self.inner
761
- .peer
1412
+ self.current_peer()?
762
1413
  .add_candidate(candidate, sdp_mid, sdp_m_line_index)
763
1414
  .await
764
1415
  }
@@ -819,6 +1470,10 @@ impl Inner {
819
1470
  }
820
1471
  self.control_open.store(false, Ordering::SeqCst);
821
1472
  self.pipeline.store(false, Ordering::SeqCst);
1473
+ drop(lock(&self.encoded).take());
1474
+ if let Ok(mut indicator) = self.indicator.lock() {
1475
+ indicator.take();
1476
+ }
822
1477
  // Release first, then tear down: a stuck modifier is the one failure the
823
1478
  // user cannot undo by reconnecting.
824
1479
  if let Ok(mut input) = self.input.lock() {
@@ -827,14 +1482,16 @@ impl Inner {
827
1482
  }
828
1483
  *input = None;
829
1484
  }
830
- let _ = self
831
- .peer
832
- .send_control(
833
- &serde_json::to_string(&ControlReply::Revoked { reason })
834
- .unwrap_or_else(|_| String::from(r#"{"kind":"revoked","reason":"closed"}"#)),
835
- )
836
- .await;
837
- self.peer.close().await;
1485
+ if let Some(peer) = self.peer.lock().ok().and_then(|peer| peer.clone()) {
1486
+ let _ = peer
1487
+ .send_control(
1488
+ &serde_json::to_string(&ControlReply::Revoked { reason }).unwrap_or_else(
1489
+ |_| String::from(r#"{"kind":"revoked","reason":"closed"}"#),
1490
+ ),
1491
+ )
1492
+ .await;
1493
+ peer.close().await;
1494
+ }
838
1495
  // Dropping the capture stops the PipeWire stream and joins its thread,
839
1496
  // which is what releases the compositor's consent for this session. It
840
1497
  // has to happen synchronously, not when the last handle happens to fall
@@ -861,7 +1518,10 @@ impl Inner {
861
1518
  }
862
1519
 
863
1520
  fn clipboard_refusal(&self) -> Option<(&'static str, &'static str)> {
864
- if self.source.source_type.as_deref() == Some("x11-root") || !wayland_clipboard_available()
1521
+ if matches!(
1522
+ self.source.source_type.as_deref(),
1523
+ Some("x11-root") | Some("encoded")
1524
+ ) || !wayland_clipboard_available()
865
1525
  {
866
1526
  return Some((
867
1527
  "clipboard-unsupported",
@@ -884,9 +1544,14 @@ impl Inner {
884
1544
 
885
1545
  fn reply(&self, payload: &ControlReply<'_>) -> Result<()> {
886
1546
  let text = serde_json::to_string(payload)?;
1547
+ let Some(peer) = self.peer.lock().ok().and_then(|peer| peer.clone()) else {
1548
+ // An encoded source has no peer until its first access unit, so a
1549
+ // rejection raised before then has nowhere to go; the request that
1550
+ // caused it is refused in the same breath.
1551
+ return Ok(());
1552
+ };
887
1553
  // The channel is asynchronous; a failure here means the peer is gone,
888
1554
  // which the connection-state handler closes anyway.
889
- let peer = self.peer.clone();
890
1555
  tokio::spawn(async move {
891
1556
  let _ = peer.send_control(&text).await;
892
1557
  });
@@ -922,6 +1587,30 @@ impl Inner {
922
1587
  }
923
1588
  *last = seq;
924
1589
  }
1590
+ let feedback = match &message {
1591
+ ControlMessage::Pointer { phase, x, y, .. } if lock(&self.encoded).is_none() => {
1592
+ let (x, y) = to_source_pixels(*x, *y, self.encoded_size(), &self.source);
1593
+ Some((
1594
+ if matches!(phase, PointerPhase::Down) {
1595
+ 'C'
1596
+ } else {
1597
+ 'M'
1598
+ },
1599
+ x,
1600
+ y,
1601
+ ))
1602
+ }
1603
+ ControlMessage::Text { text, .. } if !text.is_empty() => Some(('T', -1, -1)),
1604
+ ControlMessage::Key { down: true, .. } => Some(('T', -1, -1)),
1605
+ _ => None,
1606
+ };
1607
+ // A source the consumer feeds is a device the consumer drives. Nothing is
1608
+ // applied to this machine: the message travels back as an event, and the
1609
+ // consumer injects it where the picture actually came from.
1610
+ if lock(&self.encoded).is_some() {
1611
+ self.forward(message);
1612
+ return;
1613
+ }
925
1614
  let outcome = match message {
926
1615
  ControlMessage::Pointer {
927
1616
  phase,
@@ -958,6 +1647,13 @@ impl Inner {
958
1647
  };
959
1648
  match outcome {
960
1649
  Ok(()) => {
1650
+ if let Some((kind, x, y)) = feedback {
1651
+ if let Ok(mut indicator) = self.indicator.lock() {
1652
+ if let Some(indicator) = indicator.as_mut() {
1653
+ indicator.event(kind, x, y);
1654
+ }
1655
+ }
1656
+ }
961
1657
  if let Ok(mut m) = self.metrics.lock() {
962
1658
  m.input_applied += 1;
963
1659
  }
@@ -967,6 +1663,41 @@ impl Inner {
967
1663
  }
968
1664
  }
969
1665
 
1666
+ /// Hand one client action back to the consumer, which owns the device this
1667
+ /// stream came from. The audit trail is the event itself and the count in
1668
+ /// `session.metrics`; nothing was applied here.
1669
+ fn forward(&self, message: ControlMessage) {
1670
+ let seq = message.seq();
1671
+ // The clipboard belongs to the device the consumer drives, and this
1672
+ // engine has none for this source; saying so is better than a request
1673
+ // that never answers.
1674
+ match &message {
1675
+ ControlMessage::ClipboardRead { request, .. }
1676
+ | ControlMessage::ClipboardWrite { request, .. } => {
1677
+ let _ = self.reply(&ControlReply::Clipboard {
1678
+ request,
1679
+ text: String::new(),
1680
+ truncated: false,
1681
+ error: Some("this source's clipboard belongs to the consumer"),
1682
+ });
1683
+ return;
1684
+ }
1685
+ _ => {}
1686
+ }
1687
+ let input = match serde_json::to_value(&message) {
1688
+ Ok(input) => input,
1689
+ Err(_) => {
1690
+ self.reject(seq, "operation", "unrecognised control message");
1691
+ return;
1692
+ }
1693
+ };
1694
+ self.notify(SessionEvent::Input { input });
1695
+ if let Ok(mut m) = self.metrics.lock() {
1696
+ m.input_forwarded += 1;
1697
+ }
1698
+ let _ = self.reply(&ControlReply::Ack { seq });
1699
+ }
1700
+
970
1701
  fn with_input<T>(
971
1702
  &self,
972
1703
  action: impl FnOnce(&mut InputTarget) -> Result<T>,
@@ -1043,18 +1774,27 @@ impl Inner {
1043
1774
  }
1044
1775
 
1045
1776
  let stroke = {
1046
- let layout = self
1777
+ let held = self
1047
1778
  .layout
1048
1779
  .lock()
1049
1780
  .map_err(|_| ("session", String::from("no keyboard layout")))?;
1781
+ let layout = held
1782
+ .as_ref()
1783
+ .ok_or(("session", String::from("no keyboard layout")))?;
1050
1784
  if let Some(name) = name {
1051
1785
  keymap::named_key(&name)
1052
1786
  .ok_or(("text-unsupported", format!("unknown key {name}")))?
1053
1787
  } else if let Some(character) = character {
1054
- let wanted = character
1055
- .chars()
1788
+ let mut characters = character.chars();
1789
+ let wanted = characters
1056
1790
  .next()
1057
1791
  .ok_or(("text-unsupported", String::from("empty character")))?;
1792
+ if characters.next().is_some() {
1793
+ return Err((
1794
+ "text-unsupported",
1795
+ String::from("character must contain exactly one Unicode scalar"),
1796
+ ));
1797
+ }
1058
1798
  layout.keystroke_for_char(wanted).ok_or((
1059
1799
  "text-unsupported",
1060
1800
  format!("the active layout cannot produce {wanted:?}"),
@@ -1080,42 +1820,63 @@ impl Inner {
1080
1820
  if text.len() > 4096 {
1081
1821
  return Err(("text-too-large", String::from("text exceeds 4096 bytes")));
1082
1822
  }
1083
- let layout = self
1084
- .layout
1085
- .lock()
1086
- .map_err(|_| ("session", String::from("no keyboard layout")))?;
1087
- let (plan, unreachable) = layout.plan_text(text);
1088
- drop(layout);
1089
- if !unreachable.is_empty() {
1090
- return Err((
1091
- "text-unsupported",
1092
- format!("the active layout cannot produce {unreachable:?}; use the clipboard"),
1093
- ));
1823
+ #[cfg(target_os = "macos")]
1824
+ {
1825
+ let _ = seq;
1826
+ return self.with_input(|target| target.unicode_text(text));
1094
1827
  }
1095
- let _ = seq;
1096
- self.with_input(|target| {
1097
- // Refuse an unsupported physical key before typing any prefix or
1098
- // holding a modifier. X11 already consumes evdev identities.
1099
- target.check_keys(
1100
- plan.iter()
1101
- .flatten()
1102
- .flat_map(|stroke| std::iter::once(stroke.code).chain(stroke.modifiers())),
1103
- )?;
1104
- for keystroke in plan {
1105
- for stroke in keystroke {
1106
- let modifiers = stroke.modifiers();
1107
- for modifier in &modifiers {
1108
- target.modifier(*modifier, true, false)?;
1828
+ #[cfg(target_os = "linux")]
1829
+ {
1830
+ let held = self
1831
+ .layout
1832
+ .lock()
1833
+ .map_err(|_| ("session", String::from("no keyboard layout")))?;
1834
+ let layout = held
1835
+ .as_ref()
1836
+ .ok_or(("session", String::from("no keyboard layout")))?;
1837
+ let (plan, unreachable) = layout.plan_text(text);
1838
+ drop(held);
1839
+ if !unreachable.is_empty() {
1840
+ return Err((
1841
+ "text-unsupported",
1842
+ format!("the active layout cannot produce {unreachable:?}; use the clipboard"),
1843
+ ));
1844
+ }
1845
+ let _ = seq;
1846
+ self.with_input(|target| {
1847
+ // Uinput's immediate down/up pairs can be collapsed by a compositor's
1848
+ // event loop. XTest flushes each edge, so pace only the virtual device.
1849
+ let paced = matches!(&target.applier, Applier::Uinput(_));
1850
+ let pause = || {
1851
+ if paced {
1852
+ std::thread::sleep(std::time::Duration::from_millis(5));
1109
1853
  }
1110
- target.key(stroke.code, true)?;
1111
- target.key(stroke.code, false)?;
1112
- for modifier in modifiers.iter().rev() {
1113
- target.modifier(*modifier, false, false)?;
1854
+ };
1855
+ target.check_keys(
1856
+ plan.iter()
1857
+ .flatten()
1858
+ .flat_map(|stroke| std::iter::once(stroke.code).chain(stroke.modifiers())),
1859
+ )?;
1860
+ for keystroke in plan {
1861
+ for stroke in keystroke {
1862
+ let modifiers = stroke.modifiers();
1863
+ for modifier in &modifiers {
1864
+ target.modifier(*modifier, true, false)?;
1865
+ pause();
1866
+ }
1867
+ target.key(stroke.code, true)?;
1868
+ pause();
1869
+ target.key(stroke.code, false)?;
1870
+ pause();
1871
+ for modifier in modifiers.iter().rev() {
1872
+ target.modifier(*modifier, false, false)?;
1873
+ pause();
1874
+ }
1114
1875
  }
1115
1876
  }
1116
- }
1117
- Ok(())
1118
- })
1877
+ Ok(())
1878
+ })
1879
+ }
1119
1880
  }
1120
1881
 
1121
1882
  fn clipboard_read(self: &Arc<Self>, request: String) {
@@ -1331,6 +2092,7 @@ enum Pass {
1331
2092
  /// the desktop goes still the last frame is coded once more, sharp.
1332
2093
  fn spawn_pipeline(
1333
2094
  inner: &Arc<Inner>,
2095
+ peer: Arc<VideoPeer>,
1334
2096
  frame_rx: FrameReceiver,
1335
2097
  running: Arc<AtomicBool>,
1336
2098
  max_fps: u32,
@@ -1359,7 +2121,7 @@ fn spawn_pipeline(
1359
2121
  let slot = last_sent.map_or(Duration::ZERO, |at| {
1360
2122
  interval.saturating_sub(now.duration_since(at))
1361
2123
  });
1362
- let connected = inner.peer.is_connected();
2124
+ let connected = peer.is_connected();
1363
2125
  let wait = if !connected {
1364
2126
  // Nothing sent before the transport connects arrives; the
1365
2127
  // first frame after it is a key frame.
@@ -1395,15 +2157,17 @@ fn spawn_pipeline(
1395
2157
  if !running.load(Ordering::Relaxed) {
1396
2158
  break;
1397
2159
  }
1398
- if inner.peer.take_keyframe_request() || !connected {
2160
+ if peer.take_keyframe_request() || !connected {
1399
2161
  keyframe = true;
1400
2162
  }
1401
- let reports = inner.peer.take_loss_reports();
2163
+ let reports = peer.take_loss_reports();
1402
2164
  if let Some(kbps) = rate.update(&reports, Instant::now()) {
1403
- let applied = inner
1404
- .encoder
1405
- .lock()
1406
- .map(|mut encoder| encoder.set_bitrate(kbps));
2165
+ let applied = inner.encoder.lock().map(|mut encoder| {
2166
+ encoder
2167
+ .as_mut()
2168
+ .map(|encoder| encoder.set_bitrate(kbps))
2169
+ .unwrap_or(Ok(()))
2170
+ });
1407
2171
  if matches!(applied, Ok(Ok(()))) {
1408
2172
  if let Ok(mut m) = inner.metrics.lock() {
1409
2173
  m.target_kbps = kbps;
@@ -1434,10 +2198,13 @@ fn spawn_pipeline(
1434
2198
 
1435
2199
  let started = Instant::now();
1436
2200
  let packet = {
1437
- let mut encoder = match inner.encoder.lock() {
1438
- Ok(encoder) => encoder,
2201
+ let mut held = match inner.encoder.lock() {
2202
+ Ok(held) => held,
1439
2203
  Err(_) => break,
1440
2204
  };
2205
+ let Some(encoder) = held.as_mut() else {
2206
+ break;
2207
+ };
1441
2208
  match pass {
1442
2209
  Pass::Motion { keyframe } => encoder.encode(frame, keyframe),
1443
2210
  Pass::Refine => encoder.refine(frame),
@@ -1488,7 +2255,6 @@ fn spawn_pipeline(
1488
2255
  Pass::Keepalive => {}
1489
2256
  }
1490
2257
  last_sent = Some(now);
1491
- let peer = inner.peer.clone();
1492
2258
  if let Err(error) =
1493
2259
  handle.block_on(peer.send_frame(&packet.data, packet.keyframe, now))
1494
2260
  {
@@ -1510,6 +2276,113 @@ fn spawn_pipeline(
1510
2276
  .ok();
1511
2277
  }
1512
2278
 
2279
+ /// Packetize what a consumer feeds an encoded source, building the peer on the
2280
+ /// first access unit that carries an SPS.
2281
+ ///
2282
+ /// That order is forced by the contract: the offer must name the profile the
2283
+ /// stream actually is, and the only place that is written down is the stream's
2284
+ /// own SPS. Until one arrives there is nothing that could be offered.
2285
+ fn spawn_encoded(inner: &Arc<Inner>, mut feed: tokio_mpsc::Receiver<FedAccessUnit>) {
2286
+ let inner = inner.clone();
2287
+ tokio::spawn(async move {
2288
+ while let Some(unit) = feed.recv().await {
2289
+ if inner.closed.load(Ordering::SeqCst) {
2290
+ return;
2291
+ }
2292
+ if current_peer(&inner).is_none() {
2293
+ let Some(profile) = h264_profile_level_id(&unit.data) else {
2294
+ if let Ok(mut m) = inner.metrics.lock() {
2295
+ m.dropped_frames += 1;
2296
+ }
2297
+ continue;
2298
+ };
2299
+ let (transport, peer_events, codec) = {
2300
+ let encoded = lock(&inner.encoded);
2301
+ let Some(encoded) = encoded.as_ref() else {
2302
+ return;
2303
+ };
2304
+ let codec = match encoded.codec {
2305
+ EncodedCodec::H264 => VideoCodec::H264 {
2306
+ profile_level_id: profile,
2307
+ },
2308
+ };
2309
+ (
2310
+ encoded.transport.clone(),
2311
+ encoded.peer_events.clone(),
2312
+ codec,
2313
+ )
2314
+ };
2315
+ let offered = VideoPeer::offer(transport, peer_events, codec).await;
2316
+ match offered {
2317
+ Ok((peer, offer)) => {
2318
+ let peer = Arc::new(peer);
2319
+ let stored = {
2320
+ let mut held = lock(&inner.peer);
2321
+ if inner.closed.load(Ordering::SeqCst) {
2322
+ false
2323
+ } else {
2324
+ *held = Some(peer.clone());
2325
+ true
2326
+ }
2327
+ };
2328
+ if !stored {
2329
+ peer.close().await;
2330
+ return;
2331
+ }
2332
+ inner.notify(SessionEvent::Description {
2333
+ generation: inner.generation,
2334
+ sdp: offer,
2335
+ });
2336
+ }
2337
+ Err(error) => {
2338
+ revoke(
2339
+ &inner,
2340
+ format!("the transport refused the encoded stream: {error:#}"),
2341
+ )
2342
+ .await;
2343
+ return;
2344
+ }
2345
+ }
2346
+ }
2347
+ let Some(peer) = current_peer(&inner) else {
2348
+ return;
2349
+ };
2350
+ let started = Instant::now();
2351
+ let sent = peer.send_frame(&unit.data, unit.keyframe, started).await;
2352
+ match sent {
2353
+ Ok(()) => {
2354
+ if let Ok(mut m) = inner.metrics.lock() {
2355
+ m.encoded_frames += 1;
2356
+ m.encoded_bytes += unit.data.len() as u64;
2357
+ m.encode_micros += started.elapsed().as_micros() as u64;
2358
+ if unit.keyframe {
2359
+ m.key_frames += 1;
2360
+ }
2361
+ }
2362
+ }
2363
+ Err(error) => {
2364
+ eprintln!("the video track refused a fed access unit: {error:#}");
2365
+ revoke(&inner, String::from("the connection to the phone was lost")).await;
2366
+ return;
2367
+ }
2368
+ }
2369
+ }
2370
+ });
2371
+ }
2372
+
2373
+ fn current_peer(inner: &Arc<Inner>) -> Option<Arc<VideoPeer>> {
2374
+ inner.peer.lock().ok().and_then(|peer| peer.clone())
2375
+ }
2376
+
2377
+ /// End the session with a reason the consumer can show, exactly as the encode
2378
+ /// pipeline does when its transport goes away.
2379
+ async fn revoke(inner: &Arc<Inner>, reason: String) {
2380
+ inner.notify(SessionEvent::Revoked {
2381
+ reason: reason.clone(),
2382
+ });
2383
+ inner.close(&reason).await;
2384
+ }
2385
+
1513
2386
  fn spawn_peer_events(inner: &Arc<Inner>, mut events: tokio_mpsc::UnboundedReceiver<PeerEvent>) {
1514
2387
  let inner = inner.clone();
1515
2388
  tokio::spawn(async move {
@@ -1532,6 +2405,16 @@ fn spawn_peer_events(inner: &Arc<Inner>, mut events: tokio_mpsc::UnboundedReceiv
1532
2405
  }
1533
2406
  PeerEvent::State(state) => {
1534
2407
  use webrtc::peer_connection::RTCPeerConnectionState as State;
2408
+ // The far end needs a reference frame to start from, and only
2409
+ // the consumer can make one for a stream it encodes. A
2410
+ // captured desktop manages this in its encode loop; a fed one
2411
+ // has to ask — and everything sent before the transport
2412
+ // connected was dropped, so this is not optional.
2413
+ if lock(&inner.encoded).is_some() && matches!(state, State::Connected) {
2414
+ inner.notify(SessionEvent::KeyframeRequest {
2415
+ generation: inner.generation,
2416
+ });
2417
+ }
1535
2418
  // ICE loss need not close SCTP. Never leave a drag or chord
1536
2419
  // held while waiting for that independent notification.
1537
2420
  if matches!(state, State::Disconnected | State::Failed | State::Closed) {
@@ -1578,6 +2461,11 @@ fn spawn_peer_events(inner: &Arc<Inner>, mut events: tokio_mpsc::UnboundedReceiv
1578
2461
  }
1579
2462
  }
1580
2463
  }
2464
+ PeerEvent::KeyframeRequest => {
2465
+ inner.notify(SessionEvent::KeyframeRequest {
2466
+ generation: inner.generation,
2467
+ });
2468
+ }
1581
2469
  PeerEvent::ControlMessage(text) => {
1582
2470
  match serde_json::from_str::<ControlMessage>(&text) {
1583
2471
  Ok(message) => inner.apply(message),
@@ -1639,6 +2527,79 @@ fn opaque_id() -> String {
1639
2527
  mod tests {
1640
2528
  use super::*;
1641
2529
 
2530
+ #[tokio::test]
2531
+ async fn a_closed_encoded_session_ends_the_tasks_its_channels_park() {
2532
+ let (events, _received) = tokio_mpsc::unbounded_channel();
2533
+ let (peer_events, peer_events_rx) = tokio_mpsc::unbounded_channel::<PeerEvent>();
2534
+ let (feed, feed_rx) = tokio_mpsc::channel::<FedAccessUnit>(FEED_BACKLOG);
2535
+ let inner = Arc::new(Inner {
2536
+ id: String::from("encoded-session"),
2537
+ generation: 1,
2538
+ permissions: Vec::new(),
2539
+ source: SelectedSource {
2540
+ node_id: 0,
2541
+ width: 640,
2542
+ height: 480,
2543
+ position: None,
2544
+ source_type: Some(String::from("encoded")),
2545
+ origin_x: 0,
2546
+ origin_y: 0,
2547
+ },
2548
+ geometry: serde_json::json!({
2549
+ "source": { "width": 640, "height": 480 },
2550
+ "encoded": { "width": 640, "height": 480 },
2551
+ "origin": { "x": 0, "y": 0 },
2552
+ }),
2553
+ metrics: Arc::new(Mutex::new(Metrics::default())),
2554
+ latest: Arc::new(Mutex::new(None)),
2555
+ capture_error: Arc::new(Mutex::new(None)),
2556
+ frame_changes: tokio::sync::watch::channel(0).1,
2557
+ peer: Mutex::new(None),
2558
+ encoder: Mutex::new(None),
2559
+ input: Mutex::new(None),
2560
+ indicator: Mutex::new(None),
2561
+ capture: Mutex::new(None),
2562
+ layout: Mutex::new(None),
2563
+ last_seq: Mutex::new(0),
2564
+ control_open: AtomicBool::new(false),
2565
+ closed: AtomicBool::new(false),
2566
+ pipeline: Arc::new(AtomicBool::new(false)),
2567
+ events,
2568
+ encoded: Mutex::new(Some(Encoded {
2569
+ codec: EncodedCodec::H264,
2570
+ transport: TransportOptions {
2571
+ ice_servers: Vec::new(),
2572
+ loopback_tcp: false,
2573
+ pace_bps: 20_000_000.0,
2574
+ },
2575
+ peer_events,
2576
+ feed,
2577
+ })),
2578
+ });
2579
+ spawn_peer_events(&inner, peer_events_rx);
2580
+ spawn_encoded(&inner, feed_rx);
2581
+ inner.close("test").await;
2582
+ for _ in 0..100 {
2583
+ if Arc::strong_count(&inner) == 1 {
2584
+ break;
2585
+ }
2586
+ tokio::time::sleep(Duration::from_millis(5)).await;
2587
+ }
2588
+ assert_eq!(
2589
+ Arc::strong_count(&inner),
2590
+ 1,
2591
+ "closing must end the tasks whose channels the close takes away",
2592
+ );
2593
+ }
2594
+
2595
+ #[test]
2596
+ fn tile_damage_at_origin_does_not_underflow_or_hide_other_tiles() {
2597
+ assert_eq!(
2598
+ dirty_regions(&[1, 0, 0, 1], &[0; 4], 64, 64),
2599
+ vec![[0, 0, 32, 32], [32, 32, 32, 32]]
2600
+ );
2601
+ }
2602
+
1642
2603
  #[test]
1643
2604
  fn a_small_source_is_never_upscaled() {
1644
2605
  assert_eq!(fit(1000, 600, 1280, 800), (1000, 600));
@@ -1693,6 +2654,7 @@ mod tests {
1693
2654
  pace_bps: 20_000_000.0,
1694
2655
  },
1695
2656
  peer_events,
2657
+ VideoCodec::Vp9,
1696
2658
  )
1697
2659
  .await
1698
2660
  .expect("a peer connection builds without a network");
@@ -1718,8 +2680,11 @@ mod tests {
1718
2680
  "origin": { "x": 0, "y": 0 },
1719
2681
  }),
1720
2682
  metrics: Arc::new(Mutex::new(Metrics::default())),
1721
- peer: Arc::new(peer),
1722
- encoder: Mutex::new(Encoder::new(64, 64, 1000, 30, 1).expect("an encoder")),
2683
+ latest: Arc::new(Mutex::new(None)),
2684
+ capture_error: Arc::new(Mutex::new(None)),
2685
+ frame_changes: tokio::sync::watch::channel(0).1,
2686
+ peer: Mutex::new(Some(Arc::new(peer))),
2687
+ encoder: Mutex::new(Some(Encoder::new(64, 64, 1000, 30, 1).expect("an encoder"))),
1723
2688
  input: Mutex::new(Some(InputTarget {
1724
2689
  applier: Applier::Recording(recorded.clone()),
1725
2690
  held: HeldState::default(),
@@ -1729,12 +2694,14 @@ mod tests {
1729
2694
  wheel_rest: (0.0, 0.0),
1730
2695
  })),
1731
2696
  capture: Mutex::new(None),
1732
- layout: Mutex::new(Layout::from_environment().expect("a keymap")),
2697
+ indicator: Mutex::new(None),
2698
+ layout: Mutex::new(Some(Layout::from_environment().expect("a keymap"))),
1733
2699
  last_seq: Mutex::new(0),
1734
2700
  control_open: AtomicBool::new(true),
1735
2701
  closed: AtomicBool::new(false),
1736
2702
  pipeline: Arc::new(AtomicBool::new(true)),
1737
2703
  events,
2704
+ encoded: Mutex::new(None),
1738
2705
  });
1739
2706
  (inner, recorded)
1740
2707
  }
@@ -2015,12 +2982,41 @@ mod tests {
2015
2982
  assert_eq!(inner.metrics.lock().unwrap().input_rejected, 2);
2016
2983
  }
2017
2984
 
2985
+ #[tokio::test]
2986
+ async fn a_stopped_capture_never_serves_the_last_frame() {
2987
+ let (events, _) = tokio_mpsc::unbounded_channel();
2988
+ let (mut inner, _) = test_inner(events).await;
2989
+ Arc::get_mut(&mut inner)
2990
+ .unwrap()
2991
+ .permissions
2992
+ .push(Permission::View);
2993
+ let session = Session { inner };
2994
+ *lock(&session.inner.latest) =
2995
+ Some((1, Instant::now(), 2, 2, vec![0; 16], vec![1], vec![]));
2996
+ *lock(&session.inner.capture_error) = Some(String::from("SCStreamErrorDomain -3821"));
2997
+ assert_eq!(
2998
+ session.frame(None, "", None).unwrap_err().code,
2999
+ "stream-stopped"
3000
+ );
3001
+ assert_eq!(
3002
+ session.wait_frame(None, None, 10).await.unwrap_err().code,
3003
+ "stream-stopped"
3004
+ );
3005
+ }
3006
+
2018
3007
  #[tokio::test]
2019
3008
  async fn a_frame_the_encoder_refuses_ends_the_session_with_a_reason() {
2020
3009
  let (events, mut received) = tokio_mpsc::unbounded_channel();
2021
3010
  let (inner, _recorded) = test_inner(events).await;
2022
3011
  let (frame_tx, frame_rx) = latest_frame();
2023
- spawn_pipeline(&inner, frame_rx, Arc::new(AtomicBool::new(true)), 30, 1000);
3012
+ spawn_pipeline(
3013
+ &inner,
3014
+ current_peer(&inner).expect("the test's peer"),
3015
+ frame_rx,
3016
+ Arc::new(AtomicBool::new(true)),
3017
+ 30,
3018
+ 1000,
3019
+ );
2024
3020
 
2025
3021
  // The encoder is 64x64; a 32x32 frame is the dimension mismatch that
2026
3022
  // used to be counted and dropped behind a permanently black picture.
@@ -2052,6 +3048,210 @@ mod tests {
2052
3048
  );
2053
3049
  }
2054
3050
 
3051
+ /// A consumer-fed stream: a keyframe carrying SPS, PPS and an IDR slice.
3052
+ fn fed_keyframe() -> Vec<u8> {
3053
+ let mut stream = vec![0x00, 0x00, 0x00, 0x01];
3054
+ stream.extend_from_slice(&[0x67, 0x42, 0xc0, 0x29, 0x8c, 0x8d]);
3055
+ stream.extend_from_slice(&[0x00, 0x00, 0x01, 0x68, 0xce, 0x3c, 0x80]);
3056
+ stream.extend_from_slice(&[0x00, 0x00, 0x01, 0x65, 0x88, 0x84, 0x00]);
3057
+ stream
3058
+ }
3059
+
3060
+ fn encoded_open() -> OpenRequest {
3061
+ OpenRequest {
3062
+ source: Some(SourceRequest::Encoded {
3063
+ codec: EncodedCodec::H264,
3064
+ width: 486,
3065
+ height: 1080,
3066
+ }),
3067
+ permissions: vec![Permission::View, Permission::Control],
3068
+ max_width: 3840,
3069
+ max_height: 2160,
3070
+ bitrate_kbps: 0,
3071
+ max_fps: 30,
3072
+ ice_servers: Vec::new(),
3073
+ restore_token: None,
3074
+ ttl: Some(Duration::from_secs(30)),
3075
+ loopback_tcp: false,
3076
+ agent_indicator: false,
3077
+ }
3078
+ }
3079
+
3080
+ async fn next_notice(received: &mut tokio_mpsc::UnboundedReceiver<Notice>) -> Notice {
3081
+ tokio::time::timeout(Duration::from_secs(5), received.recv())
3082
+ .await
3083
+ .expect("a notification arrives")
3084
+ .expect("the channel stays open")
3085
+ }
3086
+
3087
+ #[tokio::test]
3088
+ async fn an_encoded_session_opens_without_capture_encoder_or_input() {
3089
+ let (events, _received) = tokio_mpsc::unbounded_channel();
3090
+ let session = Session::open(encoded_open(), events)
3091
+ .await
3092
+ .expect("an encoded source needs no display and no input device");
3093
+ let inner = &session.inner;
3094
+ assert_eq!(session.restore_source(), "encoded");
3095
+ assert_eq!(session.geometry()["encoded"]["width"], 486);
3096
+ assert_eq!(session.geometry()["encoded"]["height"], 1080);
3097
+ assert!(
3098
+ inner.encoder.lock().unwrap().is_none(),
3099
+ "the consumer is the encoder for this source"
3100
+ );
3101
+ assert!(
3102
+ inner.capture.lock().unwrap().is_none(),
3103
+ "there is nothing to capture"
3104
+ );
3105
+ assert!(
3106
+ inner.input.lock().unwrap().is_none(),
3107
+ "the client's input belongs to the consumer's device"
3108
+ );
3109
+ assert!(
3110
+ inner.peer.lock().unwrap().is_none(),
3111
+ "the offer waits for the stream's own SPS"
3112
+ );
3113
+ }
3114
+
3115
+ #[tokio::test]
3116
+ async fn the_offer_for_a_fed_stream_names_that_streams_own_profile() {
3117
+ let (events, mut received) = tokio_mpsc::unbounded_channel();
3118
+ let session = Session::open(encoded_open(), events)
3119
+ .await
3120
+ .expect("an encoded session");
3121
+ let first = next_notice(&mut received).await;
3122
+ assert!(
3123
+ matches!(
3124
+ first.event,
3125
+ SessionEvent::State {
3126
+ capture: "consented",
3127
+ ..
3128
+ }
3129
+ ),
3130
+ "the session is open before any offer exists: {:?}",
3131
+ first.event,
3132
+ );
3133
+
3134
+ session
3135
+ .feed(true, fed_keyframe())
3136
+ .expect("a keyframe is taken");
3137
+ let offered = loop {
3138
+ let notice = next_notice(&mut received).await;
3139
+ if let SessionEvent::Description { sdp, .. } = notice.event {
3140
+ break sdp;
3141
+ }
3142
+ };
3143
+ assert!(
3144
+ offered.contains("H264/90000"),
3145
+ "the fed track is offered as H.264: {offered}"
3146
+ );
3147
+ assert!(
3148
+ offered.contains("profile-level-id=42c029"),
3149
+ "the profile-level-id comes from the fed SPS, not a default: {offered}"
3150
+ );
3151
+ assert_eq!(session.metrics().fed_frames, 1);
3152
+ }
3153
+
3154
+ #[tokio::test]
3155
+ async fn a_fed_stream_forwards_its_controls_instead_of_applying_them() {
3156
+ let (events, mut received) = tokio_mpsc::unbounded_channel();
3157
+ let session = Session::open(encoded_open(), events)
3158
+ .await
3159
+ .expect("an encoded session");
3160
+ session.inner.control_open.store(true, Ordering::SeqCst);
3161
+
3162
+ session.inner.apply(ControlMessage::Pointer {
3163
+ phase: PointerPhase::Down,
3164
+ x: 10,
3165
+ y: 20,
3166
+ button: 1,
3167
+ seq: 1,
3168
+ });
3169
+
3170
+ let forwarded = loop {
3171
+ let notice = next_notice(&mut received).await;
3172
+ if let SessionEvent::Input { input } = notice.event {
3173
+ break input;
3174
+ }
3175
+ };
3176
+ assert_eq!(forwarded["kind"], "pointer");
3177
+ assert_eq!(forwarded["phase"], "down");
3178
+ assert_eq!(forwarded["x"], 10);
3179
+ assert_eq!(forwarded["seq"], 1);
3180
+ assert_eq!(session.metrics().input_forwarded, 1);
3181
+ assert_eq!(
3182
+ session.metrics().input_applied,
3183
+ 0,
3184
+ "nothing was applied to this machine"
3185
+ );
3186
+
3187
+ // The stream's own coordinates are what travels, so an encoded surface
3188
+ // cannot be driven by a coordinate the far end never rendered.
3189
+ session.inner.apply(ControlMessage::Pointer {
3190
+ phase: PointerPhase::Move,
3191
+ x: 9_000,
3192
+ y: 20,
3193
+ button: 1,
3194
+ seq: 2,
3195
+ });
3196
+ assert_eq!(session.metrics().input_forwarded, 2);
3197
+ }
3198
+
3199
+ #[tokio::test]
3200
+ async fn a_capture_session_refuses_a_fed_stream() {
3201
+ let (events, _received) = tokio_mpsc::unbounded_channel();
3202
+ let (inner, _recorded) = test_inner(events).await;
3203
+ let session = Session { inner };
3204
+ assert_eq!(
3205
+ session.feed(true, fed_keyframe()).unwrap_err().code,
3206
+ "operation",
3207
+ "only an encoded source takes access units",
3208
+ );
3209
+ }
3210
+
3211
+ #[tokio::test]
3212
+ async fn an_encoded_session_has_no_frame_to_read() {
3213
+ let (events, _received) = tokio_mpsc::unbounded_channel();
3214
+ let session = Session::open(encoded_open(), events)
3215
+ .await
3216
+ .expect("an encoded session");
3217
+ assert_eq!(session.frame(None, "", None).unwrap_err().code, "operation");
3218
+ assert_eq!(
3219
+ session.wait_frame(None, None, 10).await.unwrap_err().code,
3220
+ "operation"
3221
+ );
3222
+ assert_eq!(
3223
+ session.read_clipboard().await.unwrap_err(),
3224
+ "clipboard is unavailable for this desktop source",
3225
+ );
3226
+ }
3227
+
3228
+ #[tokio::test]
3229
+ async fn opening_without_view_permission_is_refused_before_capture() {
3230
+ let (events, _received) = tokio_mpsc::unbounded_channel();
3231
+ let result = Session::open(
3232
+ OpenRequest {
3233
+ source: None,
3234
+ permissions: vec![Permission::Control],
3235
+ max_width: 640,
3236
+ max_height: 480,
3237
+ bitrate_kbps: 0,
3238
+ max_fps: 30,
3239
+ ice_servers: Vec::new(),
3240
+ restore_token: None,
3241
+ ttl: None,
3242
+ loopback_tcp: false,
3243
+ agent_indicator: false,
3244
+ },
3245
+ events,
3246
+ )
3247
+ .await;
3248
+ let error = match result {
3249
+ Ok(_) => panic!("a viewless session must not open"),
3250
+ Err(error) => error,
3251
+ };
3252
+ assert_eq!(error.code, "permission");
3253
+ }
3254
+
2055
3255
  #[tokio::test]
2056
3256
  async fn a_lost_transport_releases_input_then_ends_the_session_without_sctp_close() {
2057
3257
  use webrtc::peer_connection::RTCPeerConnectionState as State;
@@ -2108,7 +3308,14 @@ mod tests {
2108
3308
  let (events, _events_rx) = tokio_mpsc::unbounded_channel();
2109
3309
  let (inner, _recorded) = test_inner(events).await;
2110
3310
  let (frame_tx, frame_rx) = latest_frame();
2111
- spawn_pipeline(&inner, frame_rx, Arc::new(AtomicBool::new(true)), 20, 1000);
3311
+ spawn_pipeline(
3312
+ &inner,
3313
+ current_peer(&inner).expect("the test's peer"),
3314
+ frame_rx,
3315
+ Arc::new(AtomicBool::new(true)),
3316
+ 20,
3317
+ 1000,
3318
+ );
2112
3319
 
2113
3320
  // 40 frames over 200 ms is 200 fps; the requested 20 fps caps what leaves.
2114
3321
  for _ in 0..40 {