@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.
- package/NOTICE +3 -0
- package/README.md +72 -16
- package/bin/desklink-host.mjs +14 -3
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +5 -1
- package/dist/bridge.js.map +1 -1
- package/dist/engineProcess.d.ts.map +1 -1
- package/dist/engineProcess.js +17 -5
- package/dist/engineProcess.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/keeper.d.ts +52 -0
- package/dist/keeper.d.ts.map +1 -0
- package/dist/keeper.js +101 -0
- package/dist/keeper.js.map +1 -0
- package/dist/protocol.d.ts +42 -5
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/resolveEngine.d.ts +3 -2
- package/dist/resolveEngine.d.ts.map +1 -1
- package/dist/resolveEngine.js +20 -5
- package/dist/resolveEngine.js.map +1 -1
- package/docs/BOUNDARY.md +11 -6
- package/docs/PROTOCOL.md +297 -33
- package/engine/Cargo.lock +196 -1
- package/engine/Cargo.toml +26 -5
- package/engine/build.rs +98 -23
- package/engine/native/agent_overlay_mac.m +133 -0
- package/engine/native/agent_overlay_wayland.c +192 -0
- package/engine/native/agent_overlay_x11.c +103 -0
- package/engine/native/mac_stream.mm +188 -0
- package/engine/src/capture.rs +169 -19
- package/engine/src/capture_mac.rs +145 -0
- package/engine/src/clipboard_mac.rs +182 -0
- package/engine/src/convert.rs +57 -1
- package/engine/src/indicator.rs +162 -0
- package/engine/src/input.rs +12 -0
- package/engine/src/input_mac.rs +312 -0
- package/engine/src/keeper.rs +622 -0
- package/engine/src/keymap.rs +25 -0
- package/engine/src/keymap_mac.rs +209 -0
- package/engine/src/mac.rs +722 -0
- package/engine/src/main.rs +158 -2
- package/engine/src/peer.rs +429 -22
- package/engine/src/portal.rs +4 -0
- package/engine/src/portal_mac.rs +68 -0
- package/engine/src/protocol.rs +186 -3
- package/engine/src/session.rs +1307 -100
- package/engine/src/x11.rs +23 -3
- package/engine/src/x11_mac.rs +40 -0
- package/examples/reference-client.html +33 -4
- package/package.json +2 -2
package/docs/PROTOCOL.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
# desklink local control protocol (
|
|
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
|
|
9
|
-
|
|
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":
|
|
63
|
+
{"id":1,"method":"hello","params":{"protocol":3}}
|
|
59
64
|
```
|
|
60
65
|
|
|
61
66
|
Result:
|
|
62
67
|
|
|
63
68
|
```jsonc
|
|
64
|
-
{"protocol":
|
|
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":
|
|
81
|
-
"engine": "desklink-host/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.
|
|
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
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
312
|
-
|
|
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
|