ai-remote 0.1.0 → 0.3.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 (47) hide show
  1. package/dist/cli.mjs +8065 -0
  2. package/dist/index.js +6 -6
  3. package/dist/protocols/index.js +2 -2
  4. package/dist/protocols/rdp/caps.js +1 -1
  5. package/dist/protocols/rdp/cert.js +1 -1
  6. package/dist/protocols/rdp/client.js +18 -16
  7. package/dist/protocols/rdp/client.js.map +2 -2
  8. package/dist/protocols/rdp/cliprdr.js +1 -1
  9. package/dist/protocols/rdp/credssp.js +2 -2
  10. package/dist/protocols/rdp/display.js +2 -2
  11. package/dist/protocols/rdp/gcc.js +2 -2
  12. package/dist/protocols/rdp/mcs.js +2 -2
  13. package/dist/protocols/rdp/ntlm.js +2 -2
  14. package/dist/protocols/rdp/pdu.js +1 -1
  15. package/dist/protocols/rdp/rail.js +1 -1
  16. package/dist/protocols/rdp/sec.js +3 -3
  17. package/dist/protocols/rdp/session.js +3 -3
  18. package/dist/protocols/rdp/tls.js +3 -3
  19. package/dist/protocols/rdp/vchannel.js +1 -1
  20. package/dist/protocols/rdp/x224.js +1 -1
  21. package/dist/protocols/ssh/kex.js +1 -1
  22. package/dist/protocols/ssh/session.js +4 -3
  23. package/dist/protocols/ssh/session.js.map +2 -2
  24. package/dist/protocols/ssh/transport.js +8 -6
  25. package/dist/protocols/ssh/transport.js.map +2 -2
  26. package/dist/protocols/vnc/session.js +3 -3
  27. package/dist/shared/connection.js +1 -1
  28. package/dist/shared/hosts.js +1 -1
  29. package/package.json +13 -3
  30. package/src/cli/cli.ts +451 -0
  31. package/src/cli/daemon.ts +291 -0
  32. package/src/cli/framebuffer.ts +115 -0
  33. package/src/cli/generated/viewer-bundle.ts +5 -0
  34. package/src/cli/ipc.ts +78 -0
  35. package/src/cli/paths.ts +28 -0
  36. package/src/cli/png.ts +106 -0
  37. package/src/cli/session.ts +263 -0
  38. package/src/cli/shell.ts +171 -0
  39. package/src/cli/transport.ts +86 -0
  40. package/src/cli/viewer-client/assets.d.ts +4 -0
  41. package/src/cli/viewer-client/main.ts +300 -0
  42. package/src/cli/viewer-page.ts +102 -0
  43. package/src/cli/viewer.ts +269 -0
  44. package/src/cli/wsserver.ts +144 -0
  45. package/src/protocols/rdp/client.ts +9 -1
  46. package/src/protocols/ssh/session.ts +1 -0
  47. package/src/protocols/ssh/transport.ts +15 -2
@@ -0,0 +1,86 @@
1
+ /**
2
+ * A TCP socket wearing the shape of a WebSocket.
3
+ *
4
+ * The protocol engines were written against a browser WebSocket pointed at the
5
+ * gateway's relay. What they actually use of it is small -- open, message,
6
+ * close, error, send, readyState, close() -- so a direct TCP connection can
7
+ * present the same face and the engines never learn which one they were given.
8
+ *
9
+ * The one asymmetry is the gateway's own control frames. GATEWAY_READY and
10
+ * GATEWAY_KEEPALIVE are a conversation between the browser and the relay, and
11
+ * they are strings precisely so they can never be mistaken for protocol bytes.
12
+ * There is no relay here, so they are dropped: forwarding them would push
13
+ * `VNC_GATEWAY_READY_v1` into the middle of an X.224 negotiation.
14
+ */
15
+
16
+ import net from 'node:net';
17
+
18
+ export class TcpTransport extends EventTarget {
19
+ static readonly CONNECTING = 0;
20
+ static readonly OPEN = 1;
21
+ static readonly CLOSING = 2;
22
+ static readonly CLOSED = 3;
23
+
24
+ readyState = 0;
25
+ /** Set by the engines; this transport always delivers ArrayBuffers. */
26
+ binaryType = 'arraybuffer';
27
+
28
+ #socket: net.Socket;
29
+ #closeSent = false;
30
+
31
+ constructor(host: string, port: number) {
32
+ super();
33
+
34
+ this.#socket = net.connect({ host, port, noDelay: true });
35
+
36
+ this.#socket.on('connect', () => {
37
+ this.readyState = 1;
38
+ this.dispatchEvent(new Event('open'));
39
+ });
40
+
41
+ this.#socket.on('data', (chunk: Buffer) => {
42
+ // A copy, because Node reuses its read buffer and the engines hold on to
43
+ // what they are handed until a frame is complete.
44
+ const bytes = new Uint8Array(chunk.byteLength);
45
+ bytes.set(chunk);
46
+ this.dispatchEvent(new MessageEvent('message', { data: bytes.buffer }));
47
+ });
48
+
49
+ this.#socket.on('error', (error: Error) => {
50
+ this.dispatchEvent(new Event('error'));
51
+ this.#reportClose(1006, error.message, false);
52
+ });
53
+
54
+ this.#socket.on('close', () => {
55
+ this.#reportClose(1006, 'The host closed the TCP connection.', false);
56
+ });
57
+ }
58
+
59
+ send(data: string | Uint8Array | ArrayBuffer): void {
60
+ // Gateway control frames. There is no gateway.
61
+ if (typeof data === 'string') return;
62
+ if (this.readyState !== 1) return;
63
+ this.#socket.write(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
64
+ }
65
+
66
+ close(code = 1000, reason = ''): void {
67
+ if (this.readyState >= 2) return;
68
+ this.readyState = 2;
69
+ this.#socket.destroy();
70
+ this.#reportClose(code, reason, code === 1000);
71
+ }
72
+
73
+ #reportClose(code: number, reason: string, wasClean: boolean): void {
74
+ if (this.#closeSent) return;
75
+ this.#closeSent = true;
76
+ this.readyState = 3;
77
+
78
+ // CloseEvent is not a Node global. The engines read exactly these three
79
+ // fields, so a plain Event carrying them is indistinguishable to them.
80
+ const event = new Event('close') as Event & { code: number; reason: string; wasClean: boolean };
81
+ event.code = code;
82
+ event.reason = reason;
83
+ event.wasClean = wasClean;
84
+ this.dispatchEvent(event);
85
+ }
86
+ }
@@ -0,0 +1,4 @@
1
+ // The viewer bundle imports xterm's stylesheet so the page carries it instead
2
+ // of reaching for a CDN. esbuild turns it into CSS output; TypeScript only
3
+ // needs to know the import is legal.
4
+ declare module '*.css';
@@ -0,0 +1,300 @@
1
+ /**
2
+ * The viewer front end: the desktop, and a terminal beside it.
3
+ *
4
+ * This is a real bundle rather than a hand-written script in a string, for one
5
+ * reason: a Windows console addresses the cursor absolutely -- conhost emits
6
+ * `ESC[9;1H` between every line of output -- so anything less than a genuine
7
+ * terminal emulator renders its output scrambled. xterm.js is that emulator,
8
+ * and the web front end in this repository already uses it.
9
+ */
10
+
11
+ import { Terminal } from '@xterm/xterm';
12
+ import { FitAddon } from '@xterm/addon-fit';
13
+ import '@xterm/xterm/css/xterm.css';
14
+
15
+ type Json = Record<string, any>;
16
+
17
+ const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
18
+
19
+ const canvas = $<HTMLCanvasElement>('screen');
20
+ const context = canvas.getContext('2d', { alpha: false })!;
21
+ const stateEl = $('state');
22
+ const sizeEl = $('size');
23
+ const hostEl = $('host');
24
+ const hintEl = $('hint');
25
+ const fitBtn = $('fit');
26
+ const ctlBtn = $('control');
27
+ const termBtn = $('term');
28
+ const fsBtn = $('full');
29
+ const termPane = $('terminal');
30
+ const termHost = $('termHost');
31
+
32
+ let control = false;
33
+ let painted = 0;
34
+
35
+ const socket = new WebSocket(location.href.replace(/^http/, 'ws'));
36
+ socket.binaryType = 'arraybuffer';
37
+
38
+ const send = (message: Json) => {
39
+ if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
40
+ };
41
+
42
+ // --- terminal ---------------------------------------------------------------
43
+
44
+ const terminal = new Terminal({
45
+ convertEol: false,
46
+ cursorBlink: true,
47
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
48
+ fontSize: 12,
49
+ scrollback: 5000,
50
+ theme: {
51
+ background: '#0d0f13',
52
+ foreground: '#d6dae3',
53
+ cursor: '#7ee2a8',
54
+ selectionBackground: '#2f4a7a',
55
+ },
56
+ });
57
+ const fitAddon = new FitAddon();
58
+ terminal.loadAddon(fitAddon);
59
+
60
+ let terminalOpened = false;
61
+ let terminalAttached = false;
62
+
63
+ function openTerminal(open: boolean): void {
64
+ termPane.classList.toggle('open', open);
65
+ termBtn.setAttribute('aria-pressed', String(open));
66
+ if (!open) return;
67
+
68
+ if (!terminalOpened) {
69
+ terminalOpened = true;
70
+ terminal.open(termHost);
71
+ // Keystrokes go straight to the shell, so the terminal behaves like a
72
+ // terminal: Ctrl-C interrupts, arrows reach the shell's own history.
73
+ terminal.onData((data) => {
74
+ if (!control) {
75
+ terminal.write('\r\n\x1b[33m[take control first — the terminal types on the remote machine]\x1b[m\r\n');
76
+ return;
77
+ }
78
+ send({ type: 'shell-input', text: data });
79
+ });
80
+ }
81
+
82
+ // Two frames, because the pane has only just been given a size and the
83
+ // first frame measures the layout it had before that. Fitting too early
84
+ // computes one column, and a shell told it has one column wraps every line
85
+ // of its output into a vertical ribbon.
86
+ requestAnimationFrame(() => requestAnimationFrame(() => {
87
+ fitAddon.fit();
88
+ reportSize();
89
+ if (!terminalAttached) {
90
+ terminalAttached = true;
91
+ send({ type: 'shell-attach' });
92
+ }
93
+ terminal.focus();
94
+ }));
95
+ }
96
+
97
+ /**
98
+ * Tell the shell how wide its terminal is -- but never a size that cannot be
99
+ * real. A bad measurement is worse than a stale one: the shell reflows its
100
+ * output to whatever it is told.
101
+ */
102
+ function reportSize(): void {
103
+ if (terminal.cols < 20 || terminal.rows < 5) return;
104
+ send({ type: 'shell-resize', columns: terminal.cols, rows: terminal.rows });
105
+ }
106
+
107
+ new ResizeObserver(() => {
108
+ if (!terminalOpened || !termPane.classList.contains('open')) return;
109
+ fitAddon.fit();
110
+ reportSize();
111
+ }).observe(termPane);
112
+
113
+ termBtn.addEventListener('click', () => openTerminal(termBtn.getAttribute('aria-pressed') !== 'true'));
114
+
115
+ // --- the desktop ------------------------------------------------------------
116
+
117
+ socket.addEventListener('open', () => {
118
+ stateEl.textContent = 'live';
119
+ stateEl.className = 'pill live';
120
+ });
121
+
122
+ socket.addEventListener('close', () => {
123
+ stateEl.textContent = 'disconnected';
124
+ stateEl.className = 'pill dead';
125
+ terminal.write('\r\n\x1b[31m[the session ended]\x1b[m\r\n');
126
+ });
127
+
128
+ socket.addEventListener('message', async (event) => {
129
+ if (typeof event.data !== 'string') {
130
+ await paint(new Uint8Array(event.data as ArrayBuffer));
131
+ return;
132
+ }
133
+
134
+ const message: Json = JSON.parse(event.data);
135
+ switch (message.type) {
136
+ case 'hello':
137
+ case 'resize':
138
+ resize(message);
139
+ if (message.host) hostEl.textContent = message.host;
140
+ break;
141
+ case 'control':
142
+ setControl(Boolean(message.on));
143
+ break;
144
+ case 'shell-data':
145
+ terminal.write(Uint8Array.from(atob(message.base64), (c) => c.charCodeAt(0)));
146
+ break;
147
+ case 'shell-status':
148
+ if (message.state === 'opening') terminal.write('\x1b[90m[opening a shell…]\x1b[m\r\n');
149
+ break;
150
+ case 'shell-error':
151
+ terminal.write(`\r\n\x1b[31m[${message.message}]\x1b[m\r\n`);
152
+ break;
153
+ default:
154
+ break;
155
+ }
156
+ });
157
+
158
+ function resize({ width, height }: Json): void {
159
+ if (!width || !height) return;
160
+ canvas.width = width;
161
+ canvas.height = height;
162
+ sizeEl.textContent = `${width}×${height}`;
163
+ }
164
+
165
+ /** Inflate with the platform's own decompressor; no library involved. */
166
+ async function inflate(bytes: Uint8Array): Promise<Uint8Array> {
167
+ const stream = new Blob([bytes as BlobPart]).stream()
168
+ .pipeThrough(new DecompressionStream('deflate'));
169
+ return new Uint8Array(await new Response(stream).arrayBuffer());
170
+ }
171
+
172
+ async function paint(frame: Uint8Array): Promise<void> {
173
+ const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
174
+ const left = view.getUint16(1, true);
175
+ const top = view.getUint16(3, true);
176
+ const width = view.getUint16(5, true);
177
+ const height = view.getUint16(7, true);
178
+
179
+ const rgba = await inflate(frame.subarray(9));
180
+ if (rgba.length < width * height * 4) return;
181
+
182
+ // The buffer came from a DecompressionStream, so it is a plain ArrayBuffer;
183
+ // the cast is only needed because a Uint8Array may in general be backed by a
184
+ // SharedArrayBuffer, which ImageData does not accept.
185
+ const pixels = new Uint8ClampedArray(rgba.buffer as ArrayBuffer, 0, width * height * 4);
186
+ context.putImageData(new ImageData(pixels, width, height), left, top);
187
+
188
+ if (++painted === 1) canvas.focus();
189
+ }
190
+
191
+ // --- input ------------------------------------------------------------------
192
+ //
193
+ // Positions are in desktop pixels. The canvas may be scaled to fit, so every
194
+ // pointer event is mapped back through the element's real size.
195
+
196
+ function at(event: MouseEvent): { x: number; y: number } {
197
+ const box = canvas.getBoundingClientRect();
198
+ return {
199
+ x: Math.round((event.clientX - box.left) * (canvas.width / box.width)),
200
+ y: Math.round((event.clientY - box.top) * (canvas.height / box.height)),
201
+ };
202
+ }
203
+
204
+ const desktop = (message: Json) => { if (control) send(message); };
205
+
206
+ canvas.addEventListener('mousemove', (e) => desktop({ type: 'move', ...at(e) }));
207
+ canvas.addEventListener('mousedown', (e) => {
208
+ e.preventDefault();
209
+ canvas.focus();
210
+ desktop({ type: 'button', ...at(e), button: e.button, pressed: true });
211
+ });
212
+ canvas.addEventListener('mouseup', (e) => {
213
+ e.preventDefault();
214
+ desktop({ type: 'button', ...at(e), button: e.button, pressed: false });
215
+ });
216
+ canvas.addEventListener('contextmenu', (e) => e.preventDefault());
217
+ canvas.addEventListener('wheel', (e) => {
218
+ e.preventDefault();
219
+ desktop({ type: 'scroll', ...at(e), dx: Math.sign(e.deltaX), dy: Math.sign(e.deltaY) });
220
+ }, { passive: false });
221
+
222
+ canvas.addEventListener('keydown', (e) => {
223
+ if (!control) return;
224
+ e.preventDefault();
225
+ desktop({ type: 'key', code: e.code, pressed: true });
226
+ });
227
+ canvas.addEventListener('keyup', (e) => {
228
+ if (!control) return;
229
+ e.preventDefault();
230
+ desktop({ type: 'key', code: e.code, pressed: false });
231
+ });
232
+
233
+ // --- fullscreen -------------------------------------------------------------
234
+ //
235
+ // What a remote-desktop client does with the key: the desktop takes the whole
236
+ // display, the page chrome gets out of the way, and keystrokes go to the host
237
+ // rather than to the browser. Fit is forced on while it lasts, because a 1:1
238
+ // desktop larger than the screen would otherwise be a fullscreen scrollbar.
239
+
240
+ let fitBeforeFullscreen = true;
241
+
242
+ async function toggleFullscreen(): Promise<void> {
243
+ if (document.fullscreenElement) {
244
+ await document.exitFullscreen().catch(() => {});
245
+ return;
246
+ }
247
+ fitBeforeFullscreen = fitBtn.getAttribute('aria-pressed') === 'true';
248
+ await document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
249
+ }
250
+
251
+ document.addEventListener('fullscreenchange', () => {
252
+ const on = Boolean(document.fullscreenElement);
253
+ document.body.classList.toggle('fullscreen', on);
254
+ fsBtn.setAttribute('aria-pressed', String(on));
255
+ setFit(on ? true : fitBeforeFullscreen);
256
+ if (!termPane.classList.contains('open')) canvas.focus();
257
+ });
258
+
259
+ fsBtn.addEventListener('click', () => void toggleFullscreen());
260
+
261
+ /**
262
+ * F11 belongs to the host once it has the keyboard, so fullscreen is on a
263
+ * chord the remote desktop has no use for. Escape leaves, which is what every
264
+ * fullscreen surface does anyway.
265
+ */
266
+ window.addEventListener('keydown', (event) => {
267
+ if (event.key === 'F11' && !control) { event.preventDefault(); void toggleFullscreen(); }
268
+ if (event.shiftKey && event.ctrlKey && event.code === 'KeyF') {
269
+ event.preventDefault();
270
+ void toggleFullscreen();
271
+ }
272
+ }, true);
273
+
274
+ // --- controls ---------------------------------------------------------------
275
+
276
+ function setFit(on: boolean): void {
277
+ fitBtn.setAttribute('aria-pressed', String(on));
278
+ canvas.classList.toggle('fit', on);
279
+ }
280
+
281
+ fitBtn.addEventListener('click', () => setFit(fitBtn.getAttribute('aria-pressed') !== 'true'));
282
+
283
+ // Ask; the server decides. The button reflects what came back, so it can never
284
+ // claim control the session has not actually granted.
285
+ ctlBtn.addEventListener('click', () => send({ type: 'control', on: !control }));
286
+
287
+ function setControl(on: boolean): void {
288
+ control = on;
289
+ ctlBtn.setAttribute('aria-pressed', String(on));
290
+ ctlBtn.textContent = on ? 'Release control' : 'Take control';
291
+ hintEl.textContent = on
292
+ ? 'You have control. Keyboard and pointer go to the remote machine.'
293
+ : 'Watching. The agent is driving — press “Take control” to send input yourself.';
294
+ if (on && !termPane.classList.contains('open')) canvas.focus();
295
+ }
296
+
297
+ $('cad').addEventListener('click', () => {
298
+ if (!control) { hintEl.textContent = 'Take control first — Ctrl+Alt+Del is input like any other.'; return; }
299
+ send({ type: 'cad' });
300
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The viewer page.
3
+ *
4
+ * The markup and its styling live here; the behaviour is a real bundle, built
5
+ * from src/cli/viewer-client and inlined at build time. A published CLI has no
6
+ * asset directory it can rely on being unpacked beside it, so both arrive as
7
+ * strings.
8
+ */
9
+
10
+ import { VIEWER_CSS, VIEWER_JS } from './generated/viewer-bundle';
11
+
12
+ export const PAGE = `<!doctype html>
13
+ <html lang="en">
14
+ <head>
15
+ <meta charset="utf-8">
16
+ <meta name="viewport" content="width=device-width, initial-scale=1">
17
+ <title>ai-remote viewer</title>
18
+ <style>
19
+ :root { color-scheme: dark; }
20
+ * { box-sizing: border-box; }
21
+ body {
22
+ margin: 0; height: 100vh; display: flex; flex-direction: column;
23
+ background: #12141a; color: #e6e8ee;
24
+ font: 13px/1.5 ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
25
+ }
26
+ header {
27
+ display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
28
+ padding: 8px 12px; background: #181b21; border-bottom: 1px solid #262b34;
29
+ }
30
+ header .title { font-weight: 600; }
31
+ header .spacer { flex: 1; }
32
+ .pill {
33
+ padding: 2px 8px; border-radius: 999px; background: #22272f;
34
+ border: 1px solid #2f353f; font-variant-numeric: tabular-nums; font-size: 12px;
35
+ }
36
+ .pill.live { color: #7ee2a8; border-color: #2c5540; }
37
+ .pill.dead { color: #ff9b9b; border-color: #5c3131; }
38
+ button {
39
+ font: inherit; color: inherit; background: #22272f; cursor: pointer;
40
+ border: 1px solid #2f353f; border-radius: 6px; padding: 4px 10px;
41
+ }
42
+ button:hover { background: #2c323c; }
43
+ button[aria-pressed="true"] { background: #2f4a7a; border-color: #3f63a4; }
44
+ .body { flex: 1; min-height: 0; display: flex; flex-direction: column; }
45
+ main {
46
+ flex: 1; min-height: 0; display: grid; place-items: center;
47
+ overflow: auto; padding: 12px;
48
+ }
49
+ canvas { display: block; background: #000; box-shadow: 0 10px 30px rgba(0,0,0,.8); outline: none; }
50
+ canvas.fit { max-width: 100%; max-height: 100%; object-fit: contain; }
51
+ #terminal {
52
+ display: none; min-height: 0; height: 40%;
53
+ border-top: 1px solid #262b34; background: #0d0f13; padding: 6px 8px 2px;
54
+ }
55
+ #terminal.open { display: block; }
56
+ #termHost { height: 100%; }
57
+ footer { padding: 6px 12px; background: #181b21; border-top: 1px solid #262b34; color: #97a0b0; }
58
+
59
+ /* Fullscreen: the desktop, and nothing else. The header stays reachable by
60
+ moving the pointer to the top edge, so control and the terminal are not
61
+ lost behind a keystroke nobody can guess. */
62
+ body.fullscreen { background: #000; }
63
+ body.fullscreen main { padding: 0; }
64
+ body.fullscreen footer { display: none; }
65
+ body.fullscreen canvas { box-shadow: none; }
66
+ body.fullscreen header {
67
+ position: fixed; inset: 0 0 auto 0; z-index: 10;
68
+ background: rgba(24, 27, 33, .94); border-bottom-color: rgba(38, 43, 52, .9);
69
+ transform: translateY(-100%); transition: transform .18s ease;
70
+ }
71
+ body.fullscreen header:hover,
72
+ body.fullscreen header:focus-within { transform: none; }
73
+ body.fullscreen::before {
74
+ content: ''; position: fixed; inset: 0 0 auto 0; height: 8px; z-index: 9;
75
+ }
76
+ body.fullscreen:has(header:hover)::before { height: 0; }
77
+ ${VIEWER_CSS}
78
+ </style>
79
+ </head>
80
+ <body>
81
+ <header>
82
+ <span class="title">ai-remote</span>
83
+ <span class="pill" id="host">—</span>
84
+ <span class="pill" id="size">—</span>
85
+ <span class="pill" id="state">connecting…</span>
86
+ <span class="spacer"></span>
87
+ <button id="fit" aria-pressed="true">Fit</button>
88
+ <button id="full" aria-pressed="false" title="Fullscreen (Ctrl+Shift+F, Escape to leave)">Fullscreen</button>
89
+ <button id="control" aria-pressed="false" title="While off, you watch without sending input">Take control</button>
90
+ <button id="term" aria-pressed="false">Terminal</button>
91
+ <button id="cad">Ctrl+Alt+Del</button>
92
+ </header>
93
+ <div class="body">
94
+ <main><canvas id="screen" class="fit" tabindex="0"></canvas></main>
95
+ <section id="terminal"><div id="termHost"></div></section>
96
+ </div>
97
+ <footer id="hint">Watching. The agent is driving — press “Take control” to send input yourself.</footer>
98
+ <script type="module">
99
+ ${VIEWER_JS}
100
+ </script>
101
+ </body>
102
+ </html>`;