@desklink/host 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +3 -0
- package/README.md +50 -16
- package/bin/desklink-host.mjs +13 -2
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +5 -1
- package/dist/bridge.js.map +1 -1
- package/dist/engineProcess.d.ts.map +1 -1
- package/dist/engineProcess.js +17 -5
- package/dist/engineProcess.js.map +1 -1
- package/dist/protocol.d.ts +42 -5
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/resolveEngine.d.ts +1 -0
- package/dist/resolveEngine.d.ts.map +1 -1
- package/dist/resolveEngine.js +16 -1
- package/dist/resolveEngine.js.map +1 -1
- package/docs/BOUNDARY.md +4 -3
- package/docs/PROTOCOL.md +95 -27
- package/engine/Cargo.lock +195 -1
- package/engine/Cargo.toml +25 -5
- package/engine/build.rs +98 -23
- package/engine/native/agent_overlay_mac.m +133 -0
- package/engine/native/agent_overlay_wayland.c +192 -0
- package/engine/native/agent_overlay_x11.c +103 -0
- package/engine/native/mac_stream.mm +188 -0
- package/engine/src/capture.rs +169 -19
- package/engine/src/capture_mac.rs +145 -0
- package/engine/src/clipboard_mac.rs +182 -0
- package/engine/src/convert.rs +57 -1
- package/engine/src/indicator.rs +162 -0
- package/engine/src/input.rs +12 -0
- package/engine/src/input_mac.rs +312 -0
- package/engine/src/keymap.rs +25 -0
- package/engine/src/keymap_mac.rs +209 -0
- package/engine/src/mac.rs +722 -0
- package/engine/src/main.rs +116 -2
- package/engine/src/portal.rs +4 -0
- package/engine/src/portal_mac.rs +68 -0
- package/engine/src/protocol.rs +48 -1
- package/engine/src/session.rs +556 -62
- package/engine/src/x11.rs +23 -3
- package/engine/src/x11_mac.rs +40 -0
- package/examples/reference-client.html +33 -4
- package/package.json +2 -2
package/engine/src/session.rs
CHANGED
|
@@ -61,11 +61,81 @@ pub struct Metrics {
|
|
|
61
61
|
pub input_rejected: u64,
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
fn tile_hashes(raw: &[u8], width: usize, height: usize) -> Vec<u64> {
|
|
65
|
+
use std::hash::{Hash, Hasher};
|
|
66
|
+
let mut hashes = Vec::new();
|
|
67
|
+
for y in (0..height).step_by(32) {
|
|
68
|
+
for x in (0..width).step_by(32) {
|
|
69
|
+
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
|
70
|
+
for row in y..(y + 32).min(height) {
|
|
71
|
+
raw[(row * width + x) * 4..(row * width + (x + 32).min(width)) * 4].hash(&mut hash);
|
|
72
|
+
}
|
|
73
|
+
hashes.push(hash.finish());
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
hashes
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Group changed 32-pixel tiles without merging unrelated regions.
|
|
80
|
+
fn dirty_regions(hashes: &[u64], previous: &[u64], width: usize, height: usize) -> Vec<[usize; 4]> {
|
|
81
|
+
if hashes.len() != previous.len() {
|
|
82
|
+
return vec![[0, 0, width, height]];
|
|
83
|
+
}
|
|
84
|
+
let columns = width.div_ceil(32);
|
|
85
|
+
let mut dirty: Vec<bool> = hashes
|
|
86
|
+
.iter()
|
|
87
|
+
.zip(previous)
|
|
88
|
+
.map(|(now, before)| now != before)
|
|
89
|
+
.collect();
|
|
90
|
+
let mut regions = Vec::new();
|
|
91
|
+
for index in 0..dirty.len() {
|
|
92
|
+
if !dirty[index] {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
dirty[index] = false;
|
|
96
|
+
let mut stack = vec![index];
|
|
97
|
+
let (mut left, mut top, mut right, mut bottom) = (
|
|
98
|
+
index % columns,
|
|
99
|
+
index / columns,
|
|
100
|
+
index % columns,
|
|
101
|
+
index / columns,
|
|
102
|
+
);
|
|
103
|
+
while let Some(tile) = stack.pop() {
|
|
104
|
+
let col = tile % columns;
|
|
105
|
+
let row = tile / columns;
|
|
106
|
+
left = left.min(col);
|
|
107
|
+
right = right.max(col);
|
|
108
|
+
top = top.min(row);
|
|
109
|
+
bottom = bottom.max(row);
|
|
110
|
+
for neighbor in [
|
|
111
|
+
tile.checked_sub(columns),
|
|
112
|
+
(row + 1 < height.div_ceil(32)).then_some(tile + columns),
|
|
113
|
+
(col > 0).then(|| tile - 1),
|
|
114
|
+
(col + 1 < columns).then_some(tile + 1),
|
|
115
|
+
]
|
|
116
|
+
.into_iter()
|
|
117
|
+
.flatten()
|
|
118
|
+
{
|
|
119
|
+
if dirty[neighbor] {
|
|
120
|
+
dirty[neighbor] = false;
|
|
121
|
+
stack.push(neighbor);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
let x = left * 32;
|
|
126
|
+
let y = top * 32;
|
|
127
|
+
regions.push([
|
|
128
|
+
x,
|
|
129
|
+
y,
|
|
130
|
+
((right + 1) * 32).min(width) - x,
|
|
131
|
+
((bottom + 1) * 32).min(height) - y,
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
134
|
+
regions
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Dropping either variant stops its capture: the portal releases consent and
|
|
138
|
+
/// the X11 variant stops reading its server.
|
|
69
139
|
enum FrameSource {
|
|
70
140
|
Portal(Capture),
|
|
71
141
|
X11(X11Capture),
|
|
@@ -103,6 +173,7 @@ fn select_x11(
|
|
|
103
173
|
max_fps: u32,
|
|
104
174
|
metrics: Arc<Mutex<Metrics>>,
|
|
105
175
|
sink: capture::FrameSink,
|
|
176
|
+
on_stop: Box<dyn Fn(String) + Send>,
|
|
106
177
|
) -> Result<Selected> {
|
|
107
178
|
let desktop = Arc::new(Mutex::new(X11Desktop::connect(display)?));
|
|
108
179
|
let (width, height) = {
|
|
@@ -137,17 +208,17 @@ fn select_x11(
|
|
|
137
208
|
let started = Instant::now();
|
|
138
209
|
let frame = {
|
|
139
210
|
let mut desktop = lock(&desktop);
|
|
140
|
-
desktop.
|
|
211
|
+
desktop.capture_with_pixels(max_width, max_height)
|
|
141
212
|
};
|
|
142
213
|
match frame {
|
|
143
|
-
Ok(frame) => {
|
|
214
|
+
Ok((frame, raw)) => {
|
|
144
215
|
use std::hash::{Hash, Hasher};
|
|
145
216
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
146
|
-
|
|
217
|
+
raw.hash(&mut hasher);
|
|
147
218
|
let hash = hasher.finish();
|
|
148
219
|
if last_hash != Some(hash) {
|
|
149
220
|
last_hash = Some(hash);
|
|
150
|
-
sink(frame, sequence);
|
|
221
|
+
sink(frame, sequence, raw);
|
|
151
222
|
sequence += 1;
|
|
152
223
|
}
|
|
153
224
|
}
|
|
@@ -158,6 +229,7 @@ fn select_x11(
|
|
|
158
229
|
// A display that has gone away is not recoverable by
|
|
159
230
|
// retrying, so stop rather than spin on the error.
|
|
160
231
|
if error.to_string().contains("the X11 connection dropped") {
|
|
232
|
+
on_stop(format!("X11 capture stopped: {error}"));
|
|
161
233
|
break;
|
|
162
234
|
}
|
|
163
235
|
}
|
|
@@ -312,6 +384,19 @@ impl InputTarget {
|
|
|
312
384
|
Ok(())
|
|
313
385
|
}
|
|
314
386
|
|
|
387
|
+
#[cfg(target_os = "macos")]
|
|
388
|
+
fn unicode_text(&mut self, text: &str) -> Result<()> {
|
|
389
|
+
match &mut self.applier {
|
|
390
|
+
#[cfg(target_os = "macos")]
|
|
391
|
+
Applier::Uinput(devices) => devices.unicode_text(text),
|
|
392
|
+
#[cfg(target_os = "linux")]
|
|
393
|
+
Applier::Uinput(_) => anyhow::bail!("Unicode text input is unavailable on Linux"),
|
|
394
|
+
Applier::X11(_) => anyhow::bail!("Unicode text input is unavailable for X11"),
|
|
395
|
+
#[cfg(test)]
|
|
396
|
+
Applier::Recording(_) => Ok(()),
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
315
400
|
fn chord(&mut self, code: i16, modifiers: Vec<i16>, down: bool) -> Result<()> {
|
|
316
401
|
if down {
|
|
317
402
|
if self.chord_keys.iter().any(|(held, _)| *held == code) {
|
|
@@ -412,6 +497,7 @@ pub struct OpenRequest {
|
|
|
412
497
|
pub ttl: Option<Duration>,
|
|
413
498
|
/// Offer a passive ICE-TCP candidate on the loopback, for a forwarded client.
|
|
414
499
|
pub loopback_tcp: bool,
|
|
500
|
+
pub agent_indicator: bool,
|
|
415
501
|
}
|
|
416
502
|
|
|
417
503
|
/// Events a session raises for its consumer.
|
|
@@ -432,7 +518,12 @@ pub enum SessionEvent {
|
|
|
432
518
|
transport: String,
|
|
433
519
|
first_frame: bool,
|
|
434
520
|
},
|
|
521
|
+
Frame {
|
|
522
|
+
seq: u64,
|
|
523
|
+
damage: Vec<[usize; 4]>,
|
|
524
|
+
},
|
|
435
525
|
RestoreToken(String),
|
|
526
|
+
CaptureStopped { reason: String },
|
|
436
527
|
Revoked {
|
|
437
528
|
reason: String,
|
|
438
529
|
},
|
|
@@ -447,6 +538,8 @@ pub struct Notice {
|
|
|
447
538
|
pub event: SessionEvent,
|
|
448
539
|
}
|
|
449
540
|
|
|
541
|
+
type FrameSnapshot = (u64, Instant, usize, usize, Vec<u8>, Vec<u64>, Vec<u64>);
|
|
542
|
+
|
|
450
543
|
/// Everything a session's background tasks need, shared rather than borrowed so
|
|
451
544
|
/// the consumer can own the `Session` handle while the pipeline runs.
|
|
452
545
|
struct Inner {
|
|
@@ -456,9 +549,13 @@ struct Inner {
|
|
|
456
549
|
source: SelectedSource,
|
|
457
550
|
geometry: serde_json::Value,
|
|
458
551
|
metrics: Arc<Mutex<Metrics>>,
|
|
552
|
+
latest: Arc<Mutex<Option<FrameSnapshot>>>,
|
|
553
|
+
capture_error: Arc<Mutex<Option<String>>>,
|
|
554
|
+
frame_changes: tokio::sync::watch::Receiver<u64>,
|
|
459
555
|
peer: Arc<VideoPeer>,
|
|
460
556
|
encoder: Mutex<Encoder>,
|
|
461
557
|
input: Mutex<Option<InputTarget>>,
|
|
558
|
+
indicator: Mutex<Option<crate::indicator::Indicator>>,
|
|
462
559
|
capture: Mutex<Option<FrameSource>>,
|
|
463
560
|
layout: Mutex<Layout>,
|
|
464
561
|
last_seq: Mutex<u64>,
|
|
@@ -474,12 +571,14 @@ pub struct Session {
|
|
|
474
571
|
inner: Arc<Inner>,
|
|
475
572
|
}
|
|
476
573
|
|
|
574
|
+
#[cfg(target_os = "linux")]
|
|
477
575
|
fn wayland_clipboard_available() -> bool {
|
|
478
576
|
std::env::var("XDG_SESSION_TYPE").as_deref() == Ok("wayland")
|
|
479
577
|
&& std::env::var_os("WAYLAND_DISPLAY").is_some()
|
|
480
578
|
}
|
|
481
579
|
|
|
482
580
|
/// What the engine can actually do on this machine right now.
|
|
581
|
+
#[cfg(target_os = "linux")]
|
|
483
582
|
pub fn capabilities() -> serde_json::Value {
|
|
484
583
|
let session_kind = if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
|
485
584
|
"wayland"
|
|
@@ -537,32 +636,149 @@ pub fn capabilities() -> serde_json::Value {
|
|
|
537
636
|
})
|
|
538
637
|
}
|
|
539
638
|
|
|
639
|
+
#[cfg(target_os = "macos")]
|
|
640
|
+
fn wayland_clipboard_available() -> bool {
|
|
641
|
+
true
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
#[cfg(target_os = "macos")]
|
|
645
|
+
pub fn capabilities() -> serde_json::Value {
|
|
646
|
+
let capture_granted = unsafe { crate::mac::CGPreflightScreenCaptureAccess() };
|
|
647
|
+
let (input, input_grant, unavailable) = match crate::input::probe() {
|
|
648
|
+
Ok(()) => (true, "granted", serde_json::Value::Null),
|
|
649
|
+
Err(error) => (
|
|
650
|
+
false,
|
|
651
|
+
"missing-accessibility",
|
|
652
|
+
serde_json::json!({
|
|
653
|
+
"reason": error.reason,
|
|
654
|
+
"remedy": error.remedy,
|
|
655
|
+
}),
|
|
656
|
+
),
|
|
657
|
+
};
|
|
658
|
+
serde_json::json!({
|
|
659
|
+
"protocol": crate::protocol::PROTOCOL_VERSION,
|
|
660
|
+
"engine": format!("desklink-host/{}", env!("CARGO_PKG_VERSION")),
|
|
661
|
+
"platform": "macos",
|
|
662
|
+
"session": { "kind": "quartz", "on_console": crate::mac::on_console() },
|
|
663
|
+
"x11": { "available": false, "size": null },
|
|
664
|
+
"capture": {
|
|
665
|
+
"mechanism": "screencapturekit",
|
|
666
|
+
"backends": ["screencapturekit"],
|
|
667
|
+
"formats": ["bgra"],
|
|
668
|
+
"cursor": "embedded",
|
|
669
|
+
"audio": false,
|
|
670
|
+
"displays": crate::mac::displays(),
|
|
671
|
+
"grant": if capture_granted { "granted" } else { "missing-screen-recording" },
|
|
672
|
+
"unavailable_reason": if capture_granted { serde_json::Value::Null } else { serde_json::json!({
|
|
673
|
+
"reason": format!("Screen Recording permission has not been granted to {}.", crate::mac::tcc_responsible_app_name()),
|
|
674
|
+
"remedy": format!("Allow {} in System Settings › Privacy & Security › Screen & System Audio Recording, then reconnect.", crate::mac::tcc_responsible_app_name())
|
|
675
|
+
}) }
|
|
676
|
+
},
|
|
677
|
+
"encode": { "codecs": ["vp9"], "hardware": false },
|
|
678
|
+
"input": {
|
|
679
|
+
"mechanism": "quartz-cgevent",
|
|
680
|
+
"pointer": input,
|
|
681
|
+
"wheel": input,
|
|
682
|
+
"keyboard": input,
|
|
683
|
+
"text": ["unicode", "us-ansi-keymap"],
|
|
684
|
+
"layout": keymap::LayoutNames::from_environment().identity(),
|
|
685
|
+
"unavailable_reason": unavailable,
|
|
686
|
+
"grant": input_grant,
|
|
687
|
+
},
|
|
688
|
+
"clipboard": { "read": true, "write": true, "mime": ["text/plain;charset=utf-8"],
|
|
689
|
+
"maxBytes": clipboard::MAX_CLIPBOARD_BYTES },
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
|
|
540
693
|
impl Session {
|
|
541
694
|
pub async fn open(
|
|
542
695
|
request: OpenRequest,
|
|
543
696
|
events: tokio_mpsc::UnboundedSender<Notice>,
|
|
544
697
|
) -> std::result::Result<Self, SessionError> {
|
|
698
|
+
if !request.permissions.contains(&Permission::View) {
|
|
699
|
+
return Err(SessionError::new(
|
|
700
|
+
"permission",
|
|
701
|
+
"session.open requires the view permission",
|
|
702
|
+
));
|
|
703
|
+
}
|
|
545
704
|
let id = opaque_id();
|
|
546
705
|
let wants_control = request.permissions.contains(&Permission::Control);
|
|
547
706
|
let wants_x11 = matches!(request.source, Some(SourceRequest::X11 { .. }));
|
|
707
|
+
let max_fps = request.max_fps.clamp(1, 60);
|
|
548
708
|
// Which desktop decides which input path is even available: an X display
|
|
549
709
|
// takes XTest, which cannot reach any other session, while a portal
|
|
550
710
|
// desktop needs kernel input access.
|
|
551
711
|
if wants_control && !wants_x11 {
|
|
552
712
|
// Refuse up front rather than presenting a control surface that
|
|
553
713
|
// silently does nothing.
|
|
554
|
-
if
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
714
|
+
if crate::input::probe().is_err() {
|
|
715
|
+
#[cfg(target_os = "macos")]
|
|
716
|
+
let _ = crate::input::request_access();
|
|
717
|
+
if let Err(unavailable) = crate::input::probe() {
|
|
718
|
+
return Err(SessionError::new(
|
|
719
|
+
"input-unavailable",
|
|
720
|
+
format!("{}; {}", unavailable.reason, unavailable.remedy),
|
|
721
|
+
));
|
|
722
|
+
}
|
|
559
723
|
}
|
|
560
724
|
}
|
|
561
725
|
|
|
562
726
|
let metrics = Arc::new(Mutex::new(Metrics::default()));
|
|
727
|
+
let mut indicator = None;
|
|
563
728
|
let (frame_tx, frame_rx) = latest_frame();
|
|
729
|
+
let latest = Arc::new(Mutex::new(None::<FrameSnapshot>));
|
|
730
|
+
let observed = latest.clone();
|
|
731
|
+
let frame_sequence = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
|
732
|
+
let capture_error = Arc::new(Mutex::new(None::<String>));
|
|
733
|
+
let stopped_error = capture_error.clone();
|
|
734
|
+
let stopped_latest = latest.clone();
|
|
735
|
+
let stopped_events = events.clone();
|
|
736
|
+
let stopped_id = id.clone();
|
|
737
|
+
let (frame_tx_signal, frame_changes) = tokio::sync::watch::channel(0u64);
|
|
564
738
|
let captured = metrics.clone();
|
|
565
|
-
let
|
|
739
|
+
let stop_signal = frame_tx_signal.clone();
|
|
740
|
+
let on_capture_status = move |running: bool, reason: String| {
|
|
741
|
+
if let Ok(mut error) = stopped_error.lock() { *error = if running { None } else { Some(reason.clone()) }; }
|
|
742
|
+
if let Ok(mut frame) = stopped_latest.lock() { *frame = None; }
|
|
743
|
+
stop_signal.send_replace(0);
|
|
744
|
+
let _ = stopped_events.send(Notice { session_id: stopped_id.clone(),
|
|
745
|
+
event: if running { SessionEvent::State { capture: "streaming", transport: String::from("connected"), first_frame: false } }
|
|
746
|
+
else { SessionEvent::CaptureStopped { reason } } });
|
|
747
|
+
};
|
|
748
|
+
let capture_status = Arc::new(on_capture_status);
|
|
749
|
+
let frame_events = events.clone();
|
|
750
|
+
let frame_session_id = id.clone();
|
|
751
|
+
let sink = Box::new(move |frame: I420, _seq: u64, raw: Vec<u8>| {
|
|
752
|
+
let hashes = tile_hashes(&raw, frame.width, frame.height);
|
|
753
|
+
if let Ok(mut held) = observed.lock() {
|
|
754
|
+
let unchanged =
|
|
755
|
+
held.as_ref()
|
|
756
|
+
.is_some_and(|(_, _, width, height, _, current, _)| {
|
|
757
|
+
*width == frame.width && *height == frame.height && current == &hashes
|
|
758
|
+
});
|
|
759
|
+
if !unchanged {
|
|
760
|
+
let seq = frame_sequence.fetch_add(1, Ordering::Relaxed) + 1;
|
|
761
|
+
let previous = held
|
|
762
|
+
.as_ref()
|
|
763
|
+
.map(|(_, _, _, _, _, current, _)| current.clone())
|
|
764
|
+
.unwrap_or_default();
|
|
765
|
+
let damage = dirty_regions(&hashes, &previous, frame.width, frame.height);
|
|
766
|
+
*held = Some((
|
|
767
|
+
seq,
|
|
768
|
+
Instant::now(),
|
|
769
|
+
frame.width,
|
|
770
|
+
frame.height,
|
|
771
|
+
raw,
|
|
772
|
+
hashes,
|
|
773
|
+
previous,
|
|
774
|
+
));
|
|
775
|
+
frame_tx_signal.send_replace(seq);
|
|
776
|
+
let _ = frame_events.send(Notice {
|
|
777
|
+
session_id: frame_session_id.clone(),
|
|
778
|
+
event: SessionEvent::Frame { seq, damage },
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
566
782
|
if let Ok(mut m) = captured.lock() {
|
|
567
783
|
m.captured_frames += 1;
|
|
568
784
|
}
|
|
@@ -584,15 +800,20 @@ impl Session {
|
|
|
584
800
|
display.as_deref(),
|
|
585
801
|
request.max_width,
|
|
586
802
|
request.max_height,
|
|
587
|
-
|
|
803
|
+
max_fps,
|
|
588
804
|
metrics.clone(),
|
|
589
805
|
sink,
|
|
806
|
+
Box::new({ let status = capture_status.clone(); move |reason| status(false, reason) }),
|
|
590
807
|
)
|
|
591
808
|
.map_err(|error| SessionError::new("source", format!("{error:#}")))?,
|
|
592
809
|
_ => {
|
|
593
|
-
let portal =
|
|
594
|
-
|
|
595
|
-
|
|
810
|
+
let portal = match request.source.as_ref() {
|
|
811
|
+
Some(SourceRequest::Display { display_id }) => {
|
|
812
|
+
portal::open_display(*display_id).await
|
|
813
|
+
}
|
|
814
|
+
_ => portal::open(request.restore_token.as_deref()).await,
|
|
815
|
+
}
|
|
816
|
+
.map_err(|error| SessionError::new("source", format!("{error:#}")))?;
|
|
596
817
|
if let Some(token) = &portal.restore_token {
|
|
597
818
|
let _ = events.send(Notice {
|
|
598
819
|
session_id: id.clone(),
|
|
@@ -604,8 +825,53 @@ impl Session {
|
|
|
604
825
|
let source_h = source.height.max(1) as usize;
|
|
605
826
|
let (width, height) =
|
|
606
827
|
fit(source_w, source_h, request.max_width, request.max_height);
|
|
607
|
-
|
|
608
|
-
|
|
828
|
+
#[cfg(target_os = "linux")]
|
|
829
|
+
if request.agent_indicator && wants_control {
|
|
830
|
+
indicator = Some(
|
|
831
|
+
crate::indicator::Indicator::start_wayland(source_w, source_h).map_err(
|
|
832
|
+
|error| {
|
|
833
|
+
SessionError::new("indicator-unavailable", format!("{error:#}"))
|
|
834
|
+
},
|
|
835
|
+
)?,
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
#[cfg(target_os = "macos")]
|
|
839
|
+
if request.agent_indicator && wants_control {
|
|
840
|
+
indicator = Some(
|
|
841
|
+
crate::indicator::Indicator::start(source.node_id, source_w, source_h)
|
|
842
|
+
.map_err(|error| {
|
|
843
|
+
SessionError::new("indicator", format!("{error:#}"))
|
|
844
|
+
})?,
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
#[cfg(target_os = "macos")]
|
|
848
|
+
let capture = capture::start(
|
|
849
|
+
portal,
|
|
850
|
+
width,
|
|
851
|
+
height,
|
|
852
|
+
max_fps,
|
|
853
|
+
sink,
|
|
854
|
+
indicator.as_ref().map(crate::indicator::Indicator::pid),
|
|
855
|
+
Box::new({ let status = capture_status.clone(); move |running, reason| status(running, reason) }),
|
|
856
|
+
)
|
|
857
|
+
.map_err(|error| SessionError::new("source", format!("{error:#}")))?;
|
|
858
|
+
#[cfg(target_os = "linux")]
|
|
859
|
+
let capture = capture::start(
|
|
860
|
+
portal,
|
|
861
|
+
width,
|
|
862
|
+
height,
|
|
863
|
+
max_fps,
|
|
864
|
+
sink,
|
|
865
|
+
indicator
|
|
866
|
+
.as_ref()
|
|
867
|
+
.map(crate::indicator::Indicator::position),
|
|
868
|
+
Box::new({ let status = capture_status.clone(); move |reason| status(false, reason) }),
|
|
869
|
+
)
|
|
870
|
+
.map_err(|error| SessionError::new("source", format!("{error:#}")))?;
|
|
871
|
+
#[cfg(target_os = "linux")]
|
|
872
|
+
if let Some(indicator) = indicator.as_mut() {
|
|
873
|
+
indicator.event('A', -1, -1);
|
|
874
|
+
}
|
|
609
875
|
Selected {
|
|
610
876
|
source,
|
|
611
877
|
capture: FrameSource::Portal(capture),
|
|
@@ -616,6 +882,20 @@ impl Session {
|
|
|
616
882
|
|
|
617
883
|
let source_w = source.width.max(1) as usize;
|
|
618
884
|
let source_h = source.height.max(1) as usize;
|
|
885
|
+
#[cfg(target_os = "linux")]
|
|
886
|
+
if request.agent_indicator && wants_control && wants_x11 {
|
|
887
|
+
let display = match &request.source {
|
|
888
|
+
Some(SourceRequest::X11 {
|
|
889
|
+
display: Some(display),
|
|
890
|
+
}) => display.clone(),
|
|
891
|
+
_ => std::env::var("DISPLAY")
|
|
892
|
+
.map_err(|_| SessionError::new("indicator-unavailable", "no X display"))?,
|
|
893
|
+
};
|
|
894
|
+
indicator = Some(
|
|
895
|
+
crate::indicator::Indicator::start(&display, source_w, source_h)
|
|
896
|
+
.map_err(|error| SessionError::new("indicator", format!("{error:#}")))?,
|
|
897
|
+
);
|
|
898
|
+
}
|
|
619
899
|
let (width, height) = fit(source_w, source_h, request.max_width, request.max_height);
|
|
620
900
|
|
|
621
901
|
let bitrate_kbps = if request.bitrate_kbps == 0 {
|
|
@@ -627,7 +907,7 @@ impl Session {
|
|
|
627
907
|
width,
|
|
628
908
|
height,
|
|
629
909
|
bitrate_kbps,
|
|
630
|
-
|
|
910
|
+
max_fps,
|
|
631
911
|
available_parallelism().min(8) as u32,
|
|
632
912
|
)
|
|
633
913
|
.map_err(|error| SessionError::new("encode", format!("{error:#}")))?;
|
|
@@ -647,11 +927,19 @@ impl Session {
|
|
|
647
927
|
wheel_rest: (0.0, 0.0),
|
|
648
928
|
},
|
|
649
929
|
None => InputTarget {
|
|
650
|
-
applier: Applier::Uinput(
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
)
|
|
654
|
-
|
|
930
|
+
applier: Applier::Uinput({
|
|
931
|
+
#[cfg(target_os = "linux")]
|
|
932
|
+
let devices = InputDevices::create(source_w as i32, source_h as i32);
|
|
933
|
+
#[cfg(target_os = "macos")]
|
|
934
|
+
let devices = InputDevices::create_for_display(
|
|
935
|
+
source_w as i32,
|
|
936
|
+
source_h as i32,
|
|
937
|
+
source.node_id,
|
|
938
|
+
);
|
|
939
|
+
devices.map_err(|error| {
|
|
940
|
+
SessionError::new("input-unavailable", format!("{error:#}"))
|
|
941
|
+
})?
|
|
942
|
+
}),
|
|
655
943
|
held: HeldState::default(),
|
|
656
944
|
explicit_modifiers: Vec::new(),
|
|
657
945
|
chord_modifiers: Vec::new(),
|
|
@@ -694,9 +982,13 @@ impl Session {
|
|
|
694
982
|
source,
|
|
695
983
|
geometry,
|
|
696
984
|
metrics,
|
|
985
|
+
latest,
|
|
986
|
+
capture_error,
|
|
987
|
+
frame_changes,
|
|
697
988
|
peer: Arc::new(peer),
|
|
698
989
|
encoder: Mutex::new(encoder),
|
|
699
990
|
input: Mutex::new(input),
|
|
991
|
+
indicator: Mutex::new(indicator),
|
|
700
992
|
capture: Mutex::new(Some(capture)),
|
|
701
993
|
layout: Mutex::new(layout),
|
|
702
994
|
last_seq: Mutex::new(0),
|
|
@@ -716,7 +1008,7 @@ impl Session {
|
|
|
716
1008
|
first_frame: false,
|
|
717
1009
|
});
|
|
718
1010
|
|
|
719
|
-
spawn_pipeline(&inner, frame_rx, pipeline,
|
|
1011
|
+
spawn_pipeline(&inner, frame_rx, pipeline, max_fps, bitrate_kbps);
|
|
720
1012
|
spawn_peer_events(&inner, peer_events_rx);
|
|
721
1013
|
spawn_lease(&inner, request.ttl.unwrap_or(Duration::from_secs(3600)));
|
|
722
1014
|
|
|
@@ -739,6 +1031,106 @@ impl Session {
|
|
|
739
1031
|
&self.inner.source
|
|
740
1032
|
}
|
|
741
1033
|
|
|
1034
|
+
pub async fn wait_frame(
|
|
1035
|
+
&self,
|
|
1036
|
+
after_seq: Option<u64>,
|
|
1037
|
+
still_ms: Option<u64>,
|
|
1038
|
+
timeout_ms: u64,
|
|
1039
|
+
) -> std::result::Result<(), SessionError> {
|
|
1040
|
+
let mut changes = self.inner.frame_changes.clone();
|
|
1041
|
+
let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms.min(120_000));
|
|
1042
|
+
loop {
|
|
1043
|
+
if let Some(reason) = lock(&self.inner.capture_error).clone() {
|
|
1044
|
+
return Err(SessionError::new("stream-stopped", reason));
|
|
1045
|
+
}
|
|
1046
|
+
let (seq, still) = self
|
|
1047
|
+
.inner
|
|
1048
|
+
.latest
|
|
1049
|
+
.lock()
|
|
1050
|
+
.ok()
|
|
1051
|
+
.and_then(|held| held.as_ref().map(|(seq, at, ..)| (*seq, at.elapsed())))
|
|
1052
|
+
.unwrap_or((0, Duration::ZERO));
|
|
1053
|
+
let target = still_ms.or(if after_seq.is_none() { Some(150) } else { None });
|
|
1054
|
+
let advanced = after_seq.is_none_or(|after| seq > after);
|
|
1055
|
+
if seq > 0 && advanced && target.is_none_or(|ms| still >= Duration::from_millis(ms)) {
|
|
1056
|
+
return Ok(());
|
|
1057
|
+
}
|
|
1058
|
+
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
1059
|
+
if remaining.is_zero() {
|
|
1060
|
+
return Err(SessionError::new(
|
|
1061
|
+
"frame-timeout",
|
|
1062
|
+
"frame condition not met before deadline",
|
|
1063
|
+
));
|
|
1064
|
+
}
|
|
1065
|
+
let until_still = if seq > 0 && advanced {
|
|
1066
|
+
target.map_or(remaining, |ms| {
|
|
1067
|
+
Duration::from_millis(ms).saturating_sub(still)
|
|
1068
|
+
})
|
|
1069
|
+
} else {
|
|
1070
|
+
remaining
|
|
1071
|
+
};
|
|
1072
|
+
let _ = tokio::time::timeout(remaining.min(until_still), changes.changed()).await;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/// Write only the requested lossless frame bytes; JSON carries metadata, never pixels.
|
|
1077
|
+
pub fn frame(
|
|
1078
|
+
&self,
|
|
1079
|
+
since: Option<u64>,
|
|
1080
|
+
path: &str,
|
|
1081
|
+
region: Option<[usize; 4]>,
|
|
1082
|
+
) -> std::result::Result<serde_json::Value, SessionError> {
|
|
1083
|
+
if !self.inner.permissions.contains(&Permission::View) {
|
|
1084
|
+
return Err(SessionError::new(
|
|
1085
|
+
"permission",
|
|
1086
|
+
"view permission is required",
|
|
1087
|
+
));
|
|
1088
|
+
}
|
|
1089
|
+
if let Some(reason) = lock(&self.inner.capture_error).clone() {
|
|
1090
|
+
return Err(SessionError::new("stream-stopped", reason));
|
|
1091
|
+
}
|
|
1092
|
+
let held = lock(&self.inner.latest);
|
|
1093
|
+
let (seq, at, width, height, raw, hashes, previous) = held
|
|
1094
|
+
.as_ref()
|
|
1095
|
+
.ok_or_else(|| SessionError::new("frame", "the first frame has not arrived"))?;
|
|
1096
|
+
let [x, y, w, h] = region.unwrap_or([0, 0, *width, *height]);
|
|
1097
|
+
if w == 0
|
|
1098
|
+
|| h == 0
|
|
1099
|
+
|| x.checked_add(w).is_none_or(|end| end > *width)
|
|
1100
|
+
|| y.checked_add(h).is_none_or(|end| end > *height)
|
|
1101
|
+
{
|
|
1102
|
+
return Err(SessionError::new(
|
|
1103
|
+
"coordinates",
|
|
1104
|
+
"region exceeds the encoded surface",
|
|
1105
|
+
));
|
|
1106
|
+
}
|
|
1107
|
+
let damage = if since == Some(*seq) {
|
|
1108
|
+
Vec::new()
|
|
1109
|
+
} else if since == seq.checked_sub(1) && previous.len() == hashes.len() {
|
|
1110
|
+
dirty_regions(hashes, previous, *width, *height)
|
|
1111
|
+
} else {
|
|
1112
|
+
vec![[0, 0, *width, *height]]
|
|
1113
|
+
};
|
|
1114
|
+
// An empty path asks for metadata only; no pixels are copied or written.
|
|
1115
|
+
let pixels = if path.is_empty() {
|
|
1116
|
+
None
|
|
1117
|
+
} else {
|
|
1118
|
+
let mut pixels = Vec::with_capacity(w * h * 4);
|
|
1119
|
+
for row in y..y + h {
|
|
1120
|
+
pixels.extend_from_slice(&raw[(row * width + x) * 4..(row * width + x + w) * 4]);
|
|
1121
|
+
}
|
|
1122
|
+
Some(pixels)
|
|
1123
|
+
};
|
|
1124
|
+
let result = serde_json::json!({ "seq": seq, "still_ms": at.elapsed().as_millis() as u64,
|
|
1125
|
+
"width": w, "height": h, "format": "bgrx", "damage": damage, "written": !path.is_empty() });
|
|
1126
|
+
drop(held);
|
|
1127
|
+
if let Some(pixels) = pixels {
|
|
1128
|
+
std::fs::write(path, pixels)
|
|
1129
|
+
.map_err(|error| SessionError::new("frame", error.to_string()))?;
|
|
1130
|
+
}
|
|
1131
|
+
Ok(result)
|
|
1132
|
+
}
|
|
1133
|
+
|
|
742
1134
|
pub fn metrics(&self) -> Metrics {
|
|
743
1135
|
self.inner
|
|
744
1136
|
.metrics
|
|
@@ -819,6 +1211,9 @@ impl Inner {
|
|
|
819
1211
|
}
|
|
820
1212
|
self.control_open.store(false, Ordering::SeqCst);
|
|
821
1213
|
self.pipeline.store(false, Ordering::SeqCst);
|
|
1214
|
+
if let Ok(mut indicator) = self.indicator.lock() {
|
|
1215
|
+
indicator.take();
|
|
1216
|
+
}
|
|
822
1217
|
// Release first, then tear down: a stuck modifier is the one failure the
|
|
823
1218
|
// user cannot undo by reconnecting.
|
|
824
1219
|
if let Ok(mut input) = self.input.lock() {
|
|
@@ -922,6 +1317,23 @@ impl Inner {
|
|
|
922
1317
|
}
|
|
923
1318
|
*last = seq;
|
|
924
1319
|
}
|
|
1320
|
+
let feedback = match &message {
|
|
1321
|
+
ControlMessage::Pointer { phase, x, y, .. } => {
|
|
1322
|
+
let (x, y) = to_source_pixels(*x, *y, self.encoded_size(), &self.source);
|
|
1323
|
+
Some((
|
|
1324
|
+
if matches!(phase, PointerPhase::Down) {
|
|
1325
|
+
'C'
|
|
1326
|
+
} else {
|
|
1327
|
+
'M'
|
|
1328
|
+
},
|
|
1329
|
+
x,
|
|
1330
|
+
y,
|
|
1331
|
+
))
|
|
1332
|
+
}
|
|
1333
|
+
ControlMessage::Text { text, .. } if !text.is_empty() => Some(('T', -1, -1)),
|
|
1334
|
+
ControlMessage::Key { down: true, .. } => Some(('T', -1, -1)),
|
|
1335
|
+
_ => None,
|
|
1336
|
+
};
|
|
925
1337
|
let outcome = match message {
|
|
926
1338
|
ControlMessage::Pointer {
|
|
927
1339
|
phase,
|
|
@@ -958,6 +1370,13 @@ impl Inner {
|
|
|
958
1370
|
};
|
|
959
1371
|
match outcome {
|
|
960
1372
|
Ok(()) => {
|
|
1373
|
+
if let Some((kind, x, y)) = feedback {
|
|
1374
|
+
if let Ok(mut indicator) = self.indicator.lock() {
|
|
1375
|
+
if let Some(indicator) = indicator.as_mut() {
|
|
1376
|
+
indicator.event(kind, x, y);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
961
1380
|
if let Ok(mut m) = self.metrics.lock() {
|
|
962
1381
|
m.input_applied += 1;
|
|
963
1382
|
}
|
|
@@ -1051,10 +1470,16 @@ impl Inner {
|
|
|
1051
1470
|
keymap::named_key(&name)
|
|
1052
1471
|
.ok_or(("text-unsupported", format!("unknown key {name}")))?
|
|
1053
1472
|
} else if let Some(character) = character {
|
|
1054
|
-
let
|
|
1055
|
-
|
|
1473
|
+
let mut characters = character.chars();
|
|
1474
|
+
let wanted = characters
|
|
1056
1475
|
.next()
|
|
1057
1476
|
.ok_or(("text-unsupported", String::from("empty character")))?;
|
|
1477
|
+
if characters.next().is_some() {
|
|
1478
|
+
return Err((
|
|
1479
|
+
"text-unsupported",
|
|
1480
|
+
String::from("character must contain exactly one Unicode scalar"),
|
|
1481
|
+
));
|
|
1482
|
+
}
|
|
1058
1483
|
layout.keystroke_for_char(wanted).ok_or((
|
|
1059
1484
|
"text-unsupported",
|
|
1060
1485
|
format!("the active layout cannot produce {wanted:?}"),
|
|
@@ -1080,42 +1505,60 @@ impl Inner {
|
|
|
1080
1505
|
if text.len() > 4096 {
|
|
1081
1506
|
return Err(("text-too-large", String::from("text exceeds 4096 bytes")));
|
|
1082
1507
|
}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
.
|
|
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
|
-
));
|
|
1508
|
+
#[cfg(target_os = "macos")]
|
|
1509
|
+
{
|
|
1510
|
+
let _ = seq;
|
|
1511
|
+
return self.with_input(|target| target.unicode_text(text));
|
|
1094
1512
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
)
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1513
|
+
#[cfg(target_os = "linux")]
|
|
1514
|
+
{
|
|
1515
|
+
let layout = self
|
|
1516
|
+
.layout
|
|
1517
|
+
.lock()
|
|
1518
|
+
.map_err(|_| ("session", String::from("no keyboard layout")))?;
|
|
1519
|
+
let (plan, unreachable) = layout.plan_text(text);
|
|
1520
|
+
drop(layout);
|
|
1521
|
+
if !unreachable.is_empty() {
|
|
1522
|
+
return Err((
|
|
1523
|
+
"text-unsupported",
|
|
1524
|
+
format!("the active layout cannot produce {unreachable:?}; use the clipboard"),
|
|
1525
|
+
));
|
|
1526
|
+
}
|
|
1527
|
+
let _ = seq;
|
|
1528
|
+
self.with_input(|target| {
|
|
1529
|
+
// Uinput's immediate down/up pairs can be collapsed by a compositor's
|
|
1530
|
+
// event loop. XTest flushes each edge, so pace only the virtual device.
|
|
1531
|
+
let paced = matches!(&target.applier, Applier::Uinput(_));
|
|
1532
|
+
let pause = || {
|
|
1533
|
+
if paced {
|
|
1534
|
+
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
1109
1535
|
}
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1536
|
+
};
|
|
1537
|
+
target.check_keys(
|
|
1538
|
+
plan.iter()
|
|
1539
|
+
.flatten()
|
|
1540
|
+
.flat_map(|stroke| std::iter::once(stroke.code).chain(stroke.modifiers())),
|
|
1541
|
+
)?;
|
|
1542
|
+
for keystroke in plan {
|
|
1543
|
+
for stroke in keystroke {
|
|
1544
|
+
let modifiers = stroke.modifiers();
|
|
1545
|
+
for modifier in &modifiers {
|
|
1546
|
+
target.modifier(*modifier, true, false)?;
|
|
1547
|
+
pause();
|
|
1548
|
+
}
|
|
1549
|
+
target.key(stroke.code, true)?;
|
|
1550
|
+
pause();
|
|
1551
|
+
target.key(stroke.code, false)?;
|
|
1552
|
+
pause();
|
|
1553
|
+
for modifier in modifiers.iter().rev() {
|
|
1554
|
+
target.modifier(*modifier, false, false)?;
|
|
1555
|
+
pause();
|
|
1556
|
+
}
|
|
1114
1557
|
}
|
|
1115
1558
|
}
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
}
|
|
1559
|
+
Ok(())
|
|
1560
|
+
})
|
|
1561
|
+
}
|
|
1119
1562
|
}
|
|
1120
1563
|
|
|
1121
1564
|
fn clipboard_read(self: &Arc<Self>, request: String) {
|
|
@@ -1639,6 +2082,14 @@ fn opaque_id() -> String {
|
|
|
1639
2082
|
mod tests {
|
|
1640
2083
|
use super::*;
|
|
1641
2084
|
|
|
2085
|
+
#[test]
|
|
2086
|
+
fn tile_damage_at_origin_does_not_underflow_or_hide_other_tiles() {
|
|
2087
|
+
assert_eq!(
|
|
2088
|
+
dirty_regions(&[1, 0, 0, 1], &[0; 4], 64, 64),
|
|
2089
|
+
vec![[0, 0, 32, 32], [32, 32, 32, 32]]
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
|
|
1642
2093
|
#[test]
|
|
1643
2094
|
fn a_small_source_is_never_upscaled() {
|
|
1644
2095
|
assert_eq!(fit(1000, 600, 1280, 800), (1000, 600));
|
|
@@ -1718,6 +2169,9 @@ mod tests {
|
|
|
1718
2169
|
"origin": { "x": 0, "y": 0 },
|
|
1719
2170
|
}),
|
|
1720
2171
|
metrics: Arc::new(Mutex::new(Metrics::default())),
|
|
2172
|
+
latest: Arc::new(Mutex::new(None)),
|
|
2173
|
+
capture_error: Arc::new(Mutex::new(None)),
|
|
2174
|
+
frame_changes: tokio::sync::watch::channel(0).1,
|
|
1721
2175
|
peer: Arc::new(peer),
|
|
1722
2176
|
encoder: Mutex::new(Encoder::new(64, 64, 1000, 30, 1).expect("an encoder")),
|
|
1723
2177
|
input: Mutex::new(Some(InputTarget {
|
|
@@ -1729,6 +2183,7 @@ mod tests {
|
|
|
1729
2183
|
wheel_rest: (0.0, 0.0),
|
|
1730
2184
|
})),
|
|
1731
2185
|
capture: Mutex::new(None),
|
|
2186
|
+
indicator: Mutex::new(None),
|
|
1732
2187
|
layout: Mutex::new(Layout::from_environment().expect("a keymap")),
|
|
1733
2188
|
last_seq: Mutex::new(0),
|
|
1734
2189
|
control_open: AtomicBool::new(true),
|
|
@@ -2015,6 +2470,18 @@ mod tests {
|
|
|
2015
2470
|
assert_eq!(inner.metrics.lock().unwrap().input_rejected, 2);
|
|
2016
2471
|
}
|
|
2017
2472
|
|
|
2473
|
+
#[tokio::test]
|
|
2474
|
+
async fn a_stopped_capture_never_serves_the_last_frame() {
|
|
2475
|
+
let (events, _) = tokio_mpsc::unbounded_channel();
|
|
2476
|
+
let (mut inner, _) = test_inner(events).await;
|
|
2477
|
+
Arc::get_mut(&mut inner).unwrap().permissions.push(Permission::View);
|
|
2478
|
+
let session = Session { inner };
|
|
2479
|
+
*lock(&session.inner.latest) = Some((1, Instant::now(), 2, 2, vec![0;16], vec![1], vec![]));
|
|
2480
|
+
*lock(&session.inner.capture_error) = Some(String::from("SCStreamErrorDomain -3821"));
|
|
2481
|
+
assert_eq!(session.frame(None, "", None).unwrap_err().code, "stream-stopped");
|
|
2482
|
+
assert_eq!(session.wait_frame(None, None, 10).await.unwrap_err().code, "stream-stopped");
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2018
2485
|
#[tokio::test]
|
|
2019
2486
|
async fn a_frame_the_encoder_refuses_ends_the_session_with_a_reason() {
|
|
2020
2487
|
let (events, mut received) = tokio_mpsc::unbounded_channel();
|
|
@@ -2052,6 +2519,33 @@ mod tests {
|
|
|
2052
2519
|
);
|
|
2053
2520
|
}
|
|
2054
2521
|
|
|
2522
|
+
#[tokio::test]
|
|
2523
|
+
async fn opening_without_view_permission_is_refused_before_capture() {
|
|
2524
|
+
let (events, _received) = tokio_mpsc::unbounded_channel();
|
|
2525
|
+
let result = Session::open(
|
|
2526
|
+
OpenRequest {
|
|
2527
|
+
source: None,
|
|
2528
|
+
permissions: vec![Permission::Control],
|
|
2529
|
+
max_width: 640,
|
|
2530
|
+
max_height: 480,
|
|
2531
|
+
bitrate_kbps: 0,
|
|
2532
|
+
max_fps: 30,
|
|
2533
|
+
ice_servers: Vec::new(),
|
|
2534
|
+
restore_token: None,
|
|
2535
|
+
ttl: None,
|
|
2536
|
+
loopback_tcp: false,
|
|
2537
|
+
agent_indicator: false,
|
|
2538
|
+
},
|
|
2539
|
+
events,
|
|
2540
|
+
)
|
|
2541
|
+
.await;
|
|
2542
|
+
let error = match result {
|
|
2543
|
+
Ok(_) => panic!("a viewless session must not open"),
|
|
2544
|
+
Err(error) => error,
|
|
2545
|
+
};
|
|
2546
|
+
assert_eq!(error.code, "permission");
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2055
2549
|
#[tokio::test]
|
|
2056
2550
|
async fn a_lost_transport_releases_input_then_ends_the_session_without_sctp_close() {
|
|
2057
2551
|
use webrtc::peer_connection::RTCPeerConnectionState as State;
|