@desklink/host 0.3.0 → 0.4.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.
- package/NOTICE +3 -0
- package/README.md +92 -23
- package/bin/desklink-host.mjs +14 -5
- package/dist/bridge.d.ts +3 -4
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +18 -7
- package/dist/bridge.js.map +1 -1
- package/dist/engineProcess.d.ts +6 -1
- package/dist/engineProcess.d.ts.map +1 -1
- package/dist/engineProcess.js +34 -5
- package/dist/engineProcess.js.map +1 -1
- package/dist/protocol.d.ts +35 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/resolveEngine.d.ts +6 -0
- package/dist/resolveEngine.d.ts.map +1 -1
- package/dist/resolveEngine.js +45 -6
- package/dist/resolveEngine.js.map +1 -1
- package/docs/BOUNDARY.md +8 -8
- package/docs/PROTOCOL.md +185 -45
- package/engine/Cargo.lock +2 -2
- package/engine/Cargo.toml +7 -12
- package/engine/build.rs +62 -5
- package/engine/native/agent_indicator.h +57 -0
- package/engine/native/agent_overlay_mac.m +51 -4
- package/engine/native/agent_overlay_wayland.c +189 -35
- package/engine/native/agent_overlay_x11.c +186 -55
- package/engine/src/capture.rs +96 -5
- package/engine/src/capture_win.rs +23 -0
- package/engine/src/clipboard.rs +174 -0
- package/engine/src/clipboard_win.rs +8 -0
- package/engine/src/cursor.rs +118 -0
- package/engine/src/indicator.rs +271 -5
- package/engine/src/input_win.rs +91 -0
- package/engine/src/keymap_win.rs +39 -0
- package/engine/src/mac.rs +28 -6
- package/engine/src/main.rs +167 -25
- package/engine/src/peer.rs +10 -4
- package/engine/src/portal.rs +42 -4
- package/engine/src/portal_win.rs +37 -0
- package/engine/src/protocol.rs +56 -10
- package/engine/src/session.rs +749 -203
- package/engine/src/win.rs +110 -0
- package/engine/src/x11.rs +44 -29
- package/engine/src/x11_mac.rs +4 -0
- package/examples/reference-client.html +3 -5
- package/package.json +4 -4
package/engine/src/session.rs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
//! One authorized desktop session: capture in, VP9 out, control channel in,
|
|
2
2
|
//! desktop input out, and a bounded teardown.
|
|
3
3
|
//!
|
|
4
|
-
//! The
|
|
5
|
-
//!
|
|
6
|
-
//!
|
|
4
|
+
//! The consumer's pairing is the authority for all available access. The engine
|
|
5
|
+
//! has no second identity system, no account and no notion of which application
|
|
6
|
+
//! asked.
|
|
7
7
|
|
|
8
8
|
use crate::capture::{self, Capture};
|
|
9
9
|
use crate::clipboard;
|
|
@@ -15,9 +15,7 @@ use crate::peer::{
|
|
|
15
15
|
h264_profile_level_id, PathReport, PeerEvent, TransportOptions, VideoCodec, VideoPeer,
|
|
16
16
|
};
|
|
17
17
|
use crate::portal::{self, SelectedSource};
|
|
18
|
-
use crate::protocol::{
|
|
19
|
-
ControlMessage, ControlReply, EncodedCodec, Permission, PointerPhase, SourceRequest,
|
|
20
|
-
};
|
|
18
|
+
use crate::protocol::{ControlMessage, ControlReply, EncodedCodec, PointerPhase, SourceRequest};
|
|
21
19
|
use crate::x11::X11Desktop;
|
|
22
20
|
use anyhow::{Context, Result};
|
|
23
21
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
@@ -192,6 +190,98 @@ impl Drop for X11Capture {
|
|
|
192
190
|
}
|
|
193
191
|
}
|
|
194
192
|
|
|
193
|
+
#[cfg(target_os = "linux")]
|
|
194
|
+
unsafe extern "C" {
|
|
195
|
+
fn desklink_indicator_covered(
|
|
196
|
+
x: f64,
|
|
197
|
+
y: f64,
|
|
198
|
+
opacity: f64,
|
|
199
|
+
click: f64,
|
|
200
|
+
typed: f64,
|
|
201
|
+
threshold: u32,
|
|
202
|
+
reveal: f64,
|
|
203
|
+
) -> i32;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/// The X11 agent cue is a real window, so root grabs contain it. Restore
|
|
207
|
+
/// exactly the pixels the announced cues cover (one pixel wider for edge
|
|
208
|
+
/// rounding) from the last clean frame; everything else, including what lies
|
|
209
|
+
/// inside the halo, stays live.
|
|
210
|
+
#[cfg(target_os = "linux")]
|
|
211
|
+
struct CueMask {
|
|
212
|
+
cues: crate::indicator::Cues,
|
|
213
|
+
clean: Option<Vec<u8>>,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#[cfg(target_os = "linux")]
|
|
217
|
+
impl CueMask {
|
|
218
|
+
fn new(cues: crate::indicator::Cues) -> Self {
|
|
219
|
+
Self { cues, clean: None }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
fn snapshot(&self) -> Vec<crate::indicator::Cue> {
|
|
223
|
+
self.cues
|
|
224
|
+
.lock()
|
|
225
|
+
.map(|mut feed| feed.drain())
|
|
226
|
+
.unwrap_or_default()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// `before` is the snapshot taken when the grab started: with the states
|
|
230
|
+
/// announced by the time it returned, it covers whatever the grab saw.
|
|
231
|
+
fn apply(
|
|
232
|
+
&mut self,
|
|
233
|
+
raw: &mut [u8],
|
|
234
|
+
width: usize,
|
|
235
|
+
height: usize,
|
|
236
|
+
mut cues: Vec<crate::indicator::Cue>,
|
|
237
|
+
) {
|
|
238
|
+
cues.extend(self.snapshot());
|
|
239
|
+
if let Some(clean) = self.clean.as_ref().filter(|clean| clean.len() == raw.len()) {
|
|
240
|
+
for cue in cues {
|
|
241
|
+
let side = cue.size as usize + 2;
|
|
242
|
+
let mut covered = vec![false; side * side];
|
|
243
|
+
for row in 0..cue.size as usize {
|
|
244
|
+
for col in 0..cue.size as usize {
|
|
245
|
+
let half = cue.size as f64 / 2.0;
|
|
246
|
+
covered[(row + 1) * side + col + 1] = unsafe {
|
|
247
|
+
desklink_indicator_covered(
|
|
248
|
+
(col as f64 - half) / cue.unit,
|
|
249
|
+
(row as f64 - half) / cue.unit,
|
|
250
|
+
cue.opacity,
|
|
251
|
+
cue.click,
|
|
252
|
+
cue.typed,
|
|
253
|
+
cue.threshold,
|
|
254
|
+
cue.reveal,
|
|
255
|
+
)
|
|
256
|
+
} != 0;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
for row in 0..side {
|
|
260
|
+
for col in 0..side {
|
|
261
|
+
let near =
|
|
262
|
+
|r: usize, c: usize| r < side && c < side && covered[r * side + c];
|
|
263
|
+
if !(near(row, col)
|
|
264
|
+
|| near(row.wrapping_sub(1), col)
|
|
265
|
+
|| near(row + 1, col)
|
|
266
|
+
|| near(row, col.wrapping_sub(1))
|
|
267
|
+
|| near(row, col + 1))
|
|
268
|
+
{
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
let (x, y) = (cue.x - 1 + col as i64, cue.y - 1 + row as i64);
|
|
272
|
+
if x < 0 || y < 0 || x >= width as i64 || y >= height as i64 {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
let at = (y as usize * width + x as usize) * 4;
|
|
276
|
+
raw[at..at + 4].copy_from_slice(&clean[at..at + 4]);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
self.clean = Some(raw.to_vec());
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
195
285
|
/// What a chosen capture backend gives the session.
|
|
196
286
|
struct Selected {
|
|
197
287
|
source: SelectedSource,
|
|
@@ -202,12 +292,15 @@ struct Selected {
|
|
|
202
292
|
|
|
203
293
|
/// Open an X display, start reading its root window, and hand back the same
|
|
204
294
|
/// shape the portal path produces.
|
|
295
|
+
#[allow(clippy::too_many_arguments)]
|
|
205
296
|
fn select_x11(
|
|
206
297
|
display: Option<&str>,
|
|
207
298
|
max_width: usize,
|
|
208
299
|
max_height: usize,
|
|
209
300
|
max_fps: u32,
|
|
210
301
|
metrics: Arc<Mutex<Metrics>>,
|
|
302
|
+
#[cfg(target_os = "linux")] cursor_sink: Option<crate::cursor::CursorSink>,
|
|
303
|
+
#[cfg(target_os = "linux")] cues: Option<crate::indicator::Cues>,
|
|
211
304
|
sink: capture::FrameSink,
|
|
212
305
|
on_stop: Box<dyn Fn(String) + Send>,
|
|
213
306
|
) -> Result<Selected> {
|
|
@@ -238,15 +331,25 @@ fn select_x11(
|
|
|
238
331
|
let interval = frame_interval(max_fps);
|
|
239
332
|
let mut sequence = 0u64;
|
|
240
333
|
let mut last_sent: Option<Instant> = None;
|
|
241
|
-
//
|
|
242
|
-
//
|
|
334
|
+
// XDamage wakes the loop, but pixels decide whether the root
|
|
335
|
+
// image actually changed; without XDamage, polling does so.
|
|
243
336
|
let hashed = !lock(&desktop).damage_armed();
|
|
244
337
|
let mut last_hash = None;
|
|
245
338
|
// The first frame is always grabbed; afterwards Damage wakes
|
|
246
339
|
// the loop, so a still screen costs no grab, no conversion
|
|
247
340
|
// and no hash. The fps cap stays a minimum spacing.
|
|
248
341
|
let mut first = true;
|
|
342
|
+
#[cfg(target_os = "linux")]
|
|
343
|
+
let mut cursor = cursor_sink.map(crate::cursor::Reporter::new);
|
|
344
|
+
#[cfg(target_os = "linux")]
|
|
345
|
+
let mut mask = cues.map(CueMask::new);
|
|
249
346
|
while !stop.load(Ordering::SeqCst) {
|
|
347
|
+
#[cfg(target_os = "linux")]
|
|
348
|
+
if let Some(cursor) = cursor.as_mut() {
|
|
349
|
+
if let Ok((x, y, visible)) = lock(&desktop).cursor_position() {
|
|
350
|
+
cursor.update(x, y, visible);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
250
353
|
if !first {
|
|
251
354
|
let changed = lock(&desktop).take_damage();
|
|
252
355
|
if !changed {
|
|
@@ -262,14 +365,23 @@ fn select_x11(
|
|
|
262
365
|
}
|
|
263
366
|
let started = Instant::now();
|
|
264
367
|
let grabbed = Instant::now();
|
|
368
|
+
#[cfg(target_os = "linux")]
|
|
265
369
|
let frame = {
|
|
370
|
+
let before = mask.as_ref().map(CueMask::snapshot);
|
|
266
371
|
let mut desktop = lock(&desktop);
|
|
267
|
-
desktop.
|
|
372
|
+
desktop.capture_raw().and_then(|mut raw| {
|
|
373
|
+
if let (Some(mask), Some(before)) = (mask.as_mut(), before) {
|
|
374
|
+
mask.apply(&mut raw, width as usize, height as usize, before);
|
|
375
|
+
}
|
|
376
|
+
Ok((desktop.convert(&raw, max_width, max_height)?, raw))
|
|
377
|
+
})
|
|
268
378
|
};
|
|
379
|
+
#[cfg(not(target_os = "linux"))]
|
|
380
|
+
let frame = lock(&desktop).capture_with_pixels(max_width, max_height);
|
|
269
381
|
let grab_micros = grabbed.elapsed().as_micros() as u64;
|
|
270
382
|
match frame {
|
|
271
383
|
Ok((frame, raw)) => {
|
|
272
|
-
let changed =
|
|
384
|
+
let changed = {
|
|
273
385
|
use std::hash::{Hash, Hasher};
|
|
274
386
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
275
387
|
raw.hash(&mut hasher);
|
|
@@ -280,8 +392,6 @@ fn select_x11(
|
|
|
280
392
|
last_hash = Some(hash);
|
|
281
393
|
true
|
|
282
394
|
}
|
|
283
|
-
} else {
|
|
284
|
-
true
|
|
285
395
|
};
|
|
286
396
|
if changed {
|
|
287
397
|
// Handing an unchanged frame on would keep the
|
|
@@ -480,10 +590,10 @@ impl InputTarget {
|
|
|
480
590
|
Ok(())
|
|
481
591
|
}
|
|
482
592
|
|
|
483
|
-
#[cfg(target_os = "macos")]
|
|
593
|
+
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
484
594
|
fn unicode_text(&mut self, text: &str) -> Result<()> {
|
|
485
595
|
match &mut self.applier {
|
|
486
|
-
#[cfg(target_os = "macos")]
|
|
596
|
+
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
487
597
|
Applier::Uinput(devices) => devices.unicode_text(text),
|
|
488
598
|
#[cfg(target_os = "linux")]
|
|
489
599
|
Applier::Uinput(_) => anyhow::bail!("Unicode text input is unavailable on Linux"),
|
|
@@ -582,9 +692,9 @@ impl SessionError {
|
|
|
582
692
|
}
|
|
583
693
|
|
|
584
694
|
pub struct OpenRequest {
|
|
695
|
+
pub cursor: crate::protocol::CursorMode,
|
|
585
696
|
/** Which desktop to capture; absent means the portal. */
|
|
586
697
|
pub source: Option<SourceRequest>,
|
|
587
|
-
pub permissions: Vec<Permission>,
|
|
588
698
|
pub max_width: usize,
|
|
589
699
|
pub max_height: usize,
|
|
590
700
|
pub bitrate_kbps: u32,
|
|
@@ -603,6 +713,9 @@ pub struct OpenRequest {
|
|
|
603
713
|
/// Events a session raises for its consumer.
|
|
604
714
|
#[derive(Debug)]
|
|
605
715
|
pub enum SessionEvent {
|
|
716
|
+
Cursor {
|
|
717
|
+
position: crate::protocol::CursorPosition,
|
|
718
|
+
},
|
|
606
719
|
Description {
|
|
607
720
|
generation: u64,
|
|
608
721
|
sdp: String,
|
|
@@ -619,6 +732,7 @@ pub enum SessionEvent {
|
|
|
619
732
|
first_frame: bool,
|
|
620
733
|
},
|
|
621
734
|
Frame {
|
|
735
|
+
timestamp_us: u64,
|
|
622
736
|
seq: u64,
|
|
623
737
|
damage: Vec<[usize; 4]>,
|
|
624
738
|
},
|
|
@@ -661,10 +775,16 @@ type FrameSnapshot = (u64, Instant, usize, usize, Vec<u8>, Vec<u64>, Vec<u64>);
|
|
|
661
775
|
|
|
662
776
|
/// Everything a session's background tasks need, shared rather than borrowed so
|
|
663
777
|
/// the consumer can own the `Session` handle while the pipeline runs.
|
|
778
|
+
#[cfg(target_os = "linux")]
|
|
779
|
+
enum PointDesktop {
|
|
780
|
+
X11(String),
|
|
781
|
+
Wayland,
|
|
782
|
+
Unavailable(&'static str),
|
|
783
|
+
}
|
|
784
|
+
|
|
664
785
|
struct Inner {
|
|
665
786
|
id: String,
|
|
666
787
|
generation: u64,
|
|
667
|
-
permissions: Vec<Permission>,
|
|
668
788
|
source: SelectedSource,
|
|
669
789
|
geometry: serde_json::Value,
|
|
670
790
|
metrics: Arc<Mutex<Metrics>>,
|
|
@@ -679,6 +799,9 @@ struct Inner {
|
|
|
679
799
|
encoder: Mutex<Option<VideoEncoder>>,
|
|
680
800
|
input: Mutex<Option<InputTarget>>,
|
|
681
801
|
indicator: Mutex<Option<crate::indicator::Indicator>>,
|
|
802
|
+
point_overlay: Mutex<Option<crate::indicator::Indicator>>,
|
|
803
|
+
#[cfg(target_os = "linux")]
|
|
804
|
+
point_desktop: PointDesktop,
|
|
682
805
|
capture: Mutex<Option<FrameSource>>,
|
|
683
806
|
/// Absent for an encoded source: there is no local keyboard to translate a
|
|
684
807
|
/// character through, because the client's keys are forwarded instead.
|
|
@@ -694,6 +817,7 @@ struct Inner {
|
|
|
694
817
|
encoded: Mutex<Option<Encoded>>,
|
|
695
818
|
/// `OpenRequest::local_frames`: whether `latest` is kept at all.
|
|
696
819
|
local_frames: bool,
|
|
820
|
+
cursor_info: serde_json::Value,
|
|
697
821
|
}
|
|
698
822
|
|
|
699
823
|
/// A source whose video arrives already encoded.
|
|
@@ -741,12 +865,10 @@ pub fn capabilities() -> serde_json::Value {
|
|
|
741
865
|
} else {
|
|
742
866
|
"none"
|
|
743
867
|
};
|
|
744
|
-
// Clipboard transfer needs Wayland; writes also need a selection server
|
|
745
|
-
// that can outlive this engine. Do not offer it when that tool is absent.
|
|
746
|
-
let clipboard = wayland_clipboard_available();
|
|
747
868
|
let x11 = crate::x11::X11Desktop::connect(None)
|
|
748
869
|
.map(|desktop| serde_json::json!([desktop.screen_size().0, desktop.screen_size().1]))
|
|
749
870
|
.unwrap_or(serde_json::Value::Null);
|
|
871
|
+
let clipboard = wayland_clipboard_available();
|
|
750
872
|
let (input, grant_state, unavailable) = match crate::input::probe() {
|
|
751
873
|
Ok(()) => (serde_json::json!(true), "granted", serde_json::Value::Null),
|
|
752
874
|
Err(unavailable) => (
|
|
@@ -772,6 +894,8 @@ pub fn capabilities() -> serde_json::Value {
|
|
|
772
894
|
"backends": ["portal-screencast+pipewire", "x11-root"],
|
|
773
895
|
"formats": ["bgrx", "bgra", "rgbx", "rgba"],
|
|
774
896
|
"cursor": "embedded",
|
|
897
|
+
"cursor_modes": ["embedded", "hidden"],
|
|
898
|
+
"cursor_positions": {"x11": true, "portal": "negotiated"},
|
|
775
899
|
"audio": false,
|
|
776
900
|
},
|
|
777
901
|
"encode": encode_capabilities(),
|
|
@@ -824,6 +948,7 @@ pub fn capabilities() -> serde_json::Value {
|
|
|
824
948
|
"backends": ["screencapturekit"],
|
|
825
949
|
"formats": ["bgra"],
|
|
826
950
|
"cursor": "embedded",
|
|
951
|
+
"cursor_modes": ["embedded"],
|
|
827
952
|
"audio": false,
|
|
828
953
|
"displays": crate::mac::displays(),
|
|
829
954
|
"grant": if capture_granted { "granted" } else { "missing-screen-recording" },
|
|
@@ -849,6 +974,16 @@ pub fn capabilities() -> serde_json::Value {
|
|
|
849
974
|
})
|
|
850
975
|
}
|
|
851
976
|
|
|
977
|
+
#[cfg(target_os = "windows")]
|
|
978
|
+
pub fn capabilities() -> serde_json::Value {
|
|
979
|
+
crate::win::capabilities()
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
#[cfg(target_os = "windows")]
|
|
983
|
+
fn wayland_clipboard_available() -> bool {
|
|
984
|
+
false
|
|
985
|
+
}
|
|
986
|
+
|
|
852
987
|
/// `capabilities.encode`: VP9 always, H.264 when an encoder for it starts
|
|
853
988
|
/// here now. `hardware` describes VP9, the default.
|
|
854
989
|
fn encode_capabilities() -> serde_json::Value {
|
|
@@ -872,14 +1007,20 @@ impl Session {
|
|
|
872
1007
|
request: OpenRequest,
|
|
873
1008
|
events: tokio_mpsc::UnboundedSender<Notice>,
|
|
874
1009
|
) -> std::result::Result<Self, SessionError> {
|
|
875
|
-
if
|
|
1010
|
+
if request.cursor == crate::protocol::CursorMode::Hidden {
|
|
1011
|
+
#[cfg(not(target_os = "linux"))]
|
|
876
1012
|
return Err(SessionError::new(
|
|
877
|
-
"
|
|
878
|
-
"
|
|
1013
|
+
"cursor-unavailable",
|
|
1014
|
+
"hidden cursor capture is not implemented on this platform",
|
|
879
1015
|
));
|
|
1016
|
+
if matches!(request.source, Some(SourceRequest::Encoded { .. })) {
|
|
1017
|
+
return Err(SessionError::new(
|
|
1018
|
+
"cursor-unavailable",
|
|
1019
|
+
"the engine cannot remove cursor pixels from consumer-encoded video",
|
|
1020
|
+
));
|
|
1021
|
+
}
|
|
880
1022
|
}
|
|
881
1023
|
let id = opaque_id();
|
|
882
|
-
let wants_control = request.permissions.contains(&Permission::Control);
|
|
883
1024
|
let wants_x11 = matches!(request.source, Some(SourceRequest::X11 { .. }));
|
|
884
1025
|
let max_fps = request.max_fps.clamp(1, 60);
|
|
885
1026
|
// A source the consumer feeds needs no capture backend, no encoder and
|
|
@@ -896,23 +1037,26 @@ impl Session {
|
|
|
896
1037
|
// Which desktop decides which input path is even available: an X display
|
|
897
1038
|
// takes XTest, which cannot reach any other session, while a portal
|
|
898
1039
|
// desktop needs kernel input access.
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
if crate::input::probe().is_err() {
|
|
903
|
-
#[cfg(target_os = "macos")]
|
|
904
|
-
let _ = crate::input::request_access();
|
|
905
|
-
if let Err(unavailable) = crate::input::probe() {
|
|
906
|
-
return Err(SessionError::new(
|
|
907
|
-
"input-unavailable",
|
|
908
|
-
format!("{}; {}", unavailable.reason, unavailable.remedy),
|
|
909
|
-
));
|
|
910
|
-
}
|
|
911
|
-
}
|
|
1040
|
+
#[cfg(target_os = "macos")]
|
|
1041
|
+
if crate::input::probe().is_err() {
|
|
1042
|
+
let _ = crate::input::request_access();
|
|
912
1043
|
}
|
|
913
1044
|
|
|
914
1045
|
let metrics = Arc::new(Mutex::new(Metrics::default()));
|
|
915
1046
|
let mut indicator = None;
|
|
1047
|
+
// The X11 cue starts before capture: its window is in root pixels, so
|
|
1048
|
+
// the capture thread needs the announced cue states from the first grab.
|
|
1049
|
+
#[cfg(target_os = "linux")]
|
|
1050
|
+
if request.agent_indicator && wants_x11 {
|
|
1051
|
+
let display = match &request.source {
|
|
1052
|
+
Some(SourceRequest::X11 { display }) => display.clone(),
|
|
1053
|
+
_ => None,
|
|
1054
|
+
}
|
|
1055
|
+
.or_else(|| std::env::var("DISPLAY").ok());
|
|
1056
|
+
if let Some(display) = display {
|
|
1057
|
+
indicator = crate::indicator::Indicator::start(&display, 0, 0).ok();
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
916
1060
|
let (frame_tx, frame_rx) = latest_frame();
|
|
917
1061
|
let latest = Arc::new(Mutex::new(None::<FrameSnapshot>));
|
|
918
1062
|
let observed = latest.clone();
|
|
@@ -975,7 +1119,7 @@ impl Session {
|
|
|
975
1119
|
let damage = dirty_regions(&hashes, &previous, frame.width, frame.height);
|
|
976
1120
|
*held = Some((
|
|
977
1121
|
seq,
|
|
978
|
-
|
|
1122
|
+
captured_at,
|
|
979
1123
|
frame.width,
|
|
980
1124
|
frame.height,
|
|
981
1125
|
raw,
|
|
@@ -985,7 +1129,11 @@ impl Session {
|
|
|
985
1129
|
frame_tx_signal.send_replace(seq);
|
|
986
1130
|
let _ = frame_events.send(Notice {
|
|
987
1131
|
session_id: frame_session_id.clone(),
|
|
988
|
-
event: SessionEvent::Frame {
|
|
1132
|
+
event: SessionEvent::Frame {
|
|
1133
|
+
seq,
|
|
1134
|
+
damage,
|
|
1135
|
+
timestamp_us: crate::protocol::timestamp_us(captured_at),
|
|
1136
|
+
},
|
|
989
1137
|
});
|
|
990
1138
|
}
|
|
991
1139
|
}
|
|
@@ -1002,6 +1150,21 @@ impl Session {
|
|
|
1002
1150
|
}
|
|
1003
1151
|
});
|
|
1004
1152
|
|
|
1153
|
+
#[cfg(target_os = "linux")]
|
|
1154
|
+
let cursor_sink: Option<crate::cursor::CursorSink> =
|
|
1155
|
+
if request.cursor == crate::protocol::CursorMode::Hidden {
|
|
1156
|
+
let events = events.clone();
|
|
1157
|
+
let id = id.clone();
|
|
1158
|
+
Some(Box::new(move |position| {
|
|
1159
|
+
let _ = events.send(Notice {
|
|
1160
|
+
session_id: id.clone(),
|
|
1161
|
+
event: SessionEvent::Cursor { position },
|
|
1162
|
+
});
|
|
1163
|
+
}))
|
|
1164
|
+
} else {
|
|
1165
|
+
None
|
|
1166
|
+
};
|
|
1167
|
+
let mut cursor_info = serde_json::json!({"mode":"embedded", "positions":false});
|
|
1005
1168
|
let Selected {
|
|
1006
1169
|
source,
|
|
1007
1170
|
capture,
|
|
@@ -1013,6 +1176,10 @@ impl Session {
|
|
|
1013
1176
|
request.max_height,
|
|
1014
1177
|
max_fps,
|
|
1015
1178
|
metrics.clone(),
|
|
1179
|
+
#[cfg(target_os = "linux")]
|
|
1180
|
+
cursor_sink,
|
|
1181
|
+
#[cfg(target_os = "linux")]
|
|
1182
|
+
indicator.as_ref().map(crate::indicator::Indicator::cues),
|
|
1016
1183
|
sink,
|
|
1017
1184
|
Box::new({
|
|
1018
1185
|
let status = capture_status.clone();
|
|
@@ -1025,9 +1192,30 @@ impl Session {
|
|
|
1025
1192
|
Some(SourceRequest::Display { display_id }) => {
|
|
1026
1193
|
portal::open_display(*display_id).await
|
|
1027
1194
|
}
|
|
1028
|
-
_ =>
|
|
1195
|
+
_ => {
|
|
1196
|
+
#[cfg(target_os = "linux")]
|
|
1197
|
+
{
|
|
1198
|
+
portal::open(request.restore_token.as_deref(), request.cursor).await
|
|
1199
|
+
}
|
|
1200
|
+
#[cfg(not(target_os = "linux"))]
|
|
1201
|
+
{
|
|
1202
|
+
portal::open(request.restore_token.as_deref()).await
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
.map_err(|error| {
|
|
1207
|
+
let message = format!("{error:#}");
|
|
1208
|
+
let code = if message.contains("cursor_positions_unavailable:") {
|
|
1209
|
+
"cursor_positions_unavailable"
|
|
1210
|
+
} else {
|
|
1211
|
+
"source"
|
|
1212
|
+
};
|
|
1213
|
+
SessionError::new(code, message)
|
|
1214
|
+
})?;
|
|
1215
|
+
#[cfg(target_os = "linux")]
|
|
1216
|
+
{
|
|
1217
|
+
cursor_info = serde_json::json!({"mode":portal.cursor_mode, "positions":false});
|
|
1029
1218
|
}
|
|
1030
|
-
.map_err(|error| SessionError::new("source", format!("{error:#}")))?;
|
|
1031
1219
|
if let Some(token) = &portal.restore_token {
|
|
1032
1220
|
let _ = events.send(Notice {
|
|
1033
1221
|
session_id: id.clone(),
|
|
@@ -1040,23 +1228,13 @@ impl Session {
|
|
|
1040
1228
|
let (width, height) =
|
|
1041
1229
|
fit(source_w, source_h, request.max_width, request.max_height);
|
|
1042
1230
|
#[cfg(target_os = "linux")]
|
|
1043
|
-
if request.agent_indicator
|
|
1044
|
-
indicator =
|
|
1045
|
-
crate::indicator::Indicator::start_wayland(source_w, source_h).map_err(
|
|
1046
|
-
|error| {
|
|
1047
|
-
SessionError::new("indicator-unavailable", format!("{error:#}"))
|
|
1048
|
-
},
|
|
1049
|
-
)?,
|
|
1050
|
-
);
|
|
1231
|
+
if request.agent_indicator {
|
|
1232
|
+
indicator = crate::indicator::Indicator::start_wayland(source_w, source_h).ok();
|
|
1051
1233
|
}
|
|
1052
1234
|
#[cfg(target_os = "macos")]
|
|
1053
|
-
if request.agent_indicator
|
|
1054
|
-
indicator =
|
|
1055
|
-
crate::indicator::Indicator::start(source.node_id, source_w, source_h)
|
|
1056
|
-
.map_err(|error| {
|
|
1057
|
-
SessionError::new("indicator", format!("{error:#}"))
|
|
1058
|
-
})?,
|
|
1059
|
-
);
|
|
1235
|
+
if request.agent_indicator {
|
|
1236
|
+
indicator =
|
|
1237
|
+
crate::indicator::Indicator::start(source.node_id, source_w, source_h).ok();
|
|
1060
1238
|
}
|
|
1061
1239
|
#[cfg(target_os = "macos")]
|
|
1062
1240
|
let capture = capture::start(
|
|
@@ -1072,12 +1250,26 @@ impl Session {
|
|
|
1072
1250
|
}),
|
|
1073
1251
|
)
|
|
1074
1252
|
.map_err(|error| SessionError::new("source", format!("{error:#}")))?;
|
|
1253
|
+
#[cfg(target_os = "windows")]
|
|
1254
|
+
let capture = capture::start(
|
|
1255
|
+
portal,
|
|
1256
|
+
width,
|
|
1257
|
+
height,
|
|
1258
|
+
max_fps,
|
|
1259
|
+
sink,
|
|
1260
|
+
Box::new({
|
|
1261
|
+
let status = capture_status.clone();
|
|
1262
|
+
move |running, reason| status(running, reason)
|
|
1263
|
+
}),
|
|
1264
|
+
)
|
|
1265
|
+
.map_err(|error| SessionError::new("source", format!("{error:#}")))?;
|
|
1075
1266
|
#[cfg(target_os = "linux")]
|
|
1076
1267
|
let capture = capture::start(
|
|
1077
1268
|
portal,
|
|
1078
1269
|
width,
|
|
1079
1270
|
height,
|
|
1080
1271
|
max_fps,
|
|
1272
|
+
cursor_sink,
|
|
1081
1273
|
sink,
|
|
1082
1274
|
indicator
|
|
1083
1275
|
.as_ref()
|
|
@@ -1089,6 +1281,18 @@ impl Session {
|
|
|
1089
1281
|
)
|
|
1090
1282
|
.map_err(|error| SessionError::new("source", format!("{error:#}")))?;
|
|
1091
1283
|
#[cfg(target_os = "linux")]
|
|
1284
|
+
{
|
|
1285
|
+
if request.cursor == crate::protocol::CursorMode::Hidden
|
|
1286
|
+
&& !capture.cursor_positions()
|
|
1287
|
+
{
|
|
1288
|
+
return Err(SessionError::new(
|
|
1289
|
+
"cursor_positions_unavailable",
|
|
1290
|
+
"portal stream did not provide SPA cursor metadata",
|
|
1291
|
+
));
|
|
1292
|
+
}
|
|
1293
|
+
cursor_info["positions"] = serde_json::json!(capture.cursor_positions());
|
|
1294
|
+
}
|
|
1295
|
+
#[cfg(target_os = "linux")]
|
|
1092
1296
|
if let Some(indicator) = indicator.as_mut() {
|
|
1093
1297
|
indicator.event('A', -1, -1);
|
|
1094
1298
|
}
|
|
@@ -1099,23 +1303,12 @@ impl Session {
|
|
|
1099
1303
|
}
|
|
1100
1304
|
}
|
|
1101
1305
|
};
|
|
1306
|
+
if wants_x11 {
|
|
1307
|
+
cursor_info = serde_json::json!({"mode":"hidden", "positions": request.cursor == crate::protocol::CursorMode::Hidden});
|
|
1308
|
+
}
|
|
1102
1309
|
|
|
1103
1310
|
let source_w = source.width.max(1) as usize;
|
|
1104
1311
|
let source_h = source.height.max(1) as usize;
|
|
1105
|
-
#[cfg(target_os = "linux")]
|
|
1106
|
-
if request.agent_indicator && wants_control && wants_x11 {
|
|
1107
|
-
let display = match &request.source {
|
|
1108
|
-
Some(SourceRequest::X11 {
|
|
1109
|
-
display: Some(display),
|
|
1110
|
-
}) => display.clone(),
|
|
1111
|
-
_ => std::env::var("DISPLAY")
|
|
1112
|
-
.map_err(|_| SessionError::new("indicator-unavailable", "no X display"))?,
|
|
1113
|
-
};
|
|
1114
|
-
indicator = Some(
|
|
1115
|
-
crate::indicator::Indicator::start(&display, source_w, source_h)
|
|
1116
|
-
.map_err(|error| SessionError::new("indicator", format!("{error:#}")))?,
|
|
1117
|
-
);
|
|
1118
|
-
}
|
|
1119
1312
|
let (width, height) = fit(source_w, source_h, request.max_width, request.max_height);
|
|
1120
1313
|
|
|
1121
1314
|
let bitrate_kbps = if request.bitrate_kbps == 0 {
|
|
@@ -1134,40 +1327,30 @@ impl Session {
|
|
|
1134
1327
|
|
|
1135
1328
|
// Everything that can still refuse the session comes before the peer:
|
|
1136
1329
|
// a peer created and then abandoned keeps its socket and tasks alive.
|
|
1137
|
-
let
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
let devices = InputDevices::create(source_w as i32, source_h as i32);
|
|
1153
|
-
#[cfg(target_os = "macos")]
|
|
1154
|
-
let devices = InputDevices::create_for_display(
|
|
1155
|
-
source_w as i32,
|
|
1156
|
-
source_h as i32,
|
|
1157
|
-
source.node_id,
|
|
1158
|
-
);
|
|
1159
|
-
devices.map_err(|error| {
|
|
1160
|
-
SessionError::new("input-unavailable", format!("{error:#}"))
|
|
1161
|
-
})?
|
|
1162
|
-
}),
|
|
1163
|
-
held: HeldState::default(),
|
|
1164
|
-
explicit_modifiers: Vec::new(),
|
|
1165
|
-
chord_modifiers: Vec::new(),
|
|
1166
|
-
chord_keys: Vec::new(),
|
|
1167
|
-
wheel_rest: (0.0, 0.0),
|
|
1168
|
-
},
|
|
1169
|
-
})
|
|
1330
|
+
let applier = match &x11 {
|
|
1331
|
+
Some(desktop) => lock(desktop)
|
|
1332
|
+
.input_available()
|
|
1333
|
+
.then(|| Applier::X11(desktop.clone())),
|
|
1334
|
+
None => {
|
|
1335
|
+
#[cfg(target_os = "linux")]
|
|
1336
|
+
let devices = InputDevices::create(source_w as i32, source_h as i32);
|
|
1337
|
+
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
1338
|
+
let devices = InputDevices::create_for_display(
|
|
1339
|
+
source_w as i32,
|
|
1340
|
+
source_h as i32,
|
|
1341
|
+
source.node_id,
|
|
1342
|
+
);
|
|
1343
|
+
devices.ok().map(Applier::Uinput)
|
|
1344
|
+
}
|
|
1170
1345
|
};
|
|
1346
|
+
let input = applier.map(|applier| InputTarget {
|
|
1347
|
+
applier,
|
|
1348
|
+
held: HeldState::default(),
|
|
1349
|
+
explicit_modifiers: Vec::new(),
|
|
1350
|
+
chord_modifiers: Vec::new(),
|
|
1351
|
+
chord_keys: Vec::new(),
|
|
1352
|
+
wheel_rest: (0.0, 0.0),
|
|
1353
|
+
});
|
|
1171
1354
|
|
|
1172
1355
|
let layout = Layout::from_environment().map_err(|error| {
|
|
1173
1356
|
SessionError::new("input", format!("no keyboard layout: {error:#}"))
|
|
@@ -1210,7 +1393,6 @@ impl Session {
|
|
|
1210
1393
|
let inner = Arc::new(Inner {
|
|
1211
1394
|
id,
|
|
1212
1395
|
generation,
|
|
1213
|
-
permissions: request.permissions,
|
|
1214
1396
|
source,
|
|
1215
1397
|
geometry,
|
|
1216
1398
|
metrics,
|
|
@@ -1221,6 +1403,20 @@ impl Session {
|
|
|
1221
1403
|
encoder: Mutex::new(Some(VideoEncoder::Vp9(encoder))),
|
|
1222
1404
|
input: Mutex::new(input),
|
|
1223
1405
|
indicator: Mutex::new(indicator),
|
|
1406
|
+
point_overlay: Mutex::new(None),
|
|
1407
|
+
#[cfg(target_os = "linux")]
|
|
1408
|
+
point_desktop: if wants_x11 {
|
|
1409
|
+
match &request.source {
|
|
1410
|
+
Some(SourceRequest::X11 { display }) => display
|
|
1411
|
+
.clone()
|
|
1412
|
+
.or_else(|| std::env::var("DISPLAY").ok())
|
|
1413
|
+
.map(PointDesktop::X11)
|
|
1414
|
+
.unwrap_or(PointDesktop::Unavailable("x11-unavailable")),
|
|
1415
|
+
_ => PointDesktop::Unavailable("x11-unavailable"),
|
|
1416
|
+
}
|
|
1417
|
+
} else {
|
|
1418
|
+
PointDesktop::Wayland
|
|
1419
|
+
},
|
|
1224
1420
|
capture: Mutex::new(Some(capture)),
|
|
1225
1421
|
layout: Mutex::new(Some(layout)),
|
|
1226
1422
|
last_seq: Mutex::new(0),
|
|
@@ -1230,6 +1426,7 @@ impl Session {
|
|
|
1230
1426
|
events: events.clone(),
|
|
1231
1427
|
encoded: Mutex::new(None),
|
|
1232
1428
|
local_frames: request.local_frames,
|
|
1429
|
+
cursor_info,
|
|
1233
1430
|
});
|
|
1234
1431
|
|
|
1235
1432
|
inner.notify(SessionEvent::Description {
|
|
@@ -1289,7 +1486,6 @@ impl Session {
|
|
|
1289
1486
|
let inner = Arc::new(Inner {
|
|
1290
1487
|
id,
|
|
1291
1488
|
generation,
|
|
1292
|
-
permissions: request.permissions,
|
|
1293
1489
|
source: SelectedSource {
|
|
1294
1490
|
node_id: 0,
|
|
1295
1491
|
width: width as i32,
|
|
@@ -1308,6 +1504,9 @@ impl Session {
|
|
|
1308
1504
|
encoder: Mutex::new(None),
|
|
1309
1505
|
input: Mutex::new(None),
|
|
1310
1506
|
indicator: Mutex::new(None),
|
|
1507
|
+
point_overlay: Mutex::new(None),
|
|
1508
|
+
#[cfg(target_os = "linux")]
|
|
1509
|
+
point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
|
|
1311
1510
|
capture: Mutex::new(None),
|
|
1312
1511
|
layout: Mutex::new(None),
|
|
1313
1512
|
last_seq: Mutex::new(0),
|
|
@@ -1328,6 +1527,7 @@ impl Session {
|
|
|
1328
1527
|
feed,
|
|
1329
1528
|
})),
|
|
1330
1529
|
local_frames: false,
|
|
1530
|
+
cursor_info: serde_json::json!({"mode":"unavailable", "positions":false}),
|
|
1331
1531
|
});
|
|
1332
1532
|
inner.notify(SessionEvent::State {
|
|
1333
1533
|
capture: "consented",
|
|
@@ -1385,6 +1585,14 @@ impl Session {
|
|
|
1385
1585
|
self.inner.generation
|
|
1386
1586
|
}
|
|
1387
1587
|
|
|
1588
|
+
pub fn agent_indicator(&self) -> bool {
|
|
1589
|
+
lock(&self.inner.indicator).is_some()
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
pub fn cursor_info(&self) -> &serde_json::Value {
|
|
1593
|
+
&self.inner.cursor_info
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1388
1596
|
pub fn geometry(&self) -> &serde_json::Value {
|
|
1389
1597
|
&self.inner.geometry
|
|
1390
1598
|
}
|
|
@@ -1393,6 +1601,122 @@ impl Session {
|
|
|
1393
1601
|
&self.inner.source
|
|
1394
1602
|
}
|
|
1395
1603
|
|
|
1604
|
+
pub fn point(
|
|
1605
|
+
&self,
|
|
1606
|
+
params: &crate::protocol::PointParams,
|
|
1607
|
+
) -> std::result::Result<(), SessionError> {
|
|
1608
|
+
// Lock through validation and drawing, so a concurrent close cannot leave
|
|
1609
|
+
// a newly spawned marker alive after teardown.
|
|
1610
|
+
let mut overlay = lock(&self.inner.point_overlay);
|
|
1611
|
+
if self.inner.closed.load(Ordering::SeqCst) {
|
|
1612
|
+
return Err(SessionError::new("session", "the session is closed"));
|
|
1613
|
+
}
|
|
1614
|
+
if lock(&self.inner.encoded).is_some() {
|
|
1615
|
+
return Err(SessionError::new(
|
|
1616
|
+
"operation",
|
|
1617
|
+
"an encoded source has no host desktop",
|
|
1618
|
+
));
|
|
1619
|
+
}
|
|
1620
|
+
#[cfg(target_os = "linux")]
|
|
1621
|
+
if let PointDesktop::Unavailable(reason) = &self.inner.point_desktop {
|
|
1622
|
+
return Err(SessionError::new(
|
|
1623
|
+
"point_unsupported",
|
|
1624
|
+
format!("reason: {reason}; point overlay is unavailable"),
|
|
1625
|
+
));
|
|
1626
|
+
}
|
|
1627
|
+
if params.clear {
|
|
1628
|
+
if params.x.is_some()
|
|
1629
|
+
|| params.y.is_some()
|
|
1630
|
+
|| params.timeout_ms.is_some()
|
|
1631
|
+
|| !params.label.is_empty()
|
|
1632
|
+
{
|
|
1633
|
+
return Err(SessionError::new(
|
|
1634
|
+
"malformed",
|
|
1635
|
+
"clear accepts no coordinates, label or timeout",
|
|
1636
|
+
));
|
|
1637
|
+
}
|
|
1638
|
+
#[cfg(target_os = "linux")]
|
|
1639
|
+
if overlay.is_none() && matches!(self.inner.point_desktop, PointDesktop::Wayland) {
|
|
1640
|
+
let available = crate::indicator::Indicator::wayland_layer_shell_available()
|
|
1641
|
+
.map_err(|error| {
|
|
1642
|
+
SessionError::new("indicator-unavailable", format!("{error:#}"))
|
|
1643
|
+
})?;
|
|
1644
|
+
if !available {
|
|
1645
|
+
return Err(SessionError::new(
|
|
1646
|
+
"point_unsupported",
|
|
1647
|
+
"reason: layer_shell_unavailable; compositor has no layer-shell",
|
|
1648
|
+
));
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
if let Some(helper) = overlay.take() {
|
|
1652
|
+
helper.stop();
|
|
1653
|
+
}
|
|
1654
|
+
return Ok(());
|
|
1655
|
+
}
|
|
1656
|
+
let (x, y) = params
|
|
1657
|
+
.x
|
|
1658
|
+
.zip(params.y)
|
|
1659
|
+
.ok_or_else(|| SessionError::new("malformed", "point requires x and y"))?;
|
|
1660
|
+
let source = &self.inner.source;
|
|
1661
|
+
if x < 0 || y < 0 || x >= i64::from(source.width) || y >= i64::from(source.height) {
|
|
1662
|
+
return Err(SessionError::new(
|
|
1663
|
+
"coordinates",
|
|
1664
|
+
"point is outside the selected desktop",
|
|
1665
|
+
));
|
|
1666
|
+
}
|
|
1667
|
+
let timeout = params.timeout_ms.unwrap_or(3000);
|
|
1668
|
+
if !(1..=120_000).contains(&timeout)
|
|
1669
|
+
|| params.label.len() > 96
|
|
1670
|
+
|| !params.label.bytes().all(|b| (32..=126).contains(&b))
|
|
1671
|
+
{
|
|
1672
|
+
return Err(SessionError::new(
|
|
1673
|
+
"malformed",
|
|
1674
|
+
"timeout must be 1..120000 ms; label must be at most 96 printable ASCII bytes",
|
|
1675
|
+
));
|
|
1676
|
+
}
|
|
1677
|
+
#[cfg(not(target_os = "windows"))]
|
|
1678
|
+
if overlay.is_none() {
|
|
1679
|
+
#[cfg(target_os = "linux")]
|
|
1680
|
+
let helper = match &self.inner.point_desktop {
|
|
1681
|
+
PointDesktop::X11(display) => crate::indicator::Indicator::start_point(display),
|
|
1682
|
+
PointDesktop::Wayland => crate::indicator::Indicator::start_point_wayland(
|
|
1683
|
+
source.width,
|
|
1684
|
+
source.height,
|
|
1685
|
+
source.position,
|
|
1686
|
+
),
|
|
1687
|
+
PointDesktop::Unavailable(_) => unreachable!("validated point target"),
|
|
1688
|
+
};
|
|
1689
|
+
#[cfg(target_os = "macos")]
|
|
1690
|
+
let helper = crate::indicator::Indicator::start_point(
|
|
1691
|
+
source.node_id,
|
|
1692
|
+
source.width as usize,
|
|
1693
|
+
source.height as usize,
|
|
1694
|
+
);
|
|
1695
|
+
*overlay = Some(helper.map_err(|error| {
|
|
1696
|
+
let message = format!("{error:#}");
|
|
1697
|
+
if message.contains("layer_shell_unavailable") {
|
|
1698
|
+
SessionError::new(
|
|
1699
|
+
"point_unsupported",
|
|
1700
|
+
"reason: layer_shell_unavailable; compositor has no layer-shell",
|
|
1701
|
+
)
|
|
1702
|
+
} else {
|
|
1703
|
+
SessionError::new("indicator-unavailable", message)
|
|
1704
|
+
}
|
|
1705
|
+
})?);
|
|
1706
|
+
}
|
|
1707
|
+
#[cfg(not(target_os = "windows"))]
|
|
1708
|
+
return overlay
|
|
1709
|
+
.as_mut()
|
|
1710
|
+
.unwrap()
|
|
1711
|
+
.point(x, y, timeout, ¶ms.label)
|
|
1712
|
+
.map_err(|error| SessionError::new("indicator-unavailable", format!("{error:#}")));
|
|
1713
|
+
#[cfg(target_os = "windows")]
|
|
1714
|
+
return Err(SessionError::new(
|
|
1715
|
+
"point_unsupported",
|
|
1716
|
+
"Windows point overlays are unavailable in this build.",
|
|
1717
|
+
));
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1396
1720
|
pub async fn wait_frame(
|
|
1397
1721
|
&self,
|
|
1398
1722
|
after_seq: Option<u64>,
|
|
@@ -1459,12 +1783,6 @@ impl Session {
|
|
|
1459
1783
|
path: &str,
|
|
1460
1784
|
region: Option<[usize; 4]>,
|
|
1461
1785
|
) -> std::result::Result<serde_json::Value, SessionError> {
|
|
1462
|
-
if !self.inner.permissions.contains(&Permission::View) {
|
|
1463
|
-
return Err(SessionError::new(
|
|
1464
|
-
"permission",
|
|
1465
|
-
"view permission is required",
|
|
1466
|
-
));
|
|
1467
|
-
}
|
|
1468
1786
|
if lock(&self.inner.encoded).is_some() {
|
|
1469
1787
|
return Err(SessionError::new(
|
|
1470
1788
|
"operation",
|
|
@@ -1507,7 +1825,7 @@ impl Session {
|
|
|
1507
1825
|
}
|
|
1508
1826
|
Some(pixels)
|
|
1509
1827
|
};
|
|
1510
|
-
let result = serde_json::json!({ "seq": seq, "still_ms": at.elapsed().as_millis() as u64,
|
|
1828
|
+
let result = serde_json::json!({ "seq": seq, "timestamp_us": crate::protocol::timestamp_us(*at), "still_ms": at.elapsed().as_millis() as u64,
|
|
1511
1829
|
"width": w, "height": h, "format": "bgrx", "damage": damage, "written": !path.is_empty() });
|
|
1512
1830
|
drop(held);
|
|
1513
1831
|
if let Some(pixels) = pixels {
|
|
@@ -1570,20 +1888,18 @@ impl Session {
|
|
|
1570
1888
|
if let Some((_, reason)) = self.inner.clipboard_refusal() {
|
|
1571
1889
|
return Err(reason.to_owned());
|
|
1572
1890
|
}
|
|
1573
|
-
|
|
1891
|
+
let inner = self.inner.clone();
|
|
1892
|
+
clipboard_task(move || inner.clipboard_read_text()).await
|
|
1574
1893
|
}
|
|
1575
1894
|
|
|
1576
1895
|
pub async fn write_clipboard(&self, text: String) -> std::result::Result<(), String> {
|
|
1577
1896
|
if let Some((_, reason)) = self.inner.clipboard_refusal() {
|
|
1578
1897
|
return Err(reason.to_owned());
|
|
1579
1898
|
}
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
})
|
|
1585
|
-
.await
|
|
1586
|
-
.map(|_written| ())
|
|
1899
|
+
let inner = self.inner.clone();
|
|
1900
|
+
clipboard_task(move || inner.clipboard_write_text(&text))
|
|
1901
|
+
.await
|
|
1902
|
+
.map(|_written| ())
|
|
1587
1903
|
}
|
|
1588
1904
|
|
|
1589
1905
|
pub fn mark_closed_reason(&self) -> Option<String> {
|
|
@@ -1630,6 +1946,9 @@ impl Inner {
|
|
|
1630
1946
|
if let Ok(mut indicator) = self.indicator.lock() {
|
|
1631
1947
|
indicator.take();
|
|
1632
1948
|
}
|
|
1949
|
+
if let Some(helper) = lock(&self.point_overlay).take() {
|
|
1950
|
+
helper.stop();
|
|
1951
|
+
}
|
|
1633
1952
|
// Release first, then tear down: a stuck modifier is the one failure the
|
|
1634
1953
|
// user cannot undo by reconnecting.
|
|
1635
1954
|
if let Ok(mut input) = self.input.lock() {
|
|
@@ -1669,24 +1988,40 @@ impl Inner {
|
|
|
1669
1988
|
}
|
|
1670
1989
|
}
|
|
1671
1990
|
|
|
1672
|
-
fn
|
|
1673
|
-
|
|
1991
|
+
fn clipboard_read_text(&self) -> std::result::Result<(String, bool), String> {
|
|
1992
|
+
#[cfg(target_os = "linux")]
|
|
1993
|
+
if let PointDesktop::X11(display) = &self.point_desktop {
|
|
1994
|
+
return clipboard::x11_read(display).map_err(|error| format!("{error:#}"));
|
|
1995
|
+
}
|
|
1996
|
+
clipboard::read_or_explain()
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
fn clipboard_write_text(&self, text: &str) -> std::result::Result<String, String> {
|
|
2000
|
+
#[cfg(target_os = "linux")]
|
|
2001
|
+
if let PointDesktop::X11(display) = &self.point_desktop {
|
|
2002
|
+
return clipboard::x11_write(display, text)
|
|
2003
|
+
.map(|()| String::new())
|
|
2004
|
+
.map_err(|error| format!("{error:#}"));
|
|
2005
|
+
}
|
|
2006
|
+
clipboard::write(text)
|
|
2007
|
+
.map(|()| String::new())
|
|
2008
|
+
.map_err(|error| format!("{error:#}"))
|
|
1674
2009
|
}
|
|
1675
2010
|
|
|
1676
2011
|
fn clipboard_refusal(&self) -> Option<(&'static str, &'static str)> {
|
|
1677
|
-
if
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
{
|
|
2012
|
+
if self.closed.load(Ordering::Relaxed) {
|
|
2013
|
+
return Some(("session", "the session has ended"));
|
|
2014
|
+
}
|
|
2015
|
+
#[cfg(target_os = "linux")]
|
|
2016
|
+
if self.source.source_type.as_deref() == Some("x11-root") {
|
|
2017
|
+
return None;
|
|
2018
|
+
}
|
|
2019
|
+
if self.source.source_type.as_deref() == Some("encoded") || !wayland_clipboard_available() {
|
|
1682
2020
|
return Some((
|
|
1683
2021
|
"clipboard-unsupported",
|
|
1684
2022
|
"clipboard is unavailable for this desktop source",
|
|
1685
2023
|
));
|
|
1686
2024
|
}
|
|
1687
|
-
if !self.has(Permission::Clipboard) {
|
|
1688
|
-
return Some(("permission", "this session has no clipboard permission"));
|
|
1689
|
-
}
|
|
1690
2025
|
None
|
|
1691
2026
|
}
|
|
1692
2027
|
|
|
@@ -1714,7 +2049,7 @@ impl Inner {
|
|
|
1714
2049
|
Ok(())
|
|
1715
2050
|
}
|
|
1716
2051
|
|
|
1717
|
-
/// Admit one client action. Validation
|
|
2052
|
+
/// Admit one client action. Validation and ordering checks all
|
|
1718
2053
|
/// happen before any physical effect, so a refused action leaves the desktop
|
|
1719
2054
|
/// exactly as it was.
|
|
1720
2055
|
fn apply(self: &Arc<Self>, message: ControlMessage) {
|
|
@@ -1727,10 +2062,6 @@ impl Inner {
|
|
|
1727
2062
|
self.reject(seq, "session", "the control channel is not open");
|
|
1728
2063
|
return;
|
|
1729
2064
|
}
|
|
1730
|
-
if !self.has(Permission::Control) {
|
|
1731
|
-
self.reject(seq, "permission", "this session is view-only");
|
|
1732
|
-
return;
|
|
1733
|
-
}
|
|
1734
2065
|
if seq != 0 {
|
|
1735
2066
|
let mut last = match self.last_seq.lock() {
|
|
1736
2067
|
Ok(last) => last,
|
|
@@ -1767,6 +2098,14 @@ impl Inner {
|
|
|
1767
2098
|
self.forward(message);
|
|
1768
2099
|
return;
|
|
1769
2100
|
}
|
|
2101
|
+
if !matches!(
|
|
2102
|
+
&message,
|
|
2103
|
+
ControlMessage::ClipboardRead { .. } | ControlMessage::ClipboardWrite { .. }
|
|
2104
|
+
) && lock(&self.input).is_none()
|
|
2105
|
+
{
|
|
2106
|
+
self.reject(seq, "input-unavailable", "no input backend");
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
1770
2109
|
let outcome = match message {
|
|
1771
2110
|
ControlMessage::Pointer {
|
|
1772
2111
|
phase,
|
|
@@ -1976,7 +2315,7 @@ impl Inner {
|
|
|
1976
2315
|
if text.len() > 4096 {
|
|
1977
2316
|
return Err(("text-too-large", String::from("text exceeds 4096 bytes")));
|
|
1978
2317
|
}
|
|
1979
|
-
#[cfg(target_os = "macos")]
|
|
2318
|
+
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
1980
2319
|
{
|
|
1981
2320
|
let _ = seq;
|
|
1982
2321
|
return self.with_input(|target| target.unicode_text(text));
|
|
@@ -2042,10 +2381,12 @@ impl Inner {
|
|
|
2042
2381
|
}
|
|
2043
2382
|
let inner = Arc::clone(self);
|
|
2044
2383
|
tokio::spawn(async move {
|
|
2045
|
-
let
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2384
|
+
let reader = inner.clone();
|
|
2385
|
+
let (text, truncated, error) =
|
|
2386
|
+
match clipboard_task(move || reader.clipboard_read_text()).await {
|
|
2387
|
+
Ok((text, truncated)) => (text, truncated, None),
|
|
2388
|
+
Err(reason) => (String::new(), false, Some(reason)),
|
|
2389
|
+
};
|
|
2049
2390
|
let _ = inner.reply(&ControlReply::Clipboard {
|
|
2050
2391
|
request: &request,
|
|
2051
2392
|
text,
|
|
@@ -2062,13 +2403,10 @@ impl Inner {
|
|
|
2062
2403
|
}
|
|
2063
2404
|
let inner = Arc::clone(self);
|
|
2064
2405
|
tokio::spawn(async move {
|
|
2065
|
-
let
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
})
|
|
2070
|
-
.await
|
|
2071
|
-
.err();
|
|
2406
|
+
let writer = inner.clone();
|
|
2407
|
+
let error = clipboard_task(move || writer.clipboard_write_text(&text))
|
|
2408
|
+
.await
|
|
2409
|
+
.err();
|
|
2072
2410
|
let _ = inner.reply(&ControlReply::Clipboard {
|
|
2073
2411
|
request: &request,
|
|
2074
2412
|
text: String::new(),
|
|
@@ -2940,6 +3278,60 @@ fn opaque_id() -> String {
|
|
|
2940
3278
|
|
|
2941
3279
|
#[cfg(test)]
|
|
2942
3280
|
mod tests {
|
|
3281
|
+
#[cfg(target_os = "linux")]
|
|
3282
|
+
#[test]
|
|
3283
|
+
fn x11_cue_mask_restores_only_the_cue_pixels() {
|
|
3284
|
+
use crate::indicator::Cue;
|
|
3285
|
+
let (w, h) = (200usize, 120usize);
|
|
3286
|
+
let at = |x: usize, y: usize| (y * w + x) * 4;
|
|
3287
|
+
let (cx, cy) = (20 + 55, 10 + 55);
|
|
3288
|
+
// Shaped (128) and composited (1) helpers announce different thresholds.
|
|
3289
|
+
for threshold in [128, 1] {
|
|
3290
|
+
let cue = Cue {
|
|
3291
|
+
x: 20,
|
|
3292
|
+
y: 10,
|
|
3293
|
+
size: 110,
|
|
3294
|
+
unit: 1.0,
|
|
3295
|
+
opacity: 1.0,
|
|
3296
|
+
click: -1.0,
|
|
3297
|
+
typed: -1.0,
|
|
3298
|
+
threshold,
|
|
3299
|
+
reveal: 99.0,
|
|
3300
|
+
};
|
|
3301
|
+
let mut mask = CueMask::new(Default::default());
|
|
3302
|
+
let clean: Vec<u8> = (0..w * h * 4).map(|i| (i % 251) as u8).collect();
|
|
3303
|
+
mask.apply(&mut clean.clone(), w, h, vec![]);
|
|
3304
|
+
// Paint the cue's keyline and live content inside the halo.
|
|
3305
|
+
let mut frame = clean.clone();
|
|
3306
|
+
frame[at(cx - 24, cy)..at(cx - 24, cy) + 4].fill(255);
|
|
3307
|
+
frame[at(cx - 5, cy)..at(cx - 5, cy) + 4].fill(7);
|
|
3308
|
+
frame[at(190, 110)..at(190, 110) + 4].fill(9);
|
|
3309
|
+
mask.apply(&mut frame, w, h, vec![cue]);
|
|
3310
|
+
assert_eq!(
|
|
3311
|
+
&frame[at(cx - 24, cy)..at(cx - 24, cy) + 4],
|
|
3312
|
+
&clean[at(cx - 24, cy)..at(cx - 24, cy) + 4]
|
|
3313
|
+
);
|
|
3314
|
+
assert_eq!(
|
|
3315
|
+
&frame[at(cx - 5, cy)..at(cx - 5, cy) + 4],
|
|
3316
|
+
&[7; 4],
|
|
3317
|
+
"the halo interior stays live"
|
|
3318
|
+
);
|
|
3319
|
+
assert_eq!(
|
|
3320
|
+
&frame[at(190, 110)..at(190, 110) + 4],
|
|
3321
|
+
&[9; 4],
|
|
3322
|
+
"pixels away from the cue stay live"
|
|
3323
|
+
);
|
|
3324
|
+
let mut after = clean.clone();
|
|
3325
|
+
after[at(cx - 24, cy)..at(cx - 24, cy) + 4].fill(3);
|
|
3326
|
+
mask.apply(&mut after, w, h, vec![]);
|
|
3327
|
+
assert_eq!(
|
|
3328
|
+
&after[at(cx - 24, cy)..at(cx - 24, cy) + 4],
|
|
3329
|
+
&[3; 4],
|
|
3330
|
+
"a hidden cue masks nothing"
|
|
3331
|
+
);
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
|
|
2943
3335
|
use super::*;
|
|
2944
3336
|
|
|
2945
3337
|
#[tokio::test]
|
|
@@ -2950,7 +3342,6 @@ mod tests {
|
|
|
2950
3342
|
let inner = Arc::new(Inner {
|
|
2951
3343
|
id: String::from("encoded-session"),
|
|
2952
3344
|
generation: 1,
|
|
2953
|
-
permissions: Vec::new(),
|
|
2954
3345
|
source: SelectedSource {
|
|
2955
3346
|
node_id: 0,
|
|
2956
3347
|
width: 640,
|
|
@@ -2973,6 +3364,9 @@ mod tests {
|
|
|
2973
3364
|
encoder: Mutex::new(None),
|
|
2974
3365
|
input: Mutex::new(None),
|
|
2975
3366
|
indicator: Mutex::new(None),
|
|
3367
|
+
point_overlay: Mutex::new(None),
|
|
3368
|
+
#[cfg(target_os = "linux")]
|
|
3369
|
+
point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
|
|
2976
3370
|
capture: Mutex::new(None),
|
|
2977
3371
|
layout: Mutex::new(None),
|
|
2978
3372
|
last_seq: Mutex::new(0),
|
|
@@ -2991,6 +3385,7 @@ mod tests {
|
|
|
2991
3385
|
feed,
|
|
2992
3386
|
})),
|
|
2993
3387
|
local_frames: false,
|
|
3388
|
+
cursor_info: serde_json::json!({"mode":"unavailable", "positions":false}),
|
|
2994
3389
|
});
|
|
2995
3390
|
spawn_peer_events(&inner, peer_events_rx);
|
|
2996
3391
|
spawn_encoded(&inner, feed_rx);
|
|
@@ -3112,6 +3507,136 @@ mod tests {
|
|
|
3112
3507
|
test_inner_with(events, peer, 64, 64)
|
|
3113
3508
|
}
|
|
3114
3509
|
|
|
3510
|
+
#[cfg(target_os = "linux")]
|
|
3511
|
+
#[tokio::test]
|
|
3512
|
+
async fn a_wayland_portal_session_reports_typed_point_unsupported() {
|
|
3513
|
+
let (events, _) = tokio_mpsc::unbounded_channel();
|
|
3514
|
+
let (inner, _) = test_inner(events).await;
|
|
3515
|
+
let session = Session { inner };
|
|
3516
|
+
|
|
3517
|
+
let error = session
|
|
3518
|
+
.point(&crate::protocol::PointParams {
|
|
3519
|
+
session_id: String::from("test-session"),
|
|
3520
|
+
x: Some(10),
|
|
3521
|
+
y: Some(20),
|
|
3522
|
+
label: String::new(),
|
|
3523
|
+
timeout_ms: None,
|
|
3524
|
+
clear: false,
|
|
3525
|
+
})
|
|
3526
|
+
.expect_err("compositors without layer-shell do not support point overlays");
|
|
3527
|
+
|
|
3528
|
+
assert_eq!(error.code, "point_unsupported");
|
|
3529
|
+
assert!(error.message.contains("reason: layer_shell_unavailable"));
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
// Driven only on the private headless compositor created by point-wayland-flow.py.
|
|
3533
|
+
#[cfg(target_os = "linux")]
|
|
3534
|
+
#[tokio::test]
|
|
3535
|
+
#[ignore = "requires the task-owned compositor and screenshot driver"]
|
|
3536
|
+
async fn point_wayland_private_flow() {
|
|
3537
|
+
let evidence = std::path::PathBuf::from(
|
|
3538
|
+
std::env::var_os("DESKLINK_POINT_TEST_DIR").expect("private evidence"),
|
|
3539
|
+
);
|
|
3540
|
+
let socket =
|
|
3541
|
+
std::path::PathBuf::from(std::env::var_os("WAYLAND_DISPLAY").expect("private socket"));
|
|
3542
|
+
assert!(
|
|
3543
|
+
socket.is_absolute() && socket.starts_with(evidence.join("runtime")),
|
|
3544
|
+
"never use an ambient desktop"
|
|
3545
|
+
);
|
|
3546
|
+
fn checkpoint(dir: &std::path::Path, name: &str) {
|
|
3547
|
+
std::fs::write(dir.join(format!("{name}.ready")), b"").unwrap();
|
|
3548
|
+
let deadline = Instant::now() + Duration::from_secs(15);
|
|
3549
|
+
while !dir.join(format!("{name}.continue")).exists() {
|
|
3550
|
+
assert!(
|
|
3551
|
+
Instant::now() < deadline,
|
|
3552
|
+
"screenshot driver timed out at {name}"
|
|
3553
|
+
);
|
|
3554
|
+
std::thread::sleep(Duration::from_millis(20));
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
let (events, _) = tokio_mpsc::unbounded_channel();
|
|
3558
|
+
let (mut inner, _) = test_inner(events).await;
|
|
3559
|
+
let state = Arc::get_mut(&mut inner).unwrap();
|
|
3560
|
+
state.point_desktop = PointDesktop::Wayland;
|
|
3561
|
+
state.source.width = 1280;
|
|
3562
|
+
state.source.height = 720;
|
|
3563
|
+
state.source.position = Some((1280, 0));
|
|
3564
|
+
let mut session = Session { inner };
|
|
3565
|
+
let mut params = crate::protocol::PointParams {
|
|
3566
|
+
session_id: String::from("test-session"),
|
|
3567
|
+
x: Some(400),
|
|
3568
|
+
y: Some(300),
|
|
3569
|
+
label: String::from("Here"),
|
|
3570
|
+
timeout_ms: Some(3000),
|
|
3571
|
+
clear: false,
|
|
3572
|
+
};
|
|
3573
|
+
let unsupported_socket = std::env::var_os("DESKLINK_POINT_UNSUPPORTED_SOCKET").unwrap();
|
|
3574
|
+
assert!(std::path::Path::new(&unsupported_socket).starts_with(evidence.join("runtime")));
|
|
3575
|
+
// This test runs alone, in a separate process. Only child helper startup
|
|
3576
|
+
// and the read-only clear probe read this private socket environment.
|
|
3577
|
+
std::env::set_var("WAYLAND_DISPLAY", &unsupported_socket);
|
|
3578
|
+
let refusal = session.point(¶ms).unwrap_err();
|
|
3579
|
+
assert_eq!(refusal.code, "point_unsupported");
|
|
3580
|
+
assert!(refusal.message.contains("reason: layer_shell_unavailable"));
|
|
3581
|
+
let clear = crate::protocol::PointParams {
|
|
3582
|
+
session_id: params.session_id.clone(),
|
|
3583
|
+
clear: true,
|
|
3584
|
+
x: None,
|
|
3585
|
+
y: None,
|
|
3586
|
+
label: String::new(),
|
|
3587
|
+
timeout_ms: None,
|
|
3588
|
+
};
|
|
3589
|
+
let refusal = session.point(&clear).unwrap_err();
|
|
3590
|
+
assert_eq!(refusal.code, "point_unsupported");
|
|
3591
|
+
std::env::set_var("WAYLAND_DISPLAY", &socket);
|
|
3592
|
+
Arc::get_mut(&mut session.inner).unwrap().source.position = None;
|
|
3593
|
+
let ambiguous = session.point(¶ms).unwrap_err();
|
|
3594
|
+
assert_eq!(ambiguous.code, "indicator-unavailable");
|
|
3595
|
+
assert!(ambiguous.message.contains("selected_output_unavailable"));
|
|
3596
|
+
Arc::get_mut(&mut session.inner).unwrap().source.position = Some((1280, 0));
|
|
3597
|
+
checkpoint(&evidence, "before");
|
|
3598
|
+
session.point(¶ms).unwrap();
|
|
3599
|
+
checkpoint(&evidence, "after");
|
|
3600
|
+
params.x = Some(1280);
|
|
3601
|
+
assert_eq!(session.point(¶ms).unwrap_err().code, "coordinates");
|
|
3602
|
+
params.x = Some(400);
|
|
3603
|
+
params.label = String::from("bad\nlabel");
|
|
3604
|
+
assert_eq!(session.point(¶ms).unwrap_err().code, "malformed");
|
|
3605
|
+
checkpoint(&evidence, "invalid");
|
|
3606
|
+
tokio::time::sleep(Duration::from_millis(3100)).await;
|
|
3607
|
+
checkpoint(&evidence, "expired");
|
|
3608
|
+
params.x = Some(700);
|
|
3609
|
+
params.y = Some(500);
|
|
3610
|
+
params.label.clear();
|
|
3611
|
+
params.timeout_ms = Some(10000);
|
|
3612
|
+
session.point(¶ms).unwrap();
|
|
3613
|
+
checkpoint(&evidence, "replaced");
|
|
3614
|
+
params.clear = true;
|
|
3615
|
+
params.x = None;
|
|
3616
|
+
params.y = None;
|
|
3617
|
+
params.timeout_ms = None;
|
|
3618
|
+
session.point(¶ms).unwrap();
|
|
3619
|
+
session.point(¶ms).unwrap();
|
|
3620
|
+
assert!(lock(&session.inner.point_overlay).is_none());
|
|
3621
|
+
checkpoint(&evidence, "cleared");
|
|
3622
|
+
params.clear = false;
|
|
3623
|
+
params.x = Some(400);
|
|
3624
|
+
params.y = Some(300);
|
|
3625
|
+
session.point(¶ms).unwrap();
|
|
3626
|
+
let pid = lock(&session.inner.point_overlay)
|
|
3627
|
+
.as_ref()
|
|
3628
|
+
.unwrap()
|
|
3629
|
+
.test_pid();
|
|
3630
|
+
checkpoint(&evidence, "clickthrough");
|
|
3631
|
+
session.close("test complete").await;
|
|
3632
|
+
assert_eq!(
|
|
3633
|
+
unsafe { libc::kill(pid as i32, 0) },
|
|
3634
|
+
-1,
|
|
3635
|
+
"helper reaped on session close"
|
|
3636
|
+
);
|
|
3637
|
+
checkpoint(&evidence, "closed");
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3115
3640
|
fn test_inner_with(
|
|
3116
3641
|
events: tokio_mpsc::UnboundedSender<Notice>,
|
|
3117
3642
|
peer: VideoPeer,
|
|
@@ -3124,7 +3649,6 @@ mod tests {
|
|
|
3124
3649
|
let inner = Arc::new(Inner {
|
|
3125
3650
|
id: String::from("test-session"),
|
|
3126
3651
|
generation: 1,
|
|
3127
|
-
permissions: vec![Permission::Control],
|
|
3128
3652
|
source: SelectedSource {
|
|
3129
3653
|
node_id: 0,
|
|
3130
3654
|
width: 640,
|
|
@@ -3157,6 +3681,9 @@ mod tests {
|
|
|
3157
3681
|
})),
|
|
3158
3682
|
capture: Mutex::new(None),
|
|
3159
3683
|
indicator: Mutex::new(None),
|
|
3684
|
+
point_overlay: Mutex::new(None),
|
|
3685
|
+
#[cfg(target_os = "linux")]
|
|
3686
|
+
point_desktop: PointDesktop::Unavailable("layer_shell_unavailable"),
|
|
3160
3687
|
layout: Mutex::new(Some(Layout::from_environment().expect("a keymap"))),
|
|
3161
3688
|
last_seq: Mutex::new(0),
|
|
3162
3689
|
control_open: AtomicBool::new(true),
|
|
@@ -3165,15 +3692,51 @@ mod tests {
|
|
|
3165
3692
|
events,
|
|
3166
3693
|
encoded: Mutex::new(None),
|
|
3167
3694
|
local_frames: true,
|
|
3695
|
+
cursor_info: serde_json::json!({"mode":"embedded", "positions":false}),
|
|
3168
3696
|
});
|
|
3169
3697
|
(inner, recorded)
|
|
3170
3698
|
}
|
|
3171
3699
|
|
|
3700
|
+
#[tokio::test]
|
|
3701
|
+
async fn paired_input_without_a_backend_is_an_action_failure() {
|
|
3702
|
+
let (events, _) = tokio_mpsc::unbounded_channel();
|
|
3703
|
+
let (inner, recorded) = test_inner(events).await;
|
|
3704
|
+
*lock(&inner.input) = None;
|
|
3705
|
+
assert_eq!(
|
|
3706
|
+
inner.with_input(|_| Ok(())).unwrap_err().0,
|
|
3707
|
+
"input-unavailable"
|
|
3708
|
+
);
|
|
3709
|
+
inner.apply(ControlMessage::Pointer {
|
|
3710
|
+
phase: PointerPhase::Move,
|
|
3711
|
+
x: 10,
|
|
3712
|
+
y: 20,
|
|
3713
|
+
button: 1,
|
|
3714
|
+
seq: 1,
|
|
3715
|
+
});
|
|
3716
|
+
assert_eq!(
|
|
3717
|
+
*lock(&inner.last_seq),
|
|
3718
|
+
1,
|
|
3719
|
+
"paired action passes admission, never a permission refusal"
|
|
3720
|
+
);
|
|
3721
|
+
assert_eq!(lock(&inner.metrics).input_rejected, 1);
|
|
3722
|
+
assert_eq!(lock(&inner.metrics).input_applied, 0);
|
|
3723
|
+
assert!(lock(&recorded).is_empty());
|
|
3724
|
+
assert!(
|
|
3725
|
+
!inner.closed.load(Ordering::Relaxed),
|
|
3726
|
+
"missing input does not close view access"
|
|
3727
|
+
);
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3172
3730
|
#[tokio::test]
|
|
3173
3731
|
async fn a_lease_that_expires_ends_the_session_and_releases_held_input() {
|
|
3174
3732
|
let (events, mut received) = tokio_mpsc::unbounded_channel();
|
|
3175
3733
|
let (inner, recorded) = test_inner(events).await;
|
|
3176
3734
|
|
|
3735
|
+
#[cfg(target_os = "windows")]
|
|
3736
|
+
inner
|
|
3737
|
+
.with_input(|target| target.chord(1, Vec::new(), true))
|
|
3738
|
+
.unwrap();
|
|
3739
|
+
#[cfg(not(target_os = "windows"))]
|
|
3177
3740
|
inner.apply(ControlMessage::Key {
|
|
3178
3741
|
name: None,
|
|
3179
3742
|
character: Some(String::from("a")),
|
|
@@ -3227,6 +3790,7 @@ mod tests {
|
|
|
3227
3790
|
);
|
|
3228
3791
|
}
|
|
3229
3792
|
|
|
3793
|
+
#[cfg(not(target_os = "windows"))]
|
|
3230
3794
|
#[tokio::test]
|
|
3231
3795
|
async fn a_held_modifier_survives_character_named_and_text_chords() {
|
|
3232
3796
|
use crate::input::keycode;
|
|
@@ -3421,38 +3985,24 @@ mod tests {
|
|
|
3421
3985
|
}
|
|
3422
3986
|
|
|
3423
3987
|
#[tokio::test]
|
|
3424
|
-
async fn
|
|
3988
|
+
async fn x11_capture_does_not_refuse_clipboard() {
|
|
3425
3989
|
let (events, _received) = tokio_mpsc::unbounded_channel();
|
|
3426
3990
|
let (mut inner, _recorded) = test_inner(events).await;
|
|
3427
3991
|
let owned = Arc::get_mut(&mut inner).unwrap();
|
|
3428
3992
|
owned.source.source_type = Some(String::from("x11-root"));
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
.unwrap_err()
|
|
3437
|
-
.contains("unavailable"));
|
|
3438
|
-
assert!(session
|
|
3439
|
-
.write_clipboard(String::from("secret"))
|
|
3440
|
-
.await
|
|
3441
|
-
.unwrap_err()
|
|
3442
|
-
.contains("unavailable"));
|
|
3443
|
-
inner.clipboard_read(String::from("read"));
|
|
3444
|
-
inner.clipboard_write(String::from("write"), String::from("secret"));
|
|
3445
|
-
assert_eq!(inner.metrics.lock().unwrap().input_rejected, 2);
|
|
3993
|
+
#[cfg(target_os = "linux")]
|
|
3994
|
+
assert_eq!(inner.clipboard_refusal(), None);
|
|
3995
|
+
Arc::get_mut(&mut inner).unwrap().source.source_type = Some(String::from("encoded"));
|
|
3996
|
+
assert_eq!(
|
|
3997
|
+
inner.clipboard_refusal().unwrap().0,
|
|
3998
|
+
"clipboard-unsupported"
|
|
3999
|
+
);
|
|
3446
4000
|
}
|
|
3447
4001
|
|
|
3448
4002
|
#[tokio::test]
|
|
3449
4003
|
async fn a_stopped_capture_never_serves_the_last_frame() {
|
|
3450
4004
|
let (events, _) = tokio_mpsc::unbounded_channel();
|
|
3451
|
-
let (
|
|
3452
|
-
Arc::get_mut(&mut inner)
|
|
3453
|
-
.unwrap()
|
|
3454
|
-
.permissions
|
|
3455
|
-
.push(Permission::View);
|
|
4005
|
+
let (inner, _) = test_inner(events).await;
|
|
3456
4006
|
let session = Session { inner };
|
|
3457
4007
|
*lock(&session.inner.latest) =
|
|
3458
4008
|
Some((1, Instant::now(), 2, 2, vec![0; 16], vec![1], vec![]));
|
|
@@ -3526,12 +4076,12 @@ mod tests {
|
|
|
3526
4076
|
|
|
3527
4077
|
fn encoded_open() -> OpenRequest {
|
|
3528
4078
|
OpenRequest {
|
|
4079
|
+
cursor: crate::protocol::CursorMode::Embedded,
|
|
3529
4080
|
source: Some(SourceRequest::Encoded {
|
|
3530
4081
|
codec: EncodedCodec::H264,
|
|
3531
4082
|
width: 486,
|
|
3532
4083
|
height: 1080,
|
|
3533
4084
|
}),
|
|
3534
|
-
permissions: vec![Permission::View, Permission::Control],
|
|
3535
4085
|
max_width: 3840,
|
|
3536
4086
|
max_height: 2160,
|
|
3537
4087
|
bitrate_kbps: 0,
|
|
@@ -3552,6 +4102,18 @@ mod tests {
|
|
|
3552
4102
|
.expect("the channel stays open")
|
|
3553
4103
|
}
|
|
3554
4104
|
|
|
4105
|
+
#[tokio::test]
|
|
4106
|
+
async fn hidden_cursor_is_refused_for_consumer_encoded_pixels() {
|
|
4107
|
+
let (events, _) = tokio_mpsc::unbounded_channel();
|
|
4108
|
+
let mut request = encoded_open();
|
|
4109
|
+
request.cursor = crate::protocol::CursorMode::Hidden;
|
|
4110
|
+
let error = Session::open(request, events)
|
|
4111
|
+
.await
|
|
4112
|
+
.err()
|
|
4113
|
+
.expect("cannot alter encoded pixels");
|
|
4114
|
+
assert_eq!(error.code, "cursor-unavailable");
|
|
4115
|
+
}
|
|
4116
|
+
|
|
3555
4117
|
#[tokio::test]
|
|
3556
4118
|
async fn an_encoded_session_opens_without_capture_encoder_or_input() {
|
|
3557
4119
|
let (events, _received) = tokio_mpsc::unbounded_channel();
|
|
@@ -3693,39 +4255,16 @@ mod tests {
|
|
|
3693
4255
|
);
|
|
3694
4256
|
}
|
|
3695
4257
|
|
|
3696
|
-
#[tokio::test]
|
|
3697
|
-
async fn opening_without_view_permission_is_refused_before_capture() {
|
|
3698
|
-
let (events, _received) = tokio_mpsc::unbounded_channel();
|
|
3699
|
-
let result = Session::open(
|
|
3700
|
-
OpenRequest {
|
|
3701
|
-
source: None,
|
|
3702
|
-
permissions: vec![Permission::Control],
|
|
3703
|
-
max_width: 640,
|
|
3704
|
-
max_height: 480,
|
|
3705
|
-
bitrate_kbps: 0,
|
|
3706
|
-
max_fps: 30,
|
|
3707
|
-
ice_servers: Vec::new(),
|
|
3708
|
-
restore_token: None,
|
|
3709
|
-
ttl: None,
|
|
3710
|
-
loopback_tcp: false,
|
|
3711
|
-
agent_indicator: false,
|
|
3712
|
-
local_frames: true,
|
|
3713
|
-
},
|
|
3714
|
-
events,
|
|
3715
|
-
)
|
|
3716
|
-
.await;
|
|
3717
|
-
let error = match result {
|
|
3718
|
-
Ok(_) => panic!("a viewless session must not open"),
|
|
3719
|
-
Err(error) => error,
|
|
3720
|
-
};
|
|
3721
|
-
assert_eq!(error.code, "permission");
|
|
3722
|
-
}
|
|
3723
|
-
|
|
3724
4258
|
#[tokio::test]
|
|
3725
4259
|
async fn a_lost_transport_releases_input_then_ends_the_session_without_sctp_close() {
|
|
3726
4260
|
use webrtc::peer_connection::RTCPeerConnectionState as State;
|
|
3727
4261
|
let (events, mut received) = tokio_mpsc::unbounded_channel();
|
|
3728
4262
|
let (inner, recorded) = test_inner(events).await;
|
|
4263
|
+
#[cfg(target_os = "windows")]
|
|
4264
|
+
inner
|
|
4265
|
+
.with_input(|target| target.chord(1, Vec::new(), true))
|
|
4266
|
+
.unwrap();
|
|
4267
|
+
#[cfg(not(target_os = "windows"))]
|
|
3729
4268
|
inner.apply(ControlMessage::Key {
|
|
3730
4269
|
name: Some(String::from("Control")),
|
|
3731
4270
|
character: None,
|
|
@@ -3942,7 +4481,10 @@ mod tests {
|
|
|
3942
4481
|
1000,
|
|
3943
4482
|
);
|
|
3944
4483
|
|
|
3945
|
-
// 40 frames over 200 ms
|
|
4484
|
+
// 40 frames nominally over 200 ms; Windows timers can round each 5 ms
|
|
4485
|
+
// sleep upward, so its cap assertion uses the actual burst duration.
|
|
4486
|
+
#[cfg(target_os = "windows")]
|
|
4487
|
+
let burst_started = Instant::now();
|
|
3946
4488
|
for _ in 0..40 {
|
|
3947
4489
|
frame_tx.put(
|
|
3948
4490
|
I420 {
|
|
@@ -3955,9 +4497,13 @@ mod tests {
|
|
|
3955
4497
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
3956
4498
|
}
|
|
3957
4499
|
let motion = inner.metrics.lock().unwrap().encoded_frames;
|
|
4500
|
+
#[cfg(target_os = "windows")]
|
|
4501
|
+
let cap = (burst_started.elapsed().as_secs_f64() * 20.0).ceil() as u64 + 3;
|
|
4502
|
+
#[cfg(not(target_os = "windows"))]
|
|
4503
|
+
let cap = 7;
|
|
3958
4504
|
assert!(
|
|
3959
|
-
motion <=
|
|
3960
|
-
"the requested rate is a cap, got {motion} of 40"
|
|
4505
|
+
motion <= cap,
|
|
4506
|
+
"the requested rate is a cap, got {motion} of 40 (cap {cap})"
|
|
3961
4507
|
);
|
|
3962
4508
|
assert!(
|
|
3963
4509
|
motion >= 2,
|