@desklink/host 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/NOTICE +3 -0
  2. package/README.md +72 -16
  3. package/bin/desklink-host.mjs +14 -3
  4. package/dist/bridge.d.ts.map +1 -1
  5. package/dist/bridge.js +5 -1
  6. package/dist/bridge.js.map +1 -1
  7. package/dist/engineProcess.d.ts.map +1 -1
  8. package/dist/engineProcess.js +17 -5
  9. package/dist/engineProcess.js.map +1 -1
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/keeper.d.ts +52 -0
  15. package/dist/keeper.d.ts.map +1 -0
  16. package/dist/keeper.js +101 -0
  17. package/dist/keeper.js.map +1 -0
  18. package/dist/protocol.d.ts +42 -5
  19. package/dist/protocol.d.ts.map +1 -1
  20. package/dist/protocol.js +1 -1
  21. package/dist/protocol.js.map +1 -1
  22. package/dist/resolveEngine.d.ts +3 -2
  23. package/dist/resolveEngine.d.ts.map +1 -1
  24. package/dist/resolveEngine.js +20 -5
  25. package/dist/resolveEngine.js.map +1 -1
  26. package/docs/BOUNDARY.md +11 -6
  27. package/docs/PROTOCOL.md +297 -33
  28. package/engine/Cargo.lock +196 -1
  29. package/engine/Cargo.toml +26 -5
  30. package/engine/build.rs +98 -23
  31. package/engine/native/agent_overlay_mac.m +133 -0
  32. package/engine/native/agent_overlay_wayland.c +192 -0
  33. package/engine/native/agent_overlay_x11.c +103 -0
  34. package/engine/native/mac_stream.mm +188 -0
  35. package/engine/src/capture.rs +169 -19
  36. package/engine/src/capture_mac.rs +145 -0
  37. package/engine/src/clipboard_mac.rs +182 -0
  38. package/engine/src/convert.rs +57 -1
  39. package/engine/src/indicator.rs +162 -0
  40. package/engine/src/input.rs +12 -0
  41. package/engine/src/input_mac.rs +312 -0
  42. package/engine/src/keeper.rs +622 -0
  43. package/engine/src/keymap.rs +25 -0
  44. package/engine/src/keymap_mac.rs +209 -0
  45. package/engine/src/mac.rs +722 -0
  46. package/engine/src/main.rs +158 -2
  47. package/engine/src/peer.rs +429 -22
  48. package/engine/src/portal.rs +4 -0
  49. package/engine/src/portal_mac.rs +68 -0
  50. package/engine/src/protocol.rs +186 -3
  51. package/engine/src/session.rs +1307 -100
  52. package/engine/src/x11.rs +23 -3
  53. package/engine/src/x11_mac.rs +40 -0
  54. package/examples/reference-client.html +33 -4
  55. package/package.json +2 -2
@@ -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
+ }
@@ -72,6 +72,18 @@ pub fn named_key(name: &str) -> Option<Keystroke> {
72
72
  "End" => keycode::END,
73
73
  "PageUp" => keycode::PAGE_UP,
74
74
  "PageDown" => keycode::PAGE_DOWN,
75
+ "F1" => keycode::F1,
76
+ "F2" => keycode::F2,
77
+ "F3" => keycode::F3,
78
+ "F4" => keycode::F4,
79
+ "F5" => keycode::F5,
80
+ "F6" => keycode::F6,
81
+ "F7" => keycode::F7,
82
+ "F8" => keycode::F8,
83
+ "F9" => keycode::F9,
84
+ "F10" => keycode::F10,
85
+ "F11" => keycode::F11,
86
+ "F12" => keycode::F12,
75
87
  _ => return None,
76
88
  };
77
89
  Some(Keystroke::simple(code))
@@ -309,7 +321,20 @@ mod tests {
309
321
  fn named_keys_and_modifiers_resolve_to_linux_key_codes() {
310
322
  assert_eq!(named_key("Enter").unwrap().code, keycode::ENTER);
311
323
  assert_eq!(named_key("ArrowLeft").unwrap().code, keycode::LEFT);
324
+ for (name, code) in [
325
+ ("Delete", keycode::DELETE), ("Home", keycode::HOME), ("End", keycode::END),
326
+ ("PageUp", keycode::PAGE_UP), ("PageDown", keycode::PAGE_DOWN),
327
+ ("F1", keycode::F1), ("F2", keycode::F2), ("F3", keycode::F3),
328
+ ("F4", keycode::F4), ("F5", keycode::F5), ("F6", keycode::F6),
329
+ ("F7", keycode::F7), ("F8", keycode::F8), ("F9", keycode::F9),
330
+ ("F10", keycode::F10), ("F11", keycode::F11), ("F12", keycode::F12),
331
+ ] {
332
+ assert_eq!(named_key(name).unwrap().code, code);
333
+ assert!(crate::input::native_keycode(code).is_ok(), "{name} must reach inputtino");
334
+ }
312
335
  assert_eq!(modifier_key("Control"), Some(keycode::LEFT_CTRL));
336
+ assert_eq!(modifier_key("Alt"), Some(keycode::LEFT_ALT));
337
+ assert_eq!(modifier_key("Super"), Some(keycode::LEFT_META));
313
338
  assert!(named_key("Frobnicate").is_none());
314
339
  assert!(modifier_key("Hyper").is_none());
315
340
  }