@desklink/host 0.1.1 → 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/README.md +29 -7
- package/bin/desklink-host.mjs +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/resolveEngine.d.ts +2 -2
- package/dist/resolveEngine.d.ts.map +1 -1
- package/dist/resolveEngine.js +4 -4
- package/dist/resolveEngine.js.map +1 -1
- package/docs/BOUNDARY.md +7 -3
- package/docs/PROTOCOL.md +205 -9
- package/engine/Cargo.lock +2 -1
- package/engine/Cargo.toml +2 -1
- package/engine/src/keeper.rs +622 -0
- package/engine/src/mac.rs +3 -3
- package/engine/src/main.rs +42 -0
- package/engine/src/peer.rs +429 -22
- package/engine/src/protocol.rs +138 -2
- package/engine/src/session.rs +768 -55
- package/package.json +2 -2
package/docs/PROTOCOL.md
CHANGED
|
@@ -23,7 +23,10 @@ to `stderr`. One JSON object per line, no length prefix, UTF-8.
|
|
|
23
23
|
Why stdio rather than a socket: the consumer already holds the process, so the
|
|
24
24
|
channel inherits the consumer's own access control (same uid, same session, no
|
|
25
25
|
filesystem permission to get wrong) and no second authorization mechanism has to
|
|
26
|
-
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.)
|
|
27
30
|
|
|
28
31
|
Cancellation is by closing the consumer's end. The engine treats EOF on `stdin`
|
|
29
32
|
exactly like `shutdown`: it releases held input, stops capture, closes peers, and
|
|
@@ -63,7 +66,7 @@ Request parameters use the engine's own snake_case names (`session_id`,
|
|
|
63
66
|
Result:
|
|
64
67
|
|
|
65
68
|
```jsonc
|
|
66
|
-
{"protocol":3,"engine":"desklink-host/0.
|
|
69
|
+
{"protocol":3,"engine":"desklink-host/0.2.0","platform":"linux", /* the rest of Capabilities */ }
|
|
67
70
|
```
|
|
68
71
|
|
|
69
72
|
The consumer must send `hello` first, and a version the engine does not speak is
|
|
@@ -105,7 +108,7 @@ session may require fresh consent. The macOS engine requires a VP9-enabled build
|
|
|
105
108
|
```jsonc
|
|
106
109
|
{
|
|
107
110
|
"protocol": 3,
|
|
108
|
-
"engine": "desklink-host/0.
|
|
111
|
+
"engine": "desklink-host/0.2.0",
|
|
109
112
|
"platform": "linux",
|
|
110
113
|
"session": {"kind": "wayland"},
|
|
111
114
|
"x11": {"available": true, "size": [2560, 1440]},
|
|
@@ -125,10 +128,15 @@ session may require fresh consent. The macOS engine requires a VP9-enabled build
|
|
|
125
128
|
"unavailable_reason": null,
|
|
126
129
|
"grant": "granted"
|
|
127
130
|
},
|
|
128
|
-
"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"]}
|
|
129
133
|
}
|
|
130
134
|
```
|
|
131
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
|
+
|
|
132
140
|
`input.mechanism` names the build's portal input path, `inputtino/uinput`,
|
|
133
141
|
where the engine creates its own virtual devices. A session opened against an X
|
|
134
142
|
display applies input through XTest instead, where the X server applies the
|
|
@@ -156,6 +164,7 @@ trigger a capture request.
|
|
|
156
164
|
{"id":4,"method":"session.open","params":{
|
|
157
165
|
"source": {"kind":"portal"}, // or {"kind":"x11","display":":99"}
|
|
158
166
|
// macOS: {"kind":"display","display_id":123}
|
|
167
|
+
// or {"kind":"encoded","codec":"h264","width":486,"height":1080}
|
|
159
168
|
"permissions": ["view","control","clipboard"],
|
|
160
169
|
"max_width": 3840, "max_height": 2160, // encode box (default); never upscales
|
|
161
170
|
"bitrate_kbps": 0, // 0 (default): sized to the encoded surface
|
|
@@ -182,8 +191,9 @@ Result:
|
|
|
182
191
|
screen cast, which is the only path that carries a Wayland user's consent;
|
|
183
192
|
`x11` reads a named X display's root window and applies input through XTest;
|
|
184
193
|
`display` selects a macOS display by the ID reported in capabilities (or the
|
|
185
|
-
main display when omitted)
|
|
186
|
-
|
|
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.
|
|
187
197
|
|
|
188
198
|
`permissions` are the engine's authority for this session. `view` is required
|
|
189
199
|
to open a session. The engine enforces the remaining scopes on every input and
|
|
@@ -233,6 +243,22 @@ the SDP and candidates to the client and brings back the answer.
|
|
|
233
243
|
|
|
234
244
|
A candidate that arrives before the remote description is buffered, not dropped.
|
|
235
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
|
+
|
|
236
262
|
With `loopback_tcp`, the engine also listens for ICE over TCP on
|
|
237
263
|
`127.0.0.1` and offers that passive candidate. It is for a client whose only
|
|
238
264
|
way to this computer is a forward of its loopback, such as an SSH tunnel: the
|
|
@@ -246,10 +272,21 @@ always does.
|
|
|
246
272
|
```jsonc
|
|
247
273
|
{"event":"session.state","params":{"sessionId":"…",
|
|
248
274
|
"capture":"streaming", // consented|streaming|ended
|
|
249
|
-
"transport":"connected", // new|connecting|connected|failed|closed
|
|
275
|
+
"transport":"connected", // new|connecting|connected|disconnected|failed|closed
|
|
250
276
|
"firstFrame":true}} // false until a frame has been encoded
|
|
251
277
|
```
|
|
252
278
|
|
|
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
|
+
|
|
253
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,
|
|
254
291
|
and the one the control channel's `hello` repeats. A session keeps the one
|
|
255
292
|
`generation` it was opened with: a different source or scale is a new
|
|
@@ -287,7 +324,8 @@ Metrics are a request, not a notification:
|
|
|
287
324
|
```jsonc
|
|
288
325
|
{"id":7,"result":{"captured_frames":812,"dropped_frames":3,"encoded_frames":809,
|
|
289
326
|
"encoded_bytes":12345678,"key_frames":1,"refined_frames":37,
|
|
290
|
-
"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}}
|
|
291
329
|
```
|
|
292
330
|
|
|
293
331
|
`dropped_frames` counts frames superseded by a newer one before they were coded;
|
|
@@ -327,10 +365,11 @@ are JSON, one per message:
|
|
|
327
365
|
| `release_all` | — | explicit safety net; the engine also does this on close and on channel loss |
|
|
328
366
|
| `clipboard_read` / `clipboard_write` | `request` (echoed back), `text` | explicit, on user action; never polled |
|
|
329
367
|
|
|
330
|
-
The engine answers on the same channel:
|
|
368
|
+
The engine answers on the same channel, and sends two things unprompted:
|
|
331
369
|
|
|
332
370
|
```jsonc
|
|
333
371
|
{"kind":"hello","protocol":3,"geometry":{…}} // once, when the channel opens
|
|
372
|
+
{"kind":"ping"} // heartbeat, twice a second; see "State" above
|
|
334
373
|
{"kind":"ack","seq":44}
|
|
335
374
|
{"kind":"rejected","seq":44,"code":"coordinates","message":"(9000,4) is outside the 1280x720 surface"}
|
|
336
375
|
{"kind":"clipboard","request":"…","text":"…","truncated":false} // an empty "text" with "error" when the read failed
|
|
@@ -354,6 +393,111 @@ layout can produce. macOS reports Unicode text support and currently maps
|
|
|
354
393
|
layout is diagnostic, not proof that this map matches it. Anything outside the
|
|
355
394
|
map is refused with `text-unsupported`.
|
|
356
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
|
+
|
|
357
501
|
## Latest frame over the local protocol
|
|
358
502
|
|
|
359
503
|
`session.frame` requires `view` permission. It writes the latest pre-encode frame as
|
|
@@ -416,6 +560,58 @@ session that has already ended is refused with `error.code = "session"`.
|
|
|
416
560
|
|
|
417
561
|
Closes every session and exits.
|
|
418
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
|
+
|
|
419
615
|
## What the engine deliberately does not do
|
|
420
616
|
|
|
421
617
|
- It does not decide *who* the user is. It trusts the consumer's local
|
package/engine/Cargo.lock
CHANGED
|
@@ -517,11 +517,12 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
|
|
517
517
|
|
|
518
518
|
[[package]]
|
|
519
519
|
name = "desklink-host"
|
|
520
|
-
version = "0.
|
|
520
|
+
version = "0.2.0"
|
|
521
521
|
dependencies = [
|
|
522
522
|
"anyhow",
|
|
523
523
|
"ashpd",
|
|
524
524
|
"async-trait",
|
|
525
|
+
"base64 0.22.1",
|
|
525
526
|
"block2",
|
|
526
527
|
"bytes",
|
|
527
528
|
"cc",
|
package/engine/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "desklink-host"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
rust-version = "1.97.1"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -14,6 +14,7 @@ path = "src/main.rs"
|
|
|
14
14
|
[dependencies]
|
|
15
15
|
serde = { version = "1", features = ["derive"] }
|
|
16
16
|
serde_json = "1"
|
|
17
|
+
base64 = "0.22"
|
|
17
18
|
|
|
18
19
|
[target.'cfg(target_os = "linux")'.dependencies]
|
|
19
20
|
anyhow = "1"
|