@desklink/host 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/NOTICE +23 -0
  2. package/README.md +107 -55
  3. package/bin/desklink-host.mjs +2 -2
  4. package/dist/bridge.d.ts +1 -0
  5. package/dist/bridge.d.ts.map +1 -1
  6. package/dist/bridge.js +12 -4
  7. package/dist/bridge.js.map +1 -1
  8. package/dist/engineProcess.d.ts.map +1 -1
  9. package/dist/engineProcess.js +1 -0
  10. package/dist/engineProcess.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/keeper.d.ts +52 -0
  16. package/dist/keeper.d.ts.map +1 -0
  17. package/dist/keeper.js +101 -0
  18. package/dist/keeper.js.map +1 -0
  19. package/dist/protocol.d.ts +26 -0
  20. package/dist/protocol.d.ts.map +1 -1
  21. package/dist/protocol.js.map +1 -1
  22. package/dist/resolveEngine.d.ts +3 -3
  23. package/dist/resolveEngine.d.ts.map +1 -1
  24. package/dist/resolveEngine.js +24 -11
  25. package/dist/resolveEngine.js.map +1 -1
  26. package/docs/BOUNDARY.md +20 -8
  27. package/docs/PROTOCOL.md +347 -24
  28. package/engine/Cargo.lock +47 -1
  29. package/engine/Cargo.toml +13 -2
  30. package/engine/build.rs +28 -2
  31. package/engine/native/nvenc_shim.c +453 -0
  32. package/engine/native/vaapi_shim.c +727 -0
  33. package/engine/native/vpx_shim.c +54 -4
  34. package/engine/native/vt_shim.c +448 -0
  35. package/engine/src/capture.rs +75 -55
  36. package/engine/src/capture_mac.rs +24 -14
  37. package/engine/src/convert.rs +286 -61
  38. package/engine/src/encoder.rs +122 -13
  39. package/engine/src/h264.rs +188 -0
  40. package/engine/src/h264_nvenc.rs +308 -0
  41. package/engine/src/h264_openh264.rs +735 -0
  42. package/engine/src/h264_vaapi.rs +263 -0
  43. package/engine/src/h264_vt.rs +368 -0
  44. package/engine/src/keeper.rs +622 -0
  45. package/engine/src/mac.rs +9 -6
  46. package/engine/src/main.rs +64 -4
  47. package/engine/src/peer.rs +887 -42
  48. package/engine/src/protocol.rs +153 -4
  49. package/engine/src/session.rs +1547 -137
  50. package/engine/src/x11.rs +210 -9
  51. package/engine/src/x11_mac.rs +12 -0
  52. package/engine/vendor/libva/COPYING +19 -0
  53. package/engine/vendor/libva/va/va.h +5450 -0
  54. package/engine/vendor/libva/va/va_dec_av1.h +695 -0
  55. package/engine/vendor/libva/va/va_dec_hevc.h +616 -0
  56. package/engine/vendor/libva/va/va_dec_jpeg.h +157 -0
  57. package/engine/vendor/libva/va/va_dec_vp8.h +249 -0
  58. package/engine/vendor/libva/va/va_dec_vp9.h +312 -0
  59. package/engine/vendor/libva/va/va_dec_vvc.h +688 -0
  60. package/engine/vendor/libva/va/va_drm.h +61 -0
  61. package/engine/vendor/libva/va/va_enc_av1.h +1010 -0
  62. package/engine/vendor/libva/va/va_enc_h264.h +659 -0
  63. package/engine/vendor/libva/va/va_enc_hevc.h +958 -0
  64. package/engine/vendor/libva/va/va_enc_jpeg.h +162 -0
  65. package/engine/vendor/libva/va/va_enc_mpeg2.h +308 -0
  66. package/engine/vendor/libva/va/va_enc_vp8.h +350 -0
  67. package/engine/vendor/libva/va/va_enc_vp9.h +639 -0
  68. package/engine/vendor/libva/va/va_fei.h +170 -0
  69. package/engine/vendor/libva/va/va_fei_h264.h +505 -0
  70. package/engine/vendor/libva/va/va_fei_hevc.h +254 -0
  71. package/engine/vendor/libva/va/va_prot.h +525 -0
  72. package/engine/vendor/libva/va/va_version.h +87 -0
  73. package/engine/vendor/libva/va/va_vpp.h +1590 -0
  74. package/engine/vendor/nv-codec-headers/LICENSE +24 -0
  75. package/engine/vendor/nv-codec-headers/README +7 -0
  76. package/engine/vendor/nv-codec-headers/include/ffnvcodec/nvEncodeAPI.h +3825 -0
  77. package/examples/reference-client.html +1 -1
  78. package/package.json +3 -2
@@ -0,0 +1,622 @@
1
+ //! The display keeper: a kiosk window manager for one X screen.
2
+ //!
3
+ //! A consumer that owns a private X server (one `Xvfb` per screen it created)
4
+ //! runs the keeper on it. The keeper takes SubstructureRedirect on the root
5
+ //! window — it is that screen's window manager — fills the screen with every
6
+ //! normal top-level window, parks tiny helper windows off-screen, keeps the
7
+ //! input focus on the newest window, and reports the screen's windows as one
8
+ //! JSON line per change on stdout.
9
+ //!
10
+ //! That report is what lets a consumer say "a browser is open on this screen"
11
+ //! without asking any browser tool: it reads what is really mapped.
12
+ //!
13
+ //! The contract is a stdout JSON-lines stream and nothing else; there are no
14
+ //! stdin commands. Authentication is the ambient `XAUTHORITY`. The keeper exits
15
+ //! by itself when the display goes away, which is the normal end of its life,
16
+ //! and reports fatal refusals as one error line before exiting.
17
+
18
+ use anyhow::{anyhow, Result};
19
+ use std::collections::VecDeque;
20
+ use std::io::{BufWriter, Write};
21
+ use std::time::{Duration, Instant};
22
+ use x11rb::connection::Connection;
23
+ use x11rb::errors::ReplyError;
24
+ use x11rb::protocol::xproto::{
25
+ Atom, AtomEnum, ChangeWindowAttributesAux, ConfigureWindowAux, ConnectionExt as _, EventMask,
26
+ InputFocus, MapState, StackMode, Window, WindowClass, ACCESS_ERROR,
27
+ };
28
+ use x11rb::protocol::Event;
29
+ use x11rb::rust_connection::RustConnection;
30
+
31
+ /// A top-level window smaller than this on either side is not content. An
32
+ /// emulator draws its side toolbar as a tiny second window; parked off-screen,
33
+ /// it cannot end up in a capture of the screen.
34
+ pub const MIN_CONTENT_SIDE: u16 = 120;
35
+
36
+ /// Where parked windows live: far enough off any real screen to be invisible.
37
+ pub const PARKED_XY: i16 = -30000;
38
+
39
+ /// A change is reported after this much quiet on the screen.
40
+ const DEBOUNCE: Duration = Duration::from_millis(100);
41
+
42
+ /// … but never later than this after the first unreported change, so a busy
43
+ /// screen cannot starve its consumer of reports.
44
+ const MAX_REPORT_DELAY: Duration = Duration::from_millis(300);
45
+
46
+ /// One top-level window on the kept screen, as reported to the consumer.
47
+ pub struct KeeperWindow {
48
+ pub id: u32,
49
+ pub title: Option<String>,
50
+ pub class: Option<String>,
51
+ pub pid: Option<u32>,
52
+ pub width: u16,
53
+ pub height: u16,
54
+ }
55
+
56
+ /// A window this size or larger on both sides is content; anything smaller is
57
+ /// parked, and parked means never focused either.
58
+ fn is_content(width: u16, height: u16) -> bool {
59
+ width >= MIN_CONTENT_SIDE && height >= MIN_CONTENT_SIDE
60
+ }
61
+
62
+ /// Where a top-level window of this size belongs on a screen of `screen_w` by
63
+ /// `screen_h`: filling the screen, or parked off it when it is too small to be
64
+ /// content.
65
+ fn kiosk_geometry(width: u16, height: u16, screen_w: u16, screen_h: u16) -> (i32, i32, u32, u32) {
66
+ if is_content(width, height) {
67
+ (0, 0, u32::from(screen_w), u32::from(screen_h))
68
+ } else {
69
+ (
70
+ i32::from(PARKED_XY),
71
+ i32::from(PARKED_XY),
72
+ u32::from(width),
73
+ u32::from(height),
74
+ )
75
+ }
76
+ }
77
+
78
+ /// `WM_CLASS` is two NUL-separated strings: instance, then class. The class is
79
+ /// the one consumers filter on ("Chromium", "Google-chrome").
80
+ fn decode_wm_class(data: &[u8]) -> Option<String> {
81
+ let mut strings = data
82
+ .split(|&byte| byte == 0)
83
+ .filter(|part| !part.is_empty());
84
+ strings.next()?;
85
+ strings.next().map(latin1)
86
+ }
87
+
88
+ /// A title is the trimmed text; empty is no title. The caller decodes the
89
+ /// property's bytes (`_NET_WM_NAME` is UTF-8; the `WM_NAME` fallback Latin-1).
90
+ fn decode_title(title: &str) -> Option<String> {
91
+ let title = title.trim().to_owned();
92
+ (!title.is_empty()).then_some(title)
93
+ }
94
+
95
+ /// `STRING` properties are ISO Latin-1.
96
+ fn latin1(bytes: &[u8]) -> String {
97
+ bytes.iter().map(|&byte| byte as char).collect()
98
+ }
99
+
100
+ /// The stdout line that reports a window list.
101
+ fn window_line(windows: &[KeeperWindow]) -> String {
102
+ serde_json::to_string(&serde_json::json!({
103
+ "windows": windows
104
+ .iter()
105
+ .map(|window| serde_json::json!({
106
+ "id": window.id,
107
+ "title": window.title,
108
+ "class": window.class,
109
+ "pid": window.pid,
110
+ "width": window.width,
111
+ "height": window.height,
112
+ }))
113
+ .collect::<Vec<_>>()
114
+ }))
115
+ .expect("a window report always serialises")
116
+ }
117
+
118
+ /// The display a `keep` invocation names: the value after `--display`, and
119
+ /// nothing else. One spelling, per the documented contract.
120
+ fn display_argument(args: &[String]) -> Option<String> {
121
+ let mut remaining = args.iter();
122
+ while let Some(arg) = remaining.next() {
123
+ if arg == "--display" {
124
+ return remaining.next().cloned();
125
+ }
126
+ }
127
+ None
128
+ }
129
+
130
+ /// `desklink-host keep --display :N`: keep one display until it goes away. The
131
+ /// display is required on purpose: a bare `keep` must never go hunting for a
132
+ /// screen to manage.
133
+ pub fn command(args: &[String]) -> i32 {
134
+ if args.iter().any(|arg| arg == "--help" || arg == "-h") {
135
+ println!("usage: desklink-host keep --display :N");
136
+ return 0;
137
+ }
138
+ let display = display_argument(args);
139
+ let mut out = BufWriter::new(std::io::stdout().lock());
140
+ match keep(display.as_deref(), &mut out) {
141
+ Ok(()) => 0,
142
+ Err(error) => {
143
+ eprintln!("error: {error:#}");
144
+ 1
145
+ }
146
+ }
147
+ }
148
+
149
+ /// Runs the keeper until the display goes away. A refused startup is reported
150
+ /// as the protocol's one-line error object on stdout and returned as `Err`;
151
+ /// the display going away is a normal end and returns `Ok`.
152
+ pub fn keep(display: Option<&str>, out: &mut impl Write) -> Result<()> {
153
+ let Some(display) = display.filter(|display| !display.is_empty()) else {
154
+ return fatal(out, "no display given");
155
+ };
156
+ let (conn, screen_number) = match RustConnection::connect(Some(display)) {
157
+ Ok(opened) => opened,
158
+ Err(error) => return fatal(out, &format!("cannot open X display {display}: {error}")),
159
+ };
160
+ let Some(screen) = conn.setup().roots.get(screen_number) else {
161
+ return fatal(out, "the X display has no screen");
162
+ };
163
+ let root = screen.root;
164
+ let (screen_w, screen_h) = (screen.width_in_pixels, screen.height_in_pixels);
165
+
166
+ // The claim is the whole election: a server hands SubstructureRedirect to
167
+ // exactly one client, and refuses a second with an Access error.
168
+ let claim = conn.change_window_attributes(
169
+ root,
170
+ &ChangeWindowAttributesAux::new()
171
+ .event_mask(EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY),
172
+ );
173
+ // The display was opened, so a connection that dies during the election
174
+ // is the display going away — the normal end. Only the server's own error
175
+ // replies refuse the keeper.
176
+ let claim = match claim {
177
+ Ok(cookie) => cookie,
178
+ Err(_) => return Ok(()),
179
+ };
180
+ match claim.check() {
181
+ Ok(()) => {}
182
+ Err(ReplyError::X11Error(error)) if error.error_code == ACCESS_ERROR => {
183
+ return fatal(out, "another window manager");
184
+ }
185
+ Err(ReplyError::ConnectionError(_)) => return Ok(()),
186
+ Err(error) => return fatal(out, &format!("cannot keep {display:?}: {error}")),
187
+ }
188
+
189
+ // Created if missing: a fresh Xvfb may not have them yet, and this keeper
190
+ // is the only client that reads them on this screen. With the claim won, a
191
+ // failed request is the display going away — the keeper's normal end.
192
+ let (Some(net_wm_name), Some(utf8_string), Some(net_wm_pid)) = (
193
+ intern(&conn, "_NET_WM_NAME"),
194
+ intern(&conn, "UTF8_STRING"),
195
+ intern(&conn, "_NET_WM_PID"),
196
+ ) else {
197
+ return Ok(());
198
+ };
199
+
200
+ // Windows already on the screen when the keeper starts join the kiosk.
201
+ let Ok(Ok(tree)) = conn.query_tree(root).map(|cookie| cookie.reply()) else {
202
+ return Ok(());
203
+ };
204
+ let mut tracked: VecDeque<Window> = VecDeque::new();
205
+ for window in tree.children {
206
+ track(&conn, window, &mut tracked, screen_w, screen_h);
207
+ }
208
+ let _ = refocus(&conn, &tracked);
209
+
210
+ // The consumer learns the screen's state once at startup, then on change.
211
+ write_line(
212
+ out,
213
+ &window_line(&snapshot(&conn, root, net_wm_name, utf8_string, net_wm_pid)),
214
+ )?;
215
+
216
+ // (first unreported change, when the report is due)
217
+ let mut dirty: Option<(Instant, Instant)> = None;
218
+ loop {
219
+ if let Some((first, due)) = dirty {
220
+ if Instant::now() >= due || Instant::now().duration_since(first) >= MAX_REPORT_DELAY {
221
+ dirty = None;
222
+ let line =
223
+ window_line(&snapshot(&conn, root, net_wm_name, utf8_string, net_wm_pid));
224
+ write_line(out, &line)?;
225
+ continue;
226
+ }
227
+ }
228
+ // From here on, every connection error means the display is gone (X
229
+ // protocol errors surface through replies, and the only checked reply
230
+ // is the claim above). That is the keeper's normal end.
231
+ if conn.flush().is_err() {
232
+ return Ok(());
233
+ }
234
+ let event = if dirty.is_some() {
235
+ match conn.poll_for_event() {
236
+ Ok(Some(event)) => event,
237
+ Ok(None) => {
238
+ std::thread::sleep(Duration::from_millis(5));
239
+ continue;
240
+ }
241
+ Err(_) => return Ok(()),
242
+ }
243
+ } else {
244
+ match conn.wait_for_event() {
245
+ Ok(event) => event,
246
+ Err(_) => return Ok(()),
247
+ }
248
+ };
249
+ match event {
250
+ Event::MapRequest(event) => {
251
+ // A new window wants to be seen: give it its kiosk geometry,
252
+ // then map it. Override-redirect windows never ask.
253
+ if let Some((x, y, width, height)) = mapped_size(&conn, event.window)
254
+ .map(|(w, h)| kiosk_geometry(w, h, screen_w, screen_h))
255
+ {
256
+ conn.configure_window(
257
+ event.window,
258
+ &ConfigureWindowAux::new()
259
+ .x(x)
260
+ .y(y)
261
+ .width(width)
262
+ .height(height)
263
+ .stack_mode(StackMode::ABOVE),
264
+ );
265
+ conn.map_window(event.window);
266
+ track(&conn, event.window, &mut tracked, screen_w, screen_h);
267
+ let _ = refocus(&conn, &tracked);
268
+ mark_dirty(&mut dirty);
269
+ }
270
+ }
271
+ Event::ConfigureRequest(event) => {
272
+ // The client asks for a geometry; the kiosk answer is ours.
273
+ // Zero size fields mean "unchanged", so read the current size.
274
+ let current = mapped_size(&conn, event.window);
275
+ if let Some((width, height)) = current.map(|(w, h)| {
276
+ (
277
+ if event.width > 0 { event.width } else { w },
278
+ if event.height > 0 { event.height } else { h },
279
+ )
280
+ }) {
281
+ let (x, y, width, height) = kiosk_geometry(width, height, screen_w, screen_h);
282
+ conn.configure_window(
283
+ event.window,
284
+ &ConfigureWindowAux::new()
285
+ .x(x)
286
+ .y(y)
287
+ .width(width)
288
+ .height(height),
289
+ );
290
+ mark_dirty(&mut dirty);
291
+ }
292
+ }
293
+ Event::MapNotify(event) => {
294
+ // Covers windows that appeared without a request through this
295
+ // connection. Already-tracked and unkeepable windows are skipped
296
+ // inside `track`.
297
+ if track(&conn, event.window, &mut tracked, screen_w, screen_h) {
298
+ let _ = refocus(&conn, &tracked);
299
+ mark_dirty(&mut dirty);
300
+ }
301
+ }
302
+ Event::UnmapNotify(event) => {
303
+ if untrack(&mut tracked, event.window) {
304
+ let _ = refocus(&conn, &tracked);
305
+ mark_dirty(&mut dirty);
306
+ }
307
+ }
308
+ Event::DestroyNotify(event) => {
309
+ if untrack(&mut tracked, event.window) {
310
+ let _ = refocus(&conn, &tracked);
311
+ mark_dirty(&mut dirty);
312
+ }
313
+ }
314
+ Event::ConfigureNotify(_) | Event::PropertyNotify(_) => {
315
+ mark_dirty(&mut dirty);
316
+ }
317
+ _ => {}
318
+ }
319
+ }
320
+ }
321
+
322
+ /// Records that the screen changed and a report is due.
323
+ fn mark_dirty(dirty: &mut Option<(Instant, Instant)>) {
324
+ let now = Instant::now();
325
+ match dirty {
326
+ Some((_, due)) => {
327
+ *due = now + DEBOUNCE;
328
+ }
329
+ None => *dirty = Some((now, now + DEBOUNCE)),
330
+ }
331
+ }
332
+
333
+ /// Marks a window kept (kiosk geometry, property watching) if it is a normal
334
+ /// top-level window, and reports whether the screen's state changed.
335
+ fn track(
336
+ conn: &RustConnection,
337
+ window: Window,
338
+ tracked: &mut VecDeque<Window>,
339
+ screen_w: u16,
340
+ screen_h: u16,
341
+ ) -> bool {
342
+ let Ok(Ok(attributes)) = conn
343
+ .get_window_attributes(window)
344
+ .map(|cookie| cookie.reply())
345
+ else {
346
+ return false;
347
+ };
348
+ if attributes.override_redirect
349
+ || attributes.class != WindowClass::INPUT_OUTPUT
350
+ || tracked.contains(&window)
351
+ {
352
+ return false;
353
+ }
354
+ conn.change_window_attributes(
355
+ window,
356
+ &ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE),
357
+ );
358
+ if attributes.map_state == MapState::VIEWABLE {
359
+ let (x, y, width, height) = mapped_size(conn, window)
360
+ .map(|(w, h)| kiosk_geometry(w, h, screen_w, screen_h))
361
+ .unwrap_or((0, 0, u32::from(screen_w), u32::from(screen_h)));
362
+ conn.configure_window(
363
+ window,
364
+ &ConfigureWindowAux::new()
365
+ .x(x)
366
+ .y(y)
367
+ .width(width)
368
+ .height(height),
369
+ );
370
+ }
371
+ tracked.push_back(window);
372
+ true
373
+ }
374
+
375
+ /// Stops tracking a window; reports whether it was tracked.
376
+ fn untrack(tracked: &mut VecDeque<Window>, window: Window) -> bool {
377
+ let before = tracked.len();
378
+ tracked.retain(|&tracked_window| tracked_window != window);
379
+ tracked.len() != before
380
+ }
381
+
382
+ /// Puts the input focus on the newest tracked content window, so keyboard
383
+ /// events an engine session sends go to what the user would be looking at.
384
+ /// Parked helpers are never focused: an off-screen window would swallow keys.
385
+ fn refocus(conn: &RustConnection, tracked: &VecDeque<Window>) -> Result<()> {
386
+ let newest = tracked.iter().rev().copied().find(|&window| {
387
+ mapped_size(conn, window).is_some_and(|(width, height)| is_content(width, height))
388
+ });
389
+ if let Some(window) = newest {
390
+ conn.set_input_focus(InputFocus::POINTER_ROOT, window, x11rb::CURRENT_TIME)?;
391
+ }
392
+ Ok(())
393
+ }
394
+
395
+ /// The current size of a window worth keeping, if it still exists.
396
+ fn mapped_size(conn: &RustConnection, window: Window) -> Option<(u16, u16)> {
397
+ let Ok(Ok(geometry)) = conn.get_geometry(window).map(|cookie| cookie.reply()) else {
398
+ return None;
399
+ };
400
+ Some((geometry.width, geometry.height))
401
+ }
402
+
403
+ /// Every visible, non-override-redirect top-level window, bottom of the
404
+ /// stacking order first.
405
+ fn snapshot(
406
+ conn: &RustConnection,
407
+ root: Window,
408
+ net_wm_name: Atom,
409
+ utf8_string: Atom,
410
+ net_wm_pid: Atom,
411
+ ) -> Vec<KeeperWindow> {
412
+ let Ok(Ok(tree)) = conn.query_tree(root).map(|cookie| cookie.reply()) else {
413
+ return Vec::new();
414
+ };
415
+ let mut windows = Vec::new();
416
+ for &window in &tree.children {
417
+ let Ok(Ok(attributes)) = conn
418
+ .get_window_attributes(window)
419
+ .map(|cookie| cookie.reply())
420
+ else {
421
+ continue;
422
+ };
423
+ if attributes.override_redirect
424
+ || attributes.class != WindowClass::INPUT_OUTPUT
425
+ || attributes.map_state != MapState::VIEWABLE
426
+ {
427
+ continue;
428
+ }
429
+ let Ok(Ok(geometry)) = conn.get_geometry(window).map(|cookie| cookie.reply()) else {
430
+ continue;
431
+ };
432
+ let property = |name: Atom, type_: Atom, length: u32| {
433
+ conn.get_property(false, window, name, type_, 0, length)
434
+ .ok()
435
+ .and_then(|cookie| cookie.reply().ok())
436
+ };
437
+ let title = property(net_wm_name, utf8_string, u32::MAX)
438
+ .map(|reply| reply.value)
439
+ .and_then(|value| decode_title(&String::from_utf8_lossy(&value)))
440
+ .or_else(|| {
441
+ property(AtomEnum::WM_NAME.into(), AtomEnum::STRING.into(), u32::MAX)
442
+ .map(|reply| reply.value)
443
+ .and_then(|value| decode_title(&latin1(&value)))
444
+ });
445
+ let class = property(AtomEnum::WM_CLASS.into(), AtomEnum::STRING.into(), u32::MAX)
446
+ .map(|reply| reply.value)
447
+ .and_then(|value| decode_wm_class(&value));
448
+ let pid = property(net_wm_pid, AtomEnum::CARDINAL.into(), 1).and_then(|reply| {
449
+ let mut values = reply.value32()?;
450
+ values.next()
451
+ });
452
+ windows.push(KeeperWindow {
453
+ id: window,
454
+ title,
455
+ class,
456
+ pid,
457
+ width: geometry.width,
458
+ height: geometry.height,
459
+ });
460
+ }
461
+ windows
462
+ }
463
+
464
+ /// Interns an atom, or `None` when the connection is gone; the caller has
465
+ /// already won the WM claim, so there is nothing left to refuse.
466
+ fn intern(conn: &RustConnection, name: &str) -> Option<Atom> {
467
+ conn.intern_atom(false, name.as_bytes())
468
+ .ok()?
469
+ .reply()
470
+ .ok()
471
+ .map(|reply| reply.atom)
472
+ }
473
+
474
+ fn write_line(out: &mut impl Write, line: &str) -> Result<()> {
475
+ out.write_all(line.as_bytes())?;
476
+ out.write_all(b"\n")?;
477
+ out.flush()?;
478
+ Ok(())
479
+ }
480
+
481
+ /// One error line, then stop. This is how a refused keeper talks.
482
+ fn fatal(out: &mut impl Write, error: &str) -> Result<()> {
483
+ write_line(out, &serde_json::json!({ "error": error }).to_string())?;
484
+ Err(anyhow!("{error}"))
485
+ }
486
+
487
+ #[cfg(test)]
488
+ mod tests {
489
+ use super::*;
490
+
491
+ #[test]
492
+ fn content_fills_the_screen_and_helpers_are_parked() {
493
+ assert_eq!(kiosk_geometry(1240, 760, 1280, 800), (0, 0, 1280, 800));
494
+ // Exactly at the limit is content; one pixel under is not.
495
+ assert_eq!(kiosk_geometry(120, 120, 1280, 800), (0, 0, 1280, 800));
496
+ assert_eq!(
497
+ kiosk_geometry(119, 800, 1280, 800),
498
+ (i32::from(PARKED_XY), i32::from(PARKED_XY), 119, 800)
499
+ );
500
+ assert_eq!(
501
+ kiosk_geometry(1000, 24, 1280, 800),
502
+ (i32::from(PARKED_XY), i32::from(PARKED_XY), 1000, 24)
503
+ );
504
+ }
505
+
506
+ #[test]
507
+ fn wm_class_reads_the_class_not_the_instance() {
508
+ assert_eq!(
509
+ decode_wm_class(b"google-chrome\0Google-chrome\0"),
510
+ Some(String::from("Google-chrome"))
511
+ );
512
+ assert_eq!(decode_wm_class(b"instance-only\0"), None);
513
+ assert_eq!(decode_wm_class(b""), None);
514
+ }
515
+
516
+ #[test]
517
+ fn titles_come_from_either_name_property_and_blank_is_none() {
518
+ assert_eq!(
519
+ decode_title("Pricing — Acme Store"),
520
+ Some(String::from("Pricing — Acme Store"))
521
+ );
522
+ assert_eq!(
523
+ decode_title("PPF probe - Google Chrome"),
524
+ Some(String::from("PPF probe - Google Chrome"))
525
+ );
526
+ assert_eq!(decode_title(" "), None);
527
+ assert_eq!(decode_title(""), None);
528
+ assert_eq!(
529
+ decode_title(&latin1(b"Gr\xf6\xdfe")),
530
+ Some(String::from("Gr\u{00f6}\u{00df}e"))
531
+ );
532
+ }
533
+
534
+ #[test]
535
+ fn the_report_line_is_exactly_the_promised_shape() {
536
+ let line = window_line(&[KeeperWindow {
537
+ id: 0x0040_0001,
538
+ title: Some(String::from("PPF probe - Google Chrome")),
539
+ class: Some(String::from("Google-chrome")),
540
+ pid: Some(1003571),
541
+ width: 1280,
542
+ height: 800,
543
+ }]);
544
+ let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
545
+ assert_eq!(
546
+ parsed,
547
+ serde_json::json!({
548
+ "windows": [{
549
+ "id": 0x40_0001_u64,
550
+ "title": "PPF probe - Google Chrome",
551
+ "class": "Google-chrome",
552
+ "pid": 1003571,
553
+ "width": 1280,
554
+ "height": 800,
555
+ }]
556
+ })
557
+ );
558
+ assert_eq!(window_line(&[]), r#"{"windows":[]}"#);
559
+ }
560
+
561
+ #[test]
562
+ fn missing_fields_are_null_not_absent() {
563
+ let line = window_line(&[KeeperWindow {
564
+ id: 7,
565
+ title: None,
566
+ class: None,
567
+ pid: None,
568
+ width: 100,
569
+ height: 100,
570
+ }]);
571
+ let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
572
+ let window = &parsed["windows"][0];
573
+ assert_eq!(window["title"], serde_json::Value::Null);
574
+ assert_eq!(window["class"], serde_json::Value::Null);
575
+ assert_eq!(window["pid"], serde_json::Value::Null);
576
+ assert!(window.get("width").is_some());
577
+ }
578
+
579
+ #[test]
580
+ fn an_unopenable_display_is_refused_in_one_error_line() {
581
+ let mut out = Vec::new();
582
+ assert!(keep(Some("not a display"), &mut out).is_err());
583
+ let line: serde_json::Value =
584
+ serde_json::from_str(&String::from_utf8(out).unwrap()).unwrap();
585
+ assert!(line["error"]
586
+ .as_str()
587
+ .is_some_and(|error| !error.is_empty()));
588
+ }
589
+
590
+ #[test]
591
+ fn a_refused_keeper_says_why_in_its_error_line() {
592
+ let mut out = Vec::new();
593
+ let error = fatal(&mut out, "another window manager").unwrap_err();
594
+ assert_eq!(error.to_string(), "another window manager");
595
+ assert_eq!(
596
+ String::from_utf8(out).unwrap(),
597
+ "{\"error\":\"another window manager\"}\n"
598
+ );
599
+ }
600
+
601
+ #[test]
602
+ fn the_display_flag_has_one_spelling() {
603
+ let flag = |args: &[&str]| {
604
+ display_argument(
605
+ &args
606
+ .iter()
607
+ .map(|arg| String::from(*arg))
608
+ .collect::<Vec<_>>(),
609
+ )
610
+ };
611
+ assert_eq!(flag(&["--display", ":42"]), Some(String::from(":42")));
612
+ assert_eq!(
613
+ flag(&["--display", ":42", "--x"]),
614
+ Some(String::from(":42"))
615
+ );
616
+ assert_eq!(flag(&[]), None);
617
+ assert_eq!(flag(&["--display"]), None);
618
+ // The undocumented `--display=:42` spelling is not a display: `keep`
619
+ // refuses it rather than guessing what was meant.
620
+ assert_eq!(flag(&["--display=:42"]), None);
621
+ }
622
+ }
package/engine/src/mac.rs CHANGED
@@ -200,8 +200,8 @@ fn capabilities() -> Value {
200
200
  "layout": keyboard_layout(),
201
201
  "grant": if input_granted { "granted" } else { "missing-accessibility" },
202
202
  "unavailable_reason": if input_granted { Value::Null } else { json!({
203
- "reason": "Accessibility permission has not been granted to desklink-host.",
204
- "remedy": "Allow desklink-host in System Settings › Privacy & Security › Accessibility on the Mac, then reconnect."
203
+ "reason": format!("Accessibility permission has not been granted to {}.", tcc_responsible_app_name()),
204
+ "remedy": format!("Allow {} in System Settings › Privacy & Security › Accessibility on the Mac, then reconnect.", tcc_responsible_app_name())
205
205
  }) }
206
206
  },
207
207
  "clipboard": { "read": true, "write": true, "mime": ["text/plain;charset=utf-8"], "maxBytes": crate::clipboard::MAX_CLIPBOARD_BYTES }
@@ -327,7 +327,7 @@ fn encode_probe() -> Result<(), String> {
327
327
  let mut encoder = crate::encoder::Encoder::new(640, 360, 1000, 30, 1)
328
328
  .map_err(|error| format!("VP9 encoder initialization failed: {error:#}"))?;
329
329
  let packet = encoder
330
- .encode(&frame, true)
330
+ .encode(&frame, true, false)
331
331
  .map_err(|error| format!("VP9 encoding failed: {error:#}"))?;
332
332
  println!(
333
333
  "{}",
@@ -581,7 +581,7 @@ pub fn run() -> i32 {
581
581
  let command = args.next().unwrap_or_else(|| "help".into());
582
582
  if matches!(
583
583
  command.as_str(),
584
- "serve" | "capabilities" | "capture-probe" | "indicator-demo" | "axi-record"
584
+ "serve" | "capabilities" | "capture-probe" | "indicator-demo" | "axi-record" | "setup-input"
585
585
  ) {
586
586
  #[cfg(not(desklink_macos_cli))]
587
587
  if let Err(error) = ensure_disclaimed() {
@@ -595,7 +595,7 @@ pub fn run() -> i32 {
595
595
  let engine = match std::env::current_exe() { Ok(path) => path, Err(error) => { eprintln!("engine path: {error}"); return 1; } };
596
596
  let result = std::process::Command::new("/opt/homebrew/bin/node")
597
597
  .arg(cli).arg("--bridge").args(args)
598
- .env("DESKLINK_AXI_ENGINE", engine).env("DESKLINK_MACOS", "1")
598
+ .env("DESKLINK_AXI_ENGINE", engine)
599
599
  .status();
600
600
  return match result { Ok(status) => status.code().unwrap_or(1), Err(error) => { eprintln!("axi bridge: {error}"); 1 } };
601
601
  }
@@ -707,7 +707,10 @@ mod tests {
707
707
  assert_eq!(value["protocol"], crate::protocol::PROTOCOL_VERSION);
708
708
  assert_eq!(value["platform"], "macos");
709
709
  assert_eq!(value["capture"]["mechanism"], "screencapturekit");
710
- assert_eq!(value["encode"]["codecs"], json!(["vp9"]));
710
+ assert_eq!(value["encode"]["codecs"][0], "vp9", "VP9 is always offered");
711
+ // Every Mac this engine runs on has a hardware H.264 encoder.
712
+ assert_eq!(value["encode"]["codecs"][1], "h264");
713
+ assert_eq!(value["encode"]["h264"]["encoder"], "videotoolbox");
711
714
  assert!(value["capture"]["displays"].is_array());
712
715
  assert!(value["clipboard"]["read"].as_bool().unwrap());
713
716
  assert!(matches!(