@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
package/docs/PROTOCOL.md CHANGED
@@ -1,12 +1,14 @@
1
- # desklink local control protocol (v2)
1
+ # desklink local control protocol (v3)
2
2
 
3
3
  The host engine is a per-user, on-demand process. A consumer that already owns
4
4
  the user's session starts it and talks to it over an inherited private channel —
5
5
  the engine never opens a public listener, never runs as root, and never installs
6
6
  a service.
7
7
 
8
- This document is the engine's whole public surface. It contains no application
9
- concepts: no accounts, no chat, no machine ids, no pane ids.
8
+ This document describes the shared Linux and macOS engine protocol. The macOS
9
+ engine uses the same v3 stdio/WebRTC session path, with native display capture,
10
+ input and clipboard adapters. The protocol contains no application concepts:
11
+ no accounts, no chat, no machine ids, no pane ids.
10
12
 
11
13
  ## Starting the engine
12
14
 
@@ -21,7 +23,10 @@ to `stderr`. One JSON object per line, no length prefix, UTF-8.
21
23
  Why stdio rather than a socket: the consumer already holds the process, so the
22
24
  channel inherits the consumer's own access control (same uid, same session, no
23
25
  filesystem permission to get wrong) and no second authorization mechanism has to
24
- exist. A consumer that wants a socket can wrap this process.
26
+ exist. A consumer that wants a socket can wrap this process. (A screen that
27
+ needs no protocol at all — a private `Xvfb` whose windows should simply be kept
28
+ in order and reported — can run the engine as a display keeper instead; see
29
+ "Display keeper" below.)
25
30
 
26
31
  Cancellation is by closing the consumer's end. The engine treats EOF on `stdin`
27
32
  exactly like `shutdown`: it releases held input, stops capture, closes peers, and
@@ -55,13 +60,13 @@ Request parameters use the engine's own snake_case names (`session_id`,
55
60
  ## Handshake
56
61
 
57
62
  ```jsonc
58
- {"id":1,"method":"hello","params":{"protocol":2}}
63
+ {"id":1,"method":"hello","params":{"protocol":3}}
59
64
  ```
60
65
 
61
66
  Result:
62
67
 
63
68
  ```jsonc
64
- {"protocol":2,"engine":"desklink-host/0.1.0","platform":"linux", /* the rest of Capabilities */ }
69
+ {"protocol":3,"engine":"desklink-host/0.2.0","platform":"linux", /* the rest of Capabilities */ }
65
70
  ```
66
71
 
67
72
  The consumer must send `hello` first, and a version the engine does not speak is
@@ -69,6 +74,31 @@ refused with `error.code = "unsupported-protocol"`. Until a supported `hello`
69
74
  arrives, every other request is refused the same way; `capabilities` and
70
75
  `shutdown` are the two that answer without a handshake.
71
76
 
77
+ ## macOS
78
+
79
+ macOS speaks protocol 3 and shares the session lifecycle below. It captures the
80
+ selected display with ScreenCaptureKit, encodes VP9, and uses the same WebRTC
81
+ offer, ICE, control-channel, lease, and metrics flow as Linux. Capture currently
82
+ uses one bounded ScreenCaptureKit stream (three-frame queue); the backend may
83
+ supply fewer frames. Audio is not captured.
84
+
85
+ `capabilities` is non-prompting and reports active displays (pixel and point
86
+ dimensions, origin and scale), the current keyboard layout, console state, and
87
+ Screen Recording/Accessibility grant states. A `display` source accepts an
88
+ optional `display_id` from `capture.displays`; an absent ID selects the main
89
+ display. Screen Recording consent is requested by `session.open` when capture
90
+ is needed. Control may request Accessibility consent; if it remains unavailable,
91
+ open fails with `input-unavailable`. TCC permissions attach to the responsible
92
+ launching app (for example Terminal, iTerm, or the Node.js host), not to a
93
+ separate DesklinkHost.app bundle. Enable that app in **System Settings → Privacy
94
+ & Security → Screen & System Audio Recording** and **Accessibility**, then
95
+ reconnect. No TCC state is pre-seeded or bypassed.
96
+
97
+ macOS clipboard read/write is plain text and requires the session's `clipboard`
98
+ permission. `restore_token` is not supported by this native backend; a new
99
+ session may require fresh consent. The macOS engine requires a VP9-enabled build
100
+ (`DESKLINK_VPX_STATIC_DIR`) for `serve`.
101
+
72
102
  ## Capabilities
73
103
 
74
104
  ```jsonc
@@ -77,8 +107,8 @@ arrives, every other request is refused the same way; `capabilities` and
77
107
 
78
108
  ```jsonc
79
109
  {
80
- "protocol": 2,
81
- "engine": "desklink-host/0.1.0",
110
+ "protocol": 3,
111
+ "engine": "desklink-host/0.2.0",
82
112
  "platform": "linux",
83
113
  "session": {"kind": "wayland"},
84
114
  "x11": {"available": true, "size": [2560, 1440]},
@@ -98,10 +128,15 @@ arrives, every other request is refused the same way; `capabilities` and
98
128
  "unavailable_reason": null,
99
129
  "grant": "granted"
100
130
  },
101
- "clipboard": {"read": true, "write": true, "mime": ["text/plain;charset=utf-8"], "maxBytes": 262144}
131
+ "clipboard": {"read": true, "write": true, "mime": ["text/plain;charset=utf-8"], "maxBytes": 262144},
132
+ "encoded": {"codecs": ["h264"]}
102
133
  }
103
134
  ```
104
135
 
136
+ `encoded` lists the codecs this build will carry when the consumer supplies the
137
+ video itself (see *Encoded sources*): a consumer reads it rather than inferring
138
+ support from the engine's version string.
139
+
105
140
  `input.mechanism` names the build's portal input path, `inputtino/uinput`,
106
141
  where the engine creates its own virtual devices. A session opened against an X
107
142
  display applies input through XTest instead, where the X server applies the
@@ -117,7 +152,8 @@ without kernel input access a portal session may request `view`, but an open
117
152
  requesting `control` is refused. X display capture uses XTest instead and does
118
153
  not require that grant. The muxr host and WebSocket bridge report clipboard
119
154
  unavailable for an X display, regardless of the engine's Wayland clipboard
120
- probe. The engine never asks for privileges on its own.
155
+ probe. On Linux the engine never asks for privileges on its own; macOS may
156
+ request Screen Recording or Accessibility consent at `session.open`.
121
157
 
122
158
  `capabilities` is safe to call before any consent has been given and must not
123
159
  trigger a capture request.
@@ -127,6 +163,8 @@ trigger a capture request.
127
163
  ```jsonc
128
164
  {"id":4,"method":"session.open","params":{
129
165
  "source": {"kind":"portal"}, // or {"kind":"x11","display":":99"}
166
+ // macOS: {"kind":"display","display_id":123}
167
+ // or {"kind":"encoded","codec":"h264","width":486,"height":1080}
130
168
  "permissions": ["view","control","clipboard"],
131
169
  "max_width": 3840, "max_height": 2160, // encode box (default); never upscales
132
170
  "bitrate_kbps": 0, // 0 (default): sized to the encoded surface
@@ -134,7 +172,8 @@ trigger a capture request.
134
172
  "ice_servers": [{"urls":["stun:..."],"username":null,"credential":null}],
135
173
  "restore_token": null, // from a previous session.restoreToken
136
174
  "ttl_seconds": 3600, // session lease; default 3600
137
- "loopback_tcp": false // also offer ICE over TCP on 127.0.0.1; default false
175
+ "loopback_tcp": false, // also offer ICE over TCP on 127.0.0.1; default false
176
+ "agent_indicator": false // local input feedback; default false (phone control)
138
177
  }}
139
178
  ```
140
179
 
@@ -150,20 +189,32 @@ Result:
150
189
 
151
190
  `source` selects the desktop. `portal` (or absent) asks the compositor for a
152
191
  screen cast, which is the only path that carries a Wayland user's consent;
153
- `x11` reads a named X display's root window and applies input through XTest. The
154
- choice is capability-based rather than a table of desktops: the portal when there
155
- is one, an X display otherwise. It is normally the *consumer's* decision, because
156
- a client is not in a position to know which of the two a machine can offer.
157
-
158
- `permissions` are the engine's authority for this session. The engine enforces
159
- them on every input and clipboard action and never infers them from a source, a
160
- peer or an SDP. `control` without a working input backend is refused at
161
- `session.open` with `error.code = "input-unavailable"` — the consumer must ask
162
- for `view` explicitly instead.
163
-
164
- `max_fps` defaults to 30. It caps the encoded frame rate and the X11 capture
165
- loop; portal capture offers it as the preferred PipeWire frame rate (clamped
166
- to the offered 0–1000 fps range). The source may still supply fewer frames.
192
+ `x11` reads a named X display's root window and applies input through XTest;
193
+ `display` selects a macOS display by the ID reported in capabilities (or the
194
+ main display when omitted); `encoded` carries video the consumer encoded itself.
195
+ The choice is normally the *consumer's* decision, because a client is not in a
196
+ position to know which backend a machine can offer.
197
+
198
+ `permissions` are the engine's authority for this session. `view` is required
199
+ to open a session. The engine enforces the remaining scopes on every input and
200
+ clipboard action and never infers them from a source, a peer or an SDP.
201
+ `control` without a working input backend is refused at `session.open` with
202
+ `error.code = "input-unavailable"` — the consumer must ask for `view` explicitly instead.
203
+
204
+ `max_fps` defaults to 30 and is bounded to 1–60. It caps the encoded frame
205
+ rate and X11 capture loop; portal capture offers it as the preferred PipeWire
206
+ frame rate, and macOS sets its ScreenCaptureKit stream minimum frame interval.
207
+ A source may still supply fewer frames.
208
+
209
+ `agent_indicator` is opt-in local feedback for an agent controller; desklink-axi
210
+ sets it for control sessions, never for view-only or human phone control. It
211
+ adds an eased pointer halo, click ripple, and typing pulse. On Wayland it uses
212
+ a click-through layer-shell surface with an edge glow; its bounded area is
213
+ masked before capture/diff (pixels behind the effect can remain briefly stale).
214
+ On bare X11 it replaces the pointer sprite instead, which XGetImage excludes:
215
+ there is no edge glow on that path. macOS uses a nonactivating, click-through
216
+ window excluded by the ScreenCaptureKit content filter. Closing the session
217
+ fades and removes the indicator.
167
218
 
168
219
  `session.open` is where the capture request happens, which is where the user's
169
220
  consent appears. It is not implicit and it is not retried silently.
@@ -192,6 +243,22 @@ the SDP and candidates to the client and brings back the answer.
192
243
 
193
244
  A candidate that arrives before the remote description is buffered, not dropped.
194
245
 
246
+ A client whose ICE connection goes `disconnected` may restart ICE on the same
247
+ peer connection instead of opening a new session: it sends
248
+ `session.restart_ice` with the session's id and generation, and the engine
249
+ re-offers — a fresh offer with a new ICE generation — as a new
250
+ `session.description` event, which the client answers as usual. The session
251
+ and its generation survive; only the ICE generation turns over. Fresh
252
+ candidates from the restart trickle through `session.candidate` on both sides
253
+ exactly as the initial ones do.
254
+
255
+ ```jsonc
256
+ {"id":7,"method":"session.restart_ice","params":{"session_id":"…","generation":1}}
257
+ // → {"id":7,"result":{"accepted":true}}
258
+ // …then {"event":"session.description","params":{"sessionId":"…","generation":1,
259
+ // "description":{"type":"offer","sdp":"v=0\r\n…"}}}
260
+ ```
261
+
195
262
  With `loopback_tcp`, the engine also listens for ICE over TCP on
196
263
  `127.0.0.1` and offers that passive candidate. It is for a client whose only
197
264
  way to this computer is a forward of its loopback, such as an SSH tunnel: the
@@ -205,11 +272,22 @@ always does.
205
272
  ```jsonc
206
273
  {"event":"session.state","params":{"sessionId":"…",
207
274
  "capture":"streaming", // consented|streaming|ended
208
- "transport":"connected", // new|connecting|connected|failed|closed
275
+ "transport":"connected", // new|connecting|connected|disconnected|failed|closed
209
276
  "firstFrame":true}} // false until a frame has been encoded
210
277
  ```
211
278
 
212
- The `geometry` a client maps touch against is the one `session.open` returned,
279
+ `disconnected` is ICE losing the path: the picture is frozen but the session
280
+ is alive, and a client should show reconnecting and may restart ICE (above)
281
+ rather than open a new session. `failed` is terminal: the engine revokes and
282
+ closes the session, and only a new `session.open` recovers it.
283
+
284
+ While its control channel is open the engine also sends a heartbeat twice a
285
+ second, `{"kind":"ping"}`. It lets a client notice a stalled path within
286
+ about a second and a half, long before ICE consent timers declare it
287
+ `disconnected` — and a resumed heartbeat is the path working again. Clients
288
+ that predate it ignore the unknown kind.
289
+
290
+ A stopped capture emits `{"event":"session.capture.stopped","params":{"sessionId":"…","reason":"SCStream stopped (SCStreamErrorDomain -3821): …"}}`. Frame requests then return `stream-stopped` rather than stale pixels. On macOS the engine retries opening the stream up to three times and emits `session.state` with `capture=streaming` on recovery; Linux capture errors also surface instead of serving an old frame.\n\nThe `geometry` a client maps touch against is the one `session.open` returned,
213
291
  and the one the control channel's `hello` repeats. A session keeps the one
214
292
  `generation` it was opened with: a different source or scale is a new
215
293
  `session.open`, not a generation change within a session.
@@ -246,7 +324,8 @@ Metrics are a request, not a notification:
246
324
  ```jsonc
247
325
  {"id":7,"result":{"captured_frames":812,"dropped_frames":3,"encoded_frames":809,
248
326
  "encoded_bytes":12345678,"key_frames":1,"refined_frames":37,
249
- "encode_micros":7390000,"target_kbps":16430,"input_applied":44,"input_rejected":0}}
327
+ "encode_micros":7390000,"target_kbps":16430,"input_applied":44,"input_rejected":0,
328
+ "fed_frames":0,"input_forwarded":0}}
250
329
  ```
251
330
 
252
331
  `dropped_frames` counts frames superseded by a newer one before they were coded;
@@ -286,10 +365,11 @@ are JSON, one per message:
286
365
  | `release_all` | — | explicit safety net; the engine also does this on close and on channel loss |
287
366
  | `clipboard_read` / `clipboard_write` | `request` (echoed back), `text` | explicit, on user action; never polled |
288
367
 
289
- The engine answers on the same channel:
368
+ The engine answers on the same channel, and sends two things unprompted:
290
369
 
291
370
  ```jsonc
292
- {"kind":"hello","protocol":2,"geometry":{…}} // once, when the channel opens
371
+ {"kind":"hello","protocol":3,"geometry":{…}} // once, when the channel opens
372
+ {"kind":"ping"} // heartbeat, twice a second; see "State" above
293
373
  {"kind":"ack","seq":44}
294
374
  {"kind":"rejected","seq":44,"code":"coordinates","message":"(9000,4) is outside the 1280x720 surface"}
295
375
  {"kind":"clipboard","request":"…","text":"…","truncated":false} // an empty "text" with "error" when the read failed
@@ -307,9 +387,141 @@ physical effect.
307
387
  The engine holds its own pressed-key/button state. `close`, `release_all`,
308
388
  control-channel loss and process exit all synthesize releases for exactly the
309
389
  keys and buttons this session pressed, so a dropped connection cannot leave a
310
- modifier stuck down on the desktop. `input.text` in `capabilities` states the
311
- characters the compiled layout can actually produce; anything else is refused
312
- with `text-unsupported` and the honest workaround is the explicit clipboard.
390
+ modifier stuck down on the desktop. On Linux, `input.text` in `capabilities` states the characters the compiled
391
+ layout can produce. macOS reports Unicode text support and currently maps
392
+ `key.character` chords through a US ANSI virtual-key map; the reported keyboard
393
+ layout is diagnostic, not proof that this map matches it. Anything outside the
394
+ map is refused with `text-unsupported`.
395
+
396
+ ## Encoded sources
397
+
398
+ An `encoded` source is video the **consumer** produced: a phone mirror from a
399
+ device helper, a simulator, a camera. The engine captures nothing, encodes
400
+ nothing and holds no input device for it; it packetizes what it is handed and
401
+ returns the client's control messages to the consumer, which owns whatever
402
+ device the picture came from. That is the whole point of the source: the helper
403
+ that already speaks H.264 keeps its own encoder, and the engine keeps the
404
+ WebRTC, congestion and teardown logic that is already proven on this path.
405
+
406
+ ```jsonc
407
+ {"source": {"kind":"encoded","codec":"h264","width":486,"height":1080}}
408
+ ```
409
+
410
+ `codec` is `h264`, the only one this build carries (`capabilities.encoded`).
411
+ `width`/`height` are the stream's own pixel size, chosen by the consumer because
412
+ it is the only party that knows them (`1..7680` by `1..4320`). Nothing is scaled:
413
+ `geometry.source` and `geometry.encoded` are both that size, the client aims at
414
+ those pixels, and the consumer maps them to its device. `max_width`/
415
+ `max_height`/`bitrate_kbps` are ignored for this source, and `permissions` works
416
+ as it does anywhere else — with one difference: `control` needs no local input
417
+ backend, because nothing is applied locally.
418
+
419
+ `session.open` returns before any offer exists. The offer must name the profile
420
+ the stream actually is (`packetization-mode=1` and the SPS's own
421
+ `profile-level-id`), and the only place that is written down is the stream's own
422
+ SPS, so the engine sends `session.description` **after** the first fed access
423
+ unit that carries one. A consumer must therefore not wait for the offer before
424
+ it starts feeding, and a consumer that never feeds an SPS gets no offer at all.
425
+
426
+ ### Feeding access units
427
+
428
+ ```jsonc
429
+ {"id":8,"method":"session.feed","params":{
430
+ "session_id":"…",
431
+ "keyframe":true, // this unit is an IDR
432
+ "data_b64":"AAAA…" // one Annex-B access unit, base64
433
+ }}
434
+ ```
435
+
436
+ ```jsonc
437
+ {"id":8,"result":{"accepted":true}}
438
+ ```
439
+
440
+ One request is one access unit: start codes and all, exactly as the encoder
441
+ emitted it, with SPS/PPS in the unit that carries the IDR. `keyframe` is the
442
+ consumer's own statement about the unit; the engine uses it for `session.metrics`
443
+ and for nothing else, because the RTP payloader reads the NAL types itself. The
444
+ reply acknowledges the hand-off, not the picture: units are packetized behind
445
+ the request, and `session.metrics` reports what was sent and what was dropped.
446
+
447
+ Base64 in the same JSON line keeps one ordered channel and one inherited fd.
448
+ Measured, that is not a close call: one second of a 2 Mbit/s stream is 250 KB,
449
+ and base64 costs about 0.09 ms to encode on the consumer's side and 1.0 ms to
450
+ decode on the engine's — roughly a tenth of one per cent of a core, against the
451
+ 5 % that would have justified a second fd. Decoded units are
452
+ limited to 8 MiB (`malformed` beyond that); a unit the buffer cannot hold right
453
+ away is **dropped, not queued** — a live picture is worth more than a backlog of
454
+ stale ones — and is counted in `dropped_frames`.
455
+
456
+ An access unit is dropped until the transport connects, because nothing sent
457
+ before it arrives. The engine therefore asks for a key frame the moment the
458
+ transport connects (below); a consumer that ignores that request shows a black
459
+ picture.
460
+
461
+ ### Key frames the engine cannot make
462
+
463
+ ```jsonc
464
+ {"event":"session.keyframeRequest","params":{"sessionId":"…","generation":1}}
465
+ ```
466
+
467
+ Raised when the receiver reports a loss it cannot repair (RTCP PLI or FIR), and
468
+ once when the transport connects. The consumer responds by feeding a keyframe
469
+ access unit — for a device helper, "reset the video stream". This is the encoded
470
+ source's version of the reference frame the engine's own encode loop sends when
471
+ the same thing happens on a captured desktop.
472
+
473
+ ### Input belongs to the consumer
474
+
475
+ For this source only, the control channel is **forwarded, not applied**:
476
+ `pointer`, `wheel`, `key`, `text` and `release_all` arrive as events, and the
477
+ consumer injects them wherever the picture came from (a device helper's own
478
+ touch injection, for instance). Coordinates are the encoded surface's own
479
+ pixels, exactly as a client sends them.
480
+
481
+ ```jsonc
482
+ {"event":"session.input","params":{"sessionId":"…",
483
+ "input":{"kind":"pointer","phase":"down","x":100,"y":200,"button":1,"seq":4}}}
484
+ ```
485
+
486
+ The event carries the control message as it arrived. The engine still validates
487
+ permission, ordering and replay before forwarding, so the client keeps the same
488
+ `ack`/`rejected` behaviour and a replayed gesture still cannot be delivered
489
+ twice. `ack` means "accepted for this session", not "applied to a desktop" —
490
+ `session.metrics.input_forwarded` counts what was handed on, and `input_applied`
491
+ stays zero. Clipboard messages are answered with an empty `clipboard` reply and
492
+ an error, because this source has no desktop clipboard: the device's clipboard
493
+ is the consumer's to carry.
494
+
495
+ `session.frame` and `session.wait_frame` are refused with `error.code =
496
+ "operation"` — there is no lossless frame to read — and `captured_frames`,
497
+ `refined_frames` and `target_kbps` stay at zero while `fed_frames` counts what
498
+ the consumer handed over and `encoded_frames`, `encoded_bytes`, `key_frames`
499
+ and `encode_micros` describe what was packetized and sent.
500
+
501
+ ## Latest frame over the local protocol
502
+
503
+ `session.frame` requires `view` permission. It writes the latest pre-encode frame as
504
+ packed BGRX bytes to the caller's path, with optional `[x,y,w,h]` `region`.
505
+ The directory must already exist and be private to the caller; pixels never
506
+ travel through JSON. `seq` advances only when capture changes, `still_ms` is
507
+ elapsed time since that capture, and `damage` bounds the changed 32-pixel
508
+ tiles since `since` (full frame when the requested sequence is older than the
509
+ previous capture). A request with the current sequence reports empty damage;
510
+ omitting `path` requests metadata only (`written: false`). `after_seq` waits for
511
+ `seq` to exceed that value; `still_ms` waits for that many milliseconds
512
+ of stillness. When both are supplied, both conditions must hold.
513
+ `timeout_ms` alone waits for 150 ms of stillness. The default deadline is 5 s;
514
+ `timeout_ms` can extend it to at most 120 s. An unmet condition returns
515
+ `frame-timeout`. Damage is grouped by connected 32-pixel tiles.
516
+ Each changed capture emits a local `session.frame.changed` event with `sessionId`,
517
+ `seq` and `damage`, allowing a co-located consumer to track animation across
518
+ observations. Neither the event nor this file-writing method is forwarded by
519
+ the remote WebSocket bridge.
520
+
521
+ ```json
522
+ {"id":20,"method":"session.frame","params":{"session_id":"…","since":7,"path":"/run/user/1000/frame.raw"}}
523
+ {"id":20,"result":{"seq":8,"still_ms":210,"width":1280,"height":720,"format":"bgrx","damage":[[32,32,32,32]],"written":true}}
524
+ ```
313
525
 
314
526
  ## Clipboard over the local protocol
315
527
 
@@ -348,6 +560,58 @@ session that has already ended is refused with `error.code = "session"`.
348
560
 
349
561
  Closes every session and exits.
350
562
 
563
+ ## Display keeper
564
+
565
+ ```
566
+ desklink-host keep --display :42
567
+ ```
568
+
569
+ A kiosk window manager for one X screen the consumer created itself — typically
570
+ one `Xvfb` per screen, with its own `-auth` cookie file. It is not for the
571
+ user's desktop: it takes SubstructureRedirect on the root window, so it is that
572
+ screen's window manager, and a display that already has one refuses the keeper.
573
+ Authentication is the ambient `XAUTHORITY`; a screen the consumer created should
574
+ have its own cookie file, and the keeper inherits whatever the consumer's
575
+ environment carries. There is no handshake and no stdin: the contract is a
576
+ stdout JSON-lines stream, and closing the consumer's stdout end ends the keeper.
577
+
578
+ The keeper fills the screen with every normal (non-override-redirect,
579
+ input-output) top-level window at `0,0,W,H`, raises the newest one and keeps the
580
+ input focus on the newest content window, so keyboard events an engine session
581
+ injects on that display land where a person looking at the screen would expect. A top-level window
582
+ smaller than 120 px on either side is not content — an emulator draws its side
583
+ toolbar as a tiny second window — and is parked far off-screen instead, so a
584
+ capture of the screen never shows it.
585
+
586
+ On startup, and after every change on the screen, it writes one line:
587
+
588
+ ```jsonc
589
+ {"windows":[{"id":4194305,"title":"Pricing — Acme Store - Chromium","class":"Chromium","pid":31671,"width":1280,"height":800}]}
590
+ ```
591
+
592
+ `windows` lists every visible top-level window, bottom of the stacking order
593
+ first, newest last. `id` is the X window id; `title` comes from `_NET_WM_NAME`
594
+ with `WM_NAME` as the fallback; `class` is the second string of `WM_CLASS` (the
595
+ one consumers filter on, e.g. `Chromium`, `Google-chrome`); `pid` is
596
+ `_NET_WM_PID`. Fields the window does not carry are `null`, never absent. A
597
+ change is reported after 100 ms of quiet, and never later than 300 ms after the
598
+ first unreported change, so a busy screen cannot starve its consumer.
599
+
600
+ A refused startup is one error line and a non-zero exit:
601
+
602
+ ```jsonc
603
+ {"error":"another window manager"}
604
+ ```
605
+
606
+ The same shape reports a display that could not be opened. When the display
607
+ goes away — an `Xvfb` the consumer stopped, most commonly — the keeper exits 0
608
+ on its own; a consumer treats the process ending as the screen ending.
609
+
610
+ The keeper deliberately does not decorate, move, resize on request, or remember
611
+ anything across runs. A screen with no windows produces one `"windows":[]` line
612
+ at startup and then nothing: the absence of a report is the absence of
613
+ anything to show.
614
+
351
615
  ## What the engine deliberately does not do
352
616
 
353
617
  - It does not decide *who* the user is. It trusts the consumer's local