@desklink/host 0.1.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 (142) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +44 -0
  3. package/README.md +266 -0
  4. package/bin/desklink-host.mjs +113 -0
  5. package/dist/bridge.d.ts +57 -0
  6. package/dist/bridge.d.ts.map +1 -0
  7. package/dist/bridge.js +208 -0
  8. package/dist/bridge.js.map +1 -0
  9. package/dist/engineProcess.d.ts +82 -0
  10. package/dist/engineProcess.d.ts.map +1 -0
  11. package/dist/engineProcess.js +243 -0
  12. package/dist/engineProcess.js.map +1 -0
  13. package/dist/index.d.ts +12 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +12 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/protocol.d.ts +169 -0
  18. package/dist/protocol.d.ts.map +1 -0
  19. package/dist/protocol.js +18 -0
  20. package/dist/protocol.js.map +1 -0
  21. package/dist/resolveEngine.d.ts +34 -0
  22. package/dist/resolveEngine.d.ts.map +1 -0
  23. package/dist/resolveEngine.js +103 -0
  24. package/dist/resolveEngine.js.map +1 -0
  25. package/docs/BOUNDARY.md +145 -0
  26. package/docs/PROTOCOL.md +363 -0
  27. package/engine/Cargo.lock +3077 -0
  28. package/engine/Cargo.toml +37 -0
  29. package/engine/build.rs +107 -0
  30. package/engine/native/inputtino_shim.cpp +11 -0
  31. package/engine/native/vpx_shim.c +184 -0
  32. package/engine/native/wrapper.h +7 -0
  33. package/engine/src/capture.rs +510 -0
  34. package/engine/src/clipboard.rs +132 -0
  35. package/engine/src/convert.rs +299 -0
  36. package/engine/src/encoder.rs +204 -0
  37. package/engine/src/input.rs +355 -0
  38. package/engine/src/keymap.rs +338 -0
  39. package/engine/src/main.rs +614 -0
  40. package/engine/src/peer.rs +775 -0
  41. package/engine/src/portal.rs +115 -0
  42. package/engine/src/protocol.rs +331 -0
  43. package/engine/src/session.rs +2159 -0
  44. package/engine/src/x11.rs +244 -0
  45. package/engine/vendor/inputtino/.clang-format +166 -0
  46. package/engine/vendor/inputtino/.clang-tidy +30 -0
  47. package/engine/vendor/inputtino/.dockerignore +11 -0
  48. package/engine/vendor/inputtino/.github/workflows/mirror-repo.yml +14 -0
  49. package/engine/vendor/inputtino/.github/workflows/python-lint-format-test-typecheck.yml +124 -0
  50. package/engine/vendor/inputtino/.github/workflows/python-test-install.yaml +49 -0
  51. package/engine/vendor/inputtino/.github/workflows/rust.yml +40 -0
  52. package/engine/vendor/inputtino/CMakeLists.txt +187 -0
  53. package/engine/vendor/inputtino/LICENSE +21 -0
  54. package/engine/vendor/inputtino/README.md +110 -0
  55. package/engine/vendor/inputtino/bindings/python/.devcontainer/Dockerfile +23 -0
  56. package/engine/vendor/inputtino/bindings/python/.devcontainer/devcontainer.json +28 -0
  57. package/engine/vendor/inputtino/bindings/python/.python-version +1 -0
  58. package/engine/vendor/inputtino/bindings/python/CMakeLists.txt +23 -0
  59. package/engine/vendor/inputtino/bindings/python/CPPLINT.cfg +2 -0
  60. package/engine/vendor/inputtino/bindings/python/Makefile +49 -0
  61. package/engine/vendor/inputtino/bindings/python/README.md +215 -0
  62. package/engine/vendor/inputtino/bindings/python/pyproject.toml +178 -0
  63. package/engine/vendor/inputtino/bindings/python/src/inputtino/__init__.py +43 -0
  64. package/engine/vendor/inputtino/bindings/python/src/inputtino/_core.pyi +533 -0
  65. package/engine/vendor/inputtino/bindings/python/src/inputtino/base.py +63 -0
  66. package/engine/vendor/inputtino/bindings/python/src/inputtino/joypad.py +248 -0
  67. package/engine/vendor/inputtino/bindings/python/src/inputtino/keyboard.py +256 -0
  68. package/engine/vendor/inputtino/bindings/python/src/inputtino/mouse.py +106 -0
  69. package/engine/vendor/inputtino/bindings/python/src/inputtino/pentablet.py +73 -0
  70. package/engine/vendor/inputtino/bindings/python/src/inputtino/py.typed +0 -0
  71. package/engine/vendor/inputtino/bindings/python/src/inputtino/touchscreen.py +61 -0
  72. package/engine/vendor/inputtino/bindings/python/src/inputtino/trackpad.py +74 -0
  73. package/engine/vendor/inputtino/bindings/python/src/main.cpp +217 -0
  74. package/engine/vendor/inputtino/bindings/python/tests/__init__.py +0 -0
  75. package/engine/vendor/inputtino/bindings/python/tests/helpers.py +3 -0
  76. package/engine/vendor/inputtino/bindings/python/tests/test_base.py +60 -0
  77. package/engine/vendor/inputtino/bindings/python/tests/test_inputtino.py +5 -0
  78. package/engine/vendor/inputtino/bindings/python/tests/test_joypad.py +332 -0
  79. package/engine/vendor/inputtino/bindings/python/tests/test_keyboard.py +164 -0
  80. package/engine/vendor/inputtino/bindings/python/tests/test_mouse.py +185 -0
  81. package/engine/vendor/inputtino/bindings/python/tests/test_pentablet.py +198 -0
  82. package/engine/vendor/inputtino/bindings/python/tests/test_touchscreen.py +144 -0
  83. package/engine/vendor/inputtino/bindings/python/tests/test_trackpad.py +146 -0
  84. package/engine/vendor/inputtino/bindings/python/uv.lock +390 -0
  85. package/engine/vendor/inputtino/bindings/rust/Cargo.toml +6 -0
  86. package/engine/vendor/inputtino/bindings/rust/inputtino/.dockerignore +1 -0
  87. package/engine/vendor/inputtino/bindings/rust/inputtino/Cargo.toml +18 -0
  88. package/engine/vendor/inputtino/bindings/rust/inputtino/examples/keyboard.rs +33 -0
  89. package/engine/vendor/inputtino/bindings/rust/inputtino/examples/mouse.rs +17 -0
  90. package/engine/vendor/inputtino/bindings/rust/inputtino/src/common.rs +116 -0
  91. package/engine/vendor/inputtino/bindings/rust/inputtino/src/error.rs +17 -0
  92. package/engine/vendor/inputtino/bindings/rust/inputtino/src/joypad.rs +74 -0
  93. package/engine/vendor/inputtino/bindings/rust/inputtino/src/joypad_nintendo.rs +135 -0
  94. package/engine/vendor/inputtino/bindings/rust/inputtino/src/joypad_ps5.rs +280 -0
  95. package/engine/vendor/inputtino/bindings/rust/inputtino/src/joypad_xbox.rs +134 -0
  96. package/engine/vendor/inputtino/bindings/rust/inputtino/src/keyboard.rs +76 -0
  97. package/engine/vendor/inputtino/bindings/rust/inputtino/src/lib.rs +32 -0
  98. package/engine/vendor/inputtino/bindings/rust/inputtino/src/mouse.rs +178 -0
  99. package/engine/vendor/inputtino/bindings/rust/inputtino/tests/common.rs +38 -0
  100. package/engine/vendor/inputtino/bindings/rust/inputtino/tests/joypads.rs +150 -0
  101. package/engine/vendor/inputtino/bindings/rust/inputtino/tests/keyboard.rs +73 -0
  102. package/engine/vendor/inputtino/bindings/rust/inputtino/tests/mouse.rs +139 -0
  103. package/engine/vendor/inputtino/bindings/rust/inputtino-sys/.dockerignore +1 -0
  104. package/engine/vendor/inputtino/bindings/rust/inputtino-sys/Cargo.toml +12 -0
  105. package/engine/vendor/inputtino/bindings/rust/inputtino-sys/build.rs +48 -0
  106. package/engine/vendor/inputtino/bindings/rust/inputtino-sys/src/lib.rs +5 -0
  107. package/engine/vendor/inputtino/bindings/rust/inputtino-sys/wrapper.h +1 -0
  108. package/engine/vendor/inputtino/cmake/utils.cmake +9 -0
  109. package/engine/vendor/inputtino/docker/Dockerfile +70 -0
  110. package/engine/vendor/inputtino/include/inputtino/input.h +324 -0
  111. package/engine/vendor/inputtino/include/inputtino/input.hpp +466 -0
  112. package/engine/vendor/inputtino/include/inputtino/result.hpp +37 -0
  113. package/engine/vendor/inputtino/share/pkgconfig/libinputtino.pc.in +17 -0
  114. package/engine/vendor/inputtino/src/c-bindings/helpers.hpp +19 -0
  115. package/engine/vendor/inputtino/src/c-bindings/joypad_ps5.cpp +115 -0
  116. package/engine/vendor/inputtino/src/c-bindings/joypad_switch.cpp +63 -0
  117. package/engine/vendor/inputtino/src/c-bindings/joypad_xone.cpp +62 -0
  118. package/engine/vendor/inputtino/src/c-bindings/keyboard.cpp +42 -0
  119. package/engine/vendor/inputtino/src/c-bindings/mouse.cpp +67 -0
  120. package/engine/vendor/inputtino/src/c-bindings/pen_tablet.cpp +50 -0
  121. package/engine/vendor/inputtino/src/c-bindings/touchscreen.cpp +43 -0
  122. package/engine/vendor/inputtino/src/c-bindings/trackpad.cpp +49 -0
  123. package/engine/vendor/inputtino/src/uhid/README.adoc +63 -0
  124. package/engine/vendor/inputtino/src/uhid/include/crc32.hpp +38 -0
  125. package/engine/vendor/inputtino/src/uhid/include/uhid/protected_types.hpp +36 -0
  126. package/engine/vendor/inputtino/src/uhid/include/uhid/ps5.hpp +550 -0
  127. package/engine/vendor/inputtino/src/uhid/include/uhid/uhid.hpp +168 -0
  128. package/engine/vendor/inputtino/src/uhid/joypad_ps5.cpp +580 -0
  129. package/engine/vendor/inputtino/src/uinput/include/inputtino/keyboard.hpp +84 -0
  130. package/engine/vendor/inputtino/src/uinput/include/inputtino/protected_ps5_types.hpp +7 -0
  131. package/engine/vendor/inputtino/src/uinput/include/inputtino/protected_types.hpp +110 -0
  132. package/engine/vendor/inputtino/src/uinput/joypad_nintendo.cpp +190 -0
  133. package/engine/vendor/inputtino/src/uinput/joypad_ps.cpp +203 -0
  134. package/engine/vendor/inputtino/src/uinput/joypad_utils.hpp +246 -0
  135. package/engine/vendor/inputtino/src/uinput/joypad_xbox.cpp +190 -0
  136. package/engine/vendor/inputtino/src/uinput/keyboard.cpp +120 -0
  137. package/engine/vendor/inputtino/src/uinput/mouse.cpp +199 -0
  138. package/engine/vendor/inputtino/src/uinput/pentablet.cpp +162 -0
  139. package/engine/vendor/inputtino/src/uinput/touchscreen.cpp +140 -0
  140. package/engine/vendor/inputtino/src/uinput/trackpad.cpp +192 -0
  141. package/examples/reference-client.html +428 -0
  142. package/package.json +38 -0
@@ -0,0 +1,428 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
6
+ <title>desklink reference client</title>
7
+ <!--
8
+ The smallest thing that shows a desktop in a browser.
9
+
10
+ It is served by `desklink-host bridge` and exists for two reasons: to prove the
11
+ engine is usable by a consumer that has no signaling channel of its own, and to
12
+ be the worked example that a third-party application can read instead of
13
+ reverse-engineering the protocol.
14
+
15
+ Everything it does is in the documented protocol: one WebSocket carrying the
16
+ engine's local control protocol, one RTCPeerConnection, one data channel for
17
+ pointer, keyboard and clipboard. There is no framework and no build step.
18
+ -->
19
+ <style>
20
+ :root { color-scheme: dark; }
21
+ * { box-sizing: border-box; }
22
+ html, body { margin: 0; height: 100%; background: #0e1013; color: #e8eaed;
23
+ font: 15px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; overflow: hidden; }
24
+ body { display: flex; flex-direction: column; }
25
+ header { flex: none; display: flex; align-items: center; gap: 10px; padding: 8px 12px;
26
+ background: #17191d; border-bottom: 1px solid #2a2d33; }
27
+ header b { font-size: 14px; font-weight: 600; }
28
+ #status { color: #9aa0a6; font-size: 13px; flex: 1; min-width: 0; overflow: hidden;
29
+ text-overflow: ellipsis; white-space: nowrap; }
30
+ button { font: inherit; color: inherit; background: #23262c; border: 1px solid #3a3f47;
31
+ border-radius: 9px; min-height: 38px; padding: 6px 12px; }
32
+ button:disabled { opacity: .45; }
33
+ #stage { position: relative; flex: 1; min-height: 0; overflow: hidden;
34
+ display: flex; align-items: center; justify-content: center; background: #000; }
35
+ video { display: none; max-width: 100%; max-height: 100%; touch-action: none; }
36
+ #overlay { position: absolute; inset: 0; display: flex; align-items: center;
37
+ justify-content: center; padding: 24px; text-align: center; color: #9aa0a6; }
38
+ #tools { flex: none; display: flex; gap: 6px; justify-content: center; padding: 8px;
39
+ background: #17191d; border-top: 1px solid #2a2d33; }
40
+ #remote-keyboard { position: absolute; width: 1px; height: 1px; opacity: .01; border: 0; }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <header>
45
+ <b>desklink</b>
46
+ <span id="status">Starting…</span>
47
+ <button id="reconnect" disabled>Reconnect</button>
48
+ </header>
49
+ <div id="stage">
50
+ <video id="video" autoplay muted playsinline></video>
51
+ <div id="overlay"></div>
52
+ <textarea id="remote-keyboard" aria-label="Remote keyboard"></textarea>
53
+ </div>
54
+ <nav id="tools">
55
+ <button id="keyboard">Keyboard</button>
56
+ <button id="copy" hidden>Copy from desktop</button>
57
+ <button id="paste" hidden>Paste to desktop</button>
58
+ </nav>
59
+
60
+ <script type="module">
61
+ const params = new URLSearchParams(location.search);
62
+ const token = params.get('token') ?? '';
63
+ const socketUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/desktop?token=${encodeURIComponent(token)}`;
64
+
65
+ const video = document.getElementById('video');
66
+ const overlay = document.getElementById('overlay');
67
+ const status = document.getElementById('status');
68
+ const reconnect = document.getElementById('reconnect');
69
+ const keyboard = document.getElementById('remote-keyboard');
70
+ const copy = document.getElementById('copy');
71
+ const paste = document.getElementById('paste');
72
+
73
+ let socket = null;
74
+ let peer = null;
75
+ let channel = null;
76
+ let surface = { width: 0, height: 0 };
77
+ let nextSeq = 0;
78
+ let pending = new Map();
79
+ let requestId = 0;
80
+ let attempts = 0;
81
+ const clipboardRequests = new Map();
82
+
83
+ function say(text) { status.textContent = text; }
84
+ function show(text) { overlay.style.display = 'flex'; overlay.textContent = text; }
85
+ function hide() { overlay.style.display = 'none'; video.style.display = 'block'; }
86
+
87
+ /** One request on the engine's local protocol, over the bridge. */
88
+ function request(method, params) {
89
+ const id = ++requestId;
90
+ return new Promise((resolve, reject) => {
91
+ pending.set(id, { resolve, reject });
92
+ socket.send(JSON.stringify({ id, method, params: params ?? {} }));
93
+ setTimeout(() => {
94
+ if (pending.delete(id)) reject(new Error(`${method} timed out`));
95
+ }, 30_000);
96
+ });
97
+ }
98
+
99
+ /** One message on the session's control channel, sequenced for the engine. */
100
+ function control(message) {
101
+ if (channel?.readyState !== 'open') return;
102
+ channel.send(JSON.stringify({ ...message, seq: ++nextSeq }));
103
+ }
104
+
105
+ function sendPointer(phase, x, y, button) {
106
+ control({ kind: 'pointer', phase, x, y, ...(button === undefined ? {} : { button }) });
107
+ }
108
+
109
+ /** Touch → encoded-surface coordinates, or null when the touch is off the picture. */
110
+ function point(event) {
111
+ const rect = video.getBoundingClientRect();
112
+ if (surface.width === 0 || surface.height === 0) return null;
113
+ const scale = Math.min(rect.width / surface.width, rect.height / surface.height);
114
+ const drawn = { width: surface.width * scale, height: surface.height * scale };
115
+ const left = rect.left + (rect.width - drawn.width) / 2;
116
+ const top = rect.top + (rect.height - drawn.height) / 2;
117
+ const x = Math.floor((event.clientX - left) / scale);
118
+ const y = Math.floor((event.clientY - top) / scale);
119
+ if (x < 0 || y < 0 || x >= surface.width || y >= surface.height) return null;
120
+ return { x, y };
121
+ }
122
+
123
+ // One pointer, four outcomes: tap, drag, scroll, or a move that is nothing yet.
124
+ // The slop threshold is what separates a tap from a drag on a touch screen.
125
+ const SLOP = 6;
126
+ let gesture = null;
127
+ let pointers = 0;
128
+
129
+ video.addEventListener('pointerdown', (event) => {
130
+ // Capture keeps a drag alive when the finger leaves the picture. A synthetic
131
+ // event has no real pointer behind it, so a refusal here is not fatal.
132
+ pointers += 1;
133
+ try { video.setPointerCapture(event.pointerId); } catch { /* no real pointer */ }
134
+ if (pointers > 1) {
135
+ // A second finger makes this a scroll gesture; release anything the drag
136
+ // already pressed, and never turn the lift into a click.
137
+ if (gesture?.dragging) {
138
+ sendPointer('up', gesture.current.x, gesture.current.y, 1);
139
+ }
140
+ gesture = null;
141
+ return;
142
+ }
143
+ const at = point(event);
144
+ if (at === null) return;
145
+ gesture = { startX: event.clientX, startY: event.clientY, at, current: at, dragging: false, pointerId: event.pointerId };
146
+ sendPointer('move', at.x, at.y);
147
+ });
148
+ video.addEventListener('pointermove', (event) => {
149
+ if (gesture === null || gesture.pointerId !== event.pointerId) return;
150
+ const moved = Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY);
151
+ if (!gesture.dragging && moved > SLOP) {
152
+ sendPointer('down', gesture.at.x, gesture.at.y, 1);
153
+ gesture.dragging = true;
154
+ }
155
+ const at = point(event);
156
+ if (gesture.dragging && at !== null) {
157
+ sendPointer('move', at.x, at.y, 1);
158
+ gesture.current = at;
159
+ }
160
+ });
161
+ for (const name of ['pointerup', 'pointercancel']) {
162
+ video.addEventListener(name, (event) => {
163
+ pointers = Math.max(0, pointers - 1);
164
+ if (gesture === null || gesture.pointerId !== event.pointerId) return;
165
+ const at = point(event);
166
+ if (gesture.dragging) {
167
+ if (at !== null) sendPointer('up', at.x, at.y, 1);
168
+ else sendPointer('cancel', gesture.at.x, gesture.at.y);
169
+ } else if (name === 'pointerup' && at !== null) {
170
+ // A tap is a click at the touched point: press and release on the same
171
+ // coordinates, so the desktop sees the click where the user aimed.
172
+ sendPointer('down', at.x, at.y, 1);
173
+ sendPointer('up', at.x, at.y, 1);
174
+ }
175
+ gesture = null;
176
+ });
177
+ }
178
+ video.addEventListener('wheel', (event) => {
179
+ event.preventDefault();
180
+ control({ kind: 'wheel', dx: 0, dy: Math.sign(event.deltaY) });
181
+ }, { passive: false });
182
+
183
+ // The hidden editor owns the platform keyboard; the committed text and the
184
+ // named keys are what the desktop receives.
185
+ let composing = false;
186
+ keyboard.addEventListener('compositionstart', () => { composing = true; });
187
+ keyboard.addEventListener('compositionend', (event) => {
188
+ composing = false;
189
+ if (event.data) control({ kind: 'text', text: event.data });
190
+ keyboard.value = '';
191
+ });
192
+ keyboard.addEventListener('beforeinput', (event) => {
193
+ if (composing || event.isComposing) return;
194
+ if (event.inputType === 'insertText' && event.data) {
195
+ event.preventDefault();
196
+ control({ kind: 'text', text: event.data });
197
+ } else if (event.inputType === 'deleteContentBackward') {
198
+ event.preventDefault();
199
+ control({ kind: 'key', name: 'Backspace', down: true });
200
+ control({ kind: 'key', name: 'Backspace', down: false });
201
+ } else if (event.inputType === 'insertLineBreak') {
202
+ event.preventDefault();
203
+ control({ kind: 'key', name: 'Enter', down: true });
204
+ control({ kind: 'key', name: 'Enter', down: false });
205
+ }
206
+ });
207
+ const named = {
208
+ Enter: 'Enter', Backspace: 'Backspace', Tab: 'Tab', Escape: 'Escape',
209
+ ArrowLeft: 'ArrowLeft', ArrowRight: 'ArrowRight', ArrowUp: 'ArrowUp', ArrowDown: 'ArrowDown',
210
+ Delete: 'Delete', Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown',
211
+ Control: 'Control', Shift: 'Shift', Alt: 'Alt', Meta: 'Meta',
212
+ };
213
+ keyboard.addEventListener('keydown', (event) => {
214
+ const name = named[event.key];
215
+ if (composing || name === undefined) return;
216
+ event.preventDefault();
217
+ control({ kind: 'key', name, down: true });
218
+ });
219
+ keyboard.addEventListener('keyup', (event) => {
220
+ const name = named[event.key];
221
+ if (composing || name === undefined) return;
222
+ event.preventDefault();
223
+ control({ kind: 'key', name, down: false });
224
+ });
225
+
226
+ document.getElementById('keyboard').addEventListener('click', () => {
227
+ keyboard.value = '';
228
+ keyboard.focus();
229
+ });
230
+ copy.addEventListener('click', async () => {
231
+ const id = crypto.randomUUID();
232
+ const answer = new Promise((resolve) => clipboardRequests.set(id, resolve));
233
+ control({ kind: 'clipboard_read', request: id });
234
+ const reply = await Promise.race([answer, new Promise((r) => setTimeout(() => r({ error: 'the desktop did not answer' }), 5000))]);
235
+ clipboardRequests.delete(id);
236
+ if (reply.error) { say(`Copy failed: ${reply.error}`); return; }
237
+ await navigator.clipboard.writeText(reply.text ?? '');
238
+ say('Copied from the desktop');
239
+ });
240
+ paste.addEventListener('click', async () => {
241
+ // Read the local clipboard inside the user gesture: a browser clipboard read
242
+ // outside one is refused, so it cannot happen after an await.
243
+ let text = '';
244
+ try {
245
+ text = await navigator.clipboard.readText();
246
+ } catch {
247
+ say('The browser would not read the clipboard; type the text instead');
248
+ return;
249
+ }
250
+ const id = crypto.randomUUID();
251
+ control({ kind: 'clipboard_write', request: id, text });
252
+ say('Sent to the desktop clipboard');
253
+ });
254
+
255
+ let opening = false;
256
+ async function start() {
257
+ if (opening) return;
258
+ opening = true;
259
+ reconnect.disabled = true;
260
+ copy.hidden = true;
261
+ paste.hidden = true;
262
+ video.srcObject = null;
263
+ show(attempts === 0 ? 'Connecting…' : 'Reconnecting…');
264
+ say('Connecting…');
265
+ try {
266
+ const capabilities = await request('capabilities');
267
+ const clipboardAvailable = capabilities.clipboard?.read === true && capabilities.clipboard?.write === true;
268
+ const session = await request('session.open', {
269
+ permissions: clipboardAvailable ? ['view', 'control', 'clipboard'] : ['view', 'control'],
270
+ max_fps: 30,
271
+ });
272
+ copy.hidden = !clipboardAvailable;
273
+ paste.hidden = !clipboardAvailable;
274
+ surface = session.geometry.encoded;
275
+ // Reference-client diagnostics: enough to see the session from the console
276
+ // without a debugger. Not part of the protocol.
277
+ window.__session = session;
278
+ window.__request = request;
279
+ const pc = new RTCPeerConnection({ iceServers: [] });
280
+ peer = pc;
281
+ // Exposed for the page's own diagnostics: the only trustworthy answer to
282
+ // "is it really decoding" is the decoder's frame count, not a track event.
283
+ window.__peer = pc;
284
+ // The ENGINE creates the control channel, because it is the offerer. A
285
+ // client that creates its own channel with the same label gets a second,
286
+ // unrelated stream: its requests reach the engine, and every reply the
287
+ // engine sends lands on a channel the client never read. Taking the offered
288
+ // channel is what makes the session two-way.
289
+ pc.ondatachannel = (event) => {
290
+ channel = event.channel;
291
+ channel.onmessage = (event2) => {
292
+ const reply = JSON.parse(event2.data);
293
+ (window.__replies ??= []).push(reply);
294
+ handleReply(reply);
295
+ };
296
+ };
297
+ const handleReply = (reply) => {
298
+ if (reply.kind === 'hello') { surface = reply.geometry.encoded; return; }
299
+ if (reply.kind === 'revoked') { show(`Access ended: ${reply.reason}`); teardown(); return; }
300
+ if (reply.kind === 'clipboard') { clipboardRequests.get(reply.request)?.({ text: reply.text, error: reply.error }); return; }
301
+ if (reply.kind === 'rejected') say(`Refused: ${reply.code}`);
302
+ };
303
+ pc.ontrack = (event) => {
304
+ video.srcObject = event.streams[0];
305
+ hide();
306
+ attempts = 0;
307
+ // "Live" means a frame actually rendered. A track arriving, or ICE
308
+ // connecting, is not the same thing and would be a lie on a broken codec.
309
+ const firstFrame = () => {
310
+ say(`Live · ${video.videoWidth}x${video.videoHeight}`);
311
+ };
312
+ if (typeof video.requestVideoFrameCallback === 'function') {
313
+ video.requestVideoFrameCallback(firstFrame);
314
+ } else {
315
+ video.addEventListener('loadeddata', firstFrame, { once: true });
316
+ }
317
+ };
318
+ pc.onicecandidate = (event) => {
319
+ if (event.candidate === null) return;
320
+ void request('session.candidate', {
321
+ session_id: session.sessionId,
322
+ candidate: event.candidate.candidate,
323
+ sdp_mid: event.candidate.sdpMid,
324
+ sdp_m_line_index: event.candidate.sdpMLineIndex,
325
+ }).catch(() => undefined);
326
+ };
327
+ pc.onconnectionstatechange = () => {
328
+ if (pc !== peer) return;
329
+ say(`Live · ${pc.connectionState}`);
330
+ if (pc.connectionState === 'failed') { show('The connection was lost.'); teardown(); }
331
+ };
332
+ // The engine is the offerer, so this side waits for the offer and answers.
333
+ const offer = await waitForOffer();
334
+ await pc.setRemoteDescription({ type: 'offer', sdp: offer });
335
+ const answer = await pc.createAnswer();
336
+ await pc.setLocalDescription(answer);
337
+ await request('session.description', {
338
+ session_id: session.sessionId,
339
+ description: { type: 'answer', sdp: answer.sdp },
340
+ });
341
+ reconnect.disabled = false;
342
+ void pc;
343
+ } catch (error) {
344
+ show(`Could not open the desktop. ${error instanceof Error ? error.message : error}`);
345
+ say('Failed');
346
+ teardown();
347
+ reconnect.disabled = false;
348
+ } finally {
349
+ opening = false;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * The offer arrives as a notification, and it can land before `session.open`
355
+ * resolves or after: the engine creates it while the open is still in flight, so
356
+ * both orders happen in practice. Keeping the last offer means neither order
357
+ * hangs.
358
+ */
359
+ let offerWaiter = null;
360
+ let offerSdp = null;
361
+ function waitForOffer() {
362
+ if (offerSdp !== null) {
363
+ const ready = offerSdp;
364
+ offerSdp = null;
365
+ return Promise.resolve(ready);
366
+ }
367
+ return new Promise((resolve) => { offerWaiter = resolve; });
368
+ }
369
+
370
+ function teardown(message) {
371
+ peer?.close();
372
+ peer = null;
373
+ channel = null;
374
+ video.srcObject = null;
375
+ copy.hidden = true;
376
+ paste.hidden = true;
377
+ // A dead session must leave a visible state behind: clearing the video and
378
+ // saying nothing leaves a black rectangle that looks like a broken desktop
379
+ // rather than a lost connection.
380
+ if (typeof message === 'string') show(message);
381
+ }
382
+
383
+ function connectSocket() {
384
+ socket = new WebSocket(socketUrl);
385
+ socket.onmessage = (event) => {
386
+ const message = JSON.parse(event.data);
387
+ if (message.id !== undefined) {
388
+ const waiting = pending.get(message.id);
389
+ if (waiting === undefined) return;
390
+ pending.delete(message.id);
391
+ if (message.error) waiting.reject(new Error(`${message.error.code}: ${message.error.message}`));
392
+ else waiting.resolve(message.result);
393
+ return;
394
+ }
395
+ if (message.event === 'session.description') {
396
+ const sdp = message.params.description.sdp;
397
+ if (offerWaiter !== null) {
398
+ const resolve = offerWaiter;
399
+ offerWaiter = null;
400
+ resolve(sdp);
401
+ } else {
402
+ offerSdp = sdp;
403
+ }
404
+ }
405
+ };
406
+ socket.onclose = () => {
407
+ say('Disconnected');
408
+ teardown('Disconnected from the desktop host. Reconnect to continue.');
409
+ };
410
+ socket.onerror = () => {
411
+ say('The desktop host is unreachable');
412
+ teardown('The desktop host is unreachable. Reconnect to try again.');
413
+ };
414
+ }
415
+
416
+ reconnect.addEventListener('click', () => {
417
+ attempts += 1;
418
+ teardown();
419
+ socket?.close();
420
+ connectSocket();
421
+ socket.addEventListener('open', () => void start(), { once: true });
422
+ });
423
+
424
+ connectSocket();
425
+ socket.addEventListener('open', () => { show('Connecting…'); void start(); }, { once: true });
426
+ </script>
427
+ </body>
428
+ </html>
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@desklink/host",
3
+ "version": "0.1.0",
4
+ "license": "Apache-2.0",
5
+ "description": "Per-user, on-demand desktop capture, encode and input engine with a versioned local control protocol",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "desklink-host": "./bin/desklink-host.mjs"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "dist",
15
+ "engine/native",
16
+ "engine/build.rs",
17
+ "engine/Cargo.toml",
18
+ "engine/Cargo.lock",
19
+ "engine/src",
20
+ "engine/vendor",
21
+ "docs",
22
+ "examples",
23
+ "NOTICE",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc --build"
28
+ },
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "dependencies": {
33
+ "ws": "^8.18.0"
34
+ },
35
+ "optionalDependencies": {
36
+ "@desklink/host-linux-x64-gnu": "0.1.0"
37
+ }
38
+ }