ai-remote 0.4.0 → 0.4.2

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 (166) hide show
  1. package/{src/index.ts → dist/index.d.ts} +0 -1
  2. package/dist/index.js +0 -1
  3. package/dist/protocols/index.d.ts +31 -0
  4. package/dist/protocols/index.js +0 -1
  5. package/dist/protocols/rdp/buffer.d.ts +38 -0
  6. package/dist/protocols/rdp/buffer.js +0 -1
  7. package/dist/protocols/rdp/caps.d.ts +49 -0
  8. package/dist/protocols/rdp/caps.js +0 -1
  9. package/dist/protocols/rdp/cert.d.ts +18 -0
  10. package/dist/protocols/rdp/cert.js +0 -1
  11. package/dist/protocols/rdp/client.d.ts +56 -0
  12. package/dist/protocols/rdp/client.js +0 -1
  13. package/dist/protocols/rdp/cliprdr.d.ts +27 -0
  14. package/dist/protocols/rdp/cliprdr.js +0 -1
  15. package/dist/protocols/rdp/credssp.d.ts +34 -0
  16. package/dist/protocols/rdp/credssp.js +0 -1
  17. package/dist/protocols/rdp/crypto.d.ts +63 -0
  18. package/dist/protocols/rdp/crypto.js +0 -1
  19. package/dist/protocols/rdp/display.d.ts +38 -0
  20. package/dist/protocols/rdp/display.js +0 -1
  21. package/dist/protocols/rdp/gcc.d.ts +23 -0
  22. package/dist/protocols/rdp/gcc.js +0 -1
  23. package/dist/protocols/rdp/keymap.d.ts +9 -0
  24. package/dist/protocols/rdp/keymap.js +0 -1
  25. package/dist/protocols/rdp/mcs.d.ts +40 -0
  26. package/dist/protocols/rdp/mcs.js +0 -1
  27. package/dist/protocols/rdp/ntlm.d.ts +61 -0
  28. package/dist/protocols/rdp/ntlm.js +0 -1
  29. package/dist/protocols/rdp/pdu.d.ts +155 -0
  30. package/dist/protocols/rdp/pdu.js +0 -1
  31. package/dist/protocols/rdp/rail.d.ts +119 -0
  32. package/dist/protocols/rdp/rail.js +0 -1
  33. package/dist/protocols/rdp/rle.d.ts +13 -0
  34. package/dist/protocols/rdp/rle.js +0 -1
  35. package/dist/protocols/rdp/sec.d.ts +59 -0
  36. package/dist/protocols/rdp/sec.js +0 -1
  37. package/dist/protocols/rdp/session.d.ts +62 -0
  38. package/dist/protocols/rdp/session.js +0 -1
  39. package/dist/protocols/rdp/tls.d.ts +18 -0
  40. package/dist/protocols/rdp/tls.js +0 -1
  41. package/dist/protocols/rdp/vchannel.d.ts +28 -0
  42. package/dist/protocols/rdp/vchannel.js +0 -1
  43. package/dist/protocols/rdp/x224.d.ts +40 -0
  44. package/dist/protocols/rdp/x224.js +0 -1
  45. package/dist/protocols/ssh/kex.d.ts +97 -0
  46. package/dist/protocols/ssh/kex.js +0 -1
  47. package/dist/protocols/ssh/messages.d.ts +50 -0
  48. package/dist/protocols/ssh/messages.js +0 -1
  49. package/dist/protocols/ssh/session.d.ts +66 -0
  50. package/dist/protocols/ssh/session.js +0 -1
  51. package/dist/protocols/ssh/terminal.d.ts +46 -0
  52. package/dist/protocols/ssh/terminal.js +0 -1
  53. package/dist/protocols/ssh/transport.d.ts +62 -0
  54. package/dist/protocols/ssh/transport.js +0 -1
  55. package/dist/protocols/ssh/wire.d.ts +52 -0
  56. package/dist/protocols/ssh/wire.js +0 -1
  57. package/dist/protocols/types.d.ts +127 -0
  58. package/dist/protocols/types.js +0 -1
  59. package/dist/protocols/vnc/input.d.ts +5 -0
  60. package/dist/protocols/vnc/input.js +0 -1
  61. package/dist/protocols/vnc/session.d.ts +12 -0
  62. package/dist/protocols/vnc/session.js +0 -1
  63. package/dist/shared/connection.d.ts +81 -0
  64. package/dist/shared/connection.js +0 -1
  65. package/dist/shared/device.d.ts +26 -0
  66. package/dist/shared/device.js +0 -1
  67. package/dist/shared/hosts.d.ts +63 -0
  68. package/dist/shared/hosts.js +0 -1
  69. package/dist/shared/icons.d.ts +46 -0
  70. package/dist/shared/icons.js +0 -1
  71. package/dist/shared/protocol.d.ts +28 -0
  72. package/dist/shared/protocol.js +0 -1
  73. package/dist/shared/signals.d.ts +37 -0
  74. package/dist/shared/signals.js +0 -1
  75. package/package.json +10 -11
  76. package/dist/index.js.map +0 -7
  77. package/dist/protocols/index.js.map +0 -7
  78. package/dist/protocols/rdp/buffer.js.map +0 -7
  79. package/dist/protocols/rdp/caps.js.map +0 -7
  80. package/dist/protocols/rdp/cert.js.map +0 -7
  81. package/dist/protocols/rdp/client.js.map +0 -7
  82. package/dist/protocols/rdp/cliprdr.js.map +0 -7
  83. package/dist/protocols/rdp/credssp.js.map +0 -7
  84. package/dist/protocols/rdp/crypto.js.map +0 -7
  85. package/dist/protocols/rdp/display.js.map +0 -7
  86. package/dist/protocols/rdp/gcc.js.map +0 -7
  87. package/dist/protocols/rdp/keymap.js.map +0 -7
  88. package/dist/protocols/rdp/mcs.js.map +0 -7
  89. package/dist/protocols/rdp/ntlm.js.map +0 -7
  90. package/dist/protocols/rdp/pdu.js.map +0 -7
  91. package/dist/protocols/rdp/rail.js.map +0 -7
  92. package/dist/protocols/rdp/rle.js.map +0 -7
  93. package/dist/protocols/rdp/sec.js.map +0 -7
  94. package/dist/protocols/rdp/session.js.map +0 -7
  95. package/dist/protocols/rdp/tls.js.map +0 -7
  96. package/dist/protocols/rdp/vchannel.js.map +0 -7
  97. package/dist/protocols/rdp/x224.js.map +0 -7
  98. package/dist/protocols/ssh/kex.js.map +0 -7
  99. package/dist/protocols/ssh/messages.js.map +0 -7
  100. package/dist/protocols/ssh/session.js.map +0 -7
  101. package/dist/protocols/ssh/terminal.js.map +0 -7
  102. package/dist/protocols/ssh/transport.js.map +0 -7
  103. package/dist/protocols/ssh/wire.js.map +0 -7
  104. package/dist/protocols/types.js.map +0 -7
  105. package/dist/protocols/vnc/input.js.map +0 -7
  106. package/dist/protocols/vnc/session.js.map +0 -7
  107. package/dist/shared/connection.js.map +0 -7
  108. package/dist/shared/device.js.map +0 -7
  109. package/dist/shared/hosts.js.map +0 -7
  110. package/dist/shared/icons.js.map +0 -7
  111. package/dist/shared/protocol.js.map +0 -7
  112. package/dist/shared/signals.js.map +0 -7
  113. package/src/cli/cli.ts +0 -462
  114. package/src/cli/daemon.ts +0 -306
  115. package/src/cli/framebuffer.ts +0 -115
  116. package/src/cli/generated/viewer-bundle.ts +0 -5
  117. package/src/cli/ipc.ts +0 -78
  118. package/src/cli/native/window.swift +0 -151
  119. package/src/cli/paths.ts +0 -28
  120. package/src/cli/png.ts +0 -106
  121. package/src/cli/session.ts +0 -263
  122. package/src/cli/shell.ts +0 -184
  123. package/src/cli/transport.ts +0 -86
  124. package/src/cli/viewer-client/assets.d.ts +0 -4
  125. package/src/cli/viewer-client/main.ts +0 -323
  126. package/src/cli/viewer-page.ts +0 -138
  127. package/src/cli/viewer.ts +0 -296
  128. package/src/cli/window.ts +0 -140
  129. package/src/cli/wsserver.ts +0 -144
  130. package/src/protocols/index.ts +0 -72
  131. package/src/protocols/rdp/buffer.ts +0 -204
  132. package/src/protocols/rdp/caps.ts +0 -304
  133. package/src/protocols/rdp/cert.ts +0 -190
  134. package/src/protocols/rdp/client.ts +0 -1371
  135. package/src/protocols/rdp/cliprdr.ts +0 -344
  136. package/src/protocols/rdp/credssp.ts +0 -319
  137. package/src/protocols/rdp/crypto.ts +0 -416
  138. package/src/protocols/rdp/display.ts +0 -750
  139. package/src/protocols/rdp/gcc.ts +0 -162
  140. package/src/protocols/rdp/keymap.ts +0 -68
  141. package/src/protocols/rdp/mcs.ts +0 -283
  142. package/src/protocols/rdp/ntlm.ts +0 -380
  143. package/src/protocols/rdp/pdu.ts +0 -497
  144. package/src/protocols/rdp/rail.ts +0 -480
  145. package/src/protocols/rdp/rle.ts +0 -343
  146. package/src/protocols/rdp/sec.ts +0 -184
  147. package/src/protocols/rdp/session.ts +0 -393
  148. package/src/protocols/rdp/tls.ts +0 -452
  149. package/src/protocols/rdp/vchannel.ts +0 -89
  150. package/src/protocols/rdp/x224.ts +0 -139
  151. package/src/protocols/ssh/kex.ts +0 -227
  152. package/src/protocols/ssh/messages.ts +0 -54
  153. package/src/protocols/ssh/session.ts +0 -879
  154. package/src/protocols/ssh/terminal.ts +0 -242
  155. package/src/protocols/ssh/transport.ts +0 -745
  156. package/src/protocols/ssh/wire.ts +0 -220
  157. package/src/protocols/types.ts +0 -146
  158. package/src/protocols/vnc/input.ts +0 -13
  159. package/src/protocols/vnc/novnc.d.ts +0 -75
  160. package/src/protocols/vnc/session.ts +0 -367
  161. package/src/shared/connection.ts +0 -206
  162. package/src/shared/device.ts +0 -28
  163. package/src/shared/hosts.ts +0 -240
  164. package/src/shared/icons.ts +0 -49
  165. package/src/shared/protocol.ts +0 -55
  166. package/src/shared/signals.ts +0 -67
package/src/cli/shell.ts DELETED
@@ -1,184 +0,0 @@
1
- /**
2
- * An SSH shell on the same machine the desktop is showing.
3
- *
4
- * RDP has no shell channel, so a terminal beside the desktop is a second
5
- * connection to the same host. The engine is the browser's, unchanged: the key
6
- * exchange, the session keys and the password exist only in this process.
7
- */
8
-
9
- import { SshSession } from '../protocols/ssh/session';
10
- import { TcpTransport } from './transport';
11
-
12
- /**
13
- * The account name on its own.
14
- *
15
- * An RDP username may carry the machine or domain with it -- `DESKTOP-01\\owner`
16
- * or `owner@corp` -- and SSH wants neither. The terminal defaults to the same
17
- * account as the desktop, so this is what makes that default work rather than
18
- * fail on a name that was perfectly good for RDP.
19
- */
20
- export function bareUsername(username: string): string {
21
- const withoutDomain = username.includes('\\') ? username.split('\\').pop()! : username;
22
- return withoutDomain.split('@')[0].trim();
23
- }
24
-
25
- export interface ShellOptions {
26
- host: string;
27
- port: number;
28
- username: string;
29
- password: string;
30
- columns?: number;
31
- rows?: number;
32
- }
33
-
34
- export interface CommandResult {
35
- output: string;
36
- exitStatus: number | null;
37
- timedOut: boolean;
38
- durationMs: number | null;
39
- }
40
-
41
- export class Shell {
42
- readonly options: ShellOptions;
43
- session: any;
44
- ready = false;
45
- lastError = '';
46
-
47
- #sinks = new Set<(bytes: Uint8Array) => void>();
48
- /** What the shell has printed, so a viewer attaching late sees the scrollback. */
49
- #scrollback: Uint8Array[] = [];
50
- #scrollbackBytes = 0;
51
-
52
- constructor(options: ShellOptions) {
53
- this.options = options;
54
-
55
- this.session = new SshSession(`tcp://${options.host}:${options.port}`, {
56
- username: options.username,
57
- password: options.password,
58
- columns: options.columns ?? 120,
59
- rows: options.rows ?? 30,
60
- // Every host this reaches was named by the person running the command,
61
- // on their own network. There is no stored known_hosts to compare with
62
- // yet, so nothing useful can be decided here.
63
- verifyHost: async () => true,
64
- openTransport: () => new TcpTransport(options.host, options.port),
65
- });
66
-
67
- this.session.addEventListener('data', (event: any) => {
68
- // `display` is what the terminal should show: the same stream with the
69
- // framing `runCommand` wraps around a command filtered back out. `bytes`
70
- // is the unfiltered original, which would leak those markers on screen.
71
- const bytes: Uint8Array = event.detail.display ?? event.detail.bytes;
72
- if (!bytes?.length) return;
73
- this.#remember(bytes);
74
- for (const sink of this.#sinks) sink(bytes);
75
- });
76
-
77
- this.session.addEventListener('ready', () => { this.ready = true; });
78
- this.session.addEventListener('error', (event: any) => {
79
- this.lastError = event.detail?.message || '';
80
- });
81
- this.session.addEventListener('close', () => { this.ready = false; });
82
- }
83
-
84
- /** Everything printed so far, capped so a long-running shell cannot grow forever. */
85
- get scrollback(): Uint8Array {
86
- const total = this.#scrollback.reduce((sum, chunk) => sum + chunk.length, 0);
87
- const out = new Uint8Array(total);
88
- let offset = 0;
89
- for (const chunk of this.#scrollback) { out.set(chunk, offset); offset += chunk.length; }
90
- return out;
91
- }
92
-
93
- onData(sink: (bytes: Uint8Array) => void): () => void {
94
- this.#sinks.add(sink);
95
- return () => this.#sinks.delete(sink);
96
- }
97
-
98
- connect(timeoutMs = 30_000): Promise<void> {
99
- return new Promise((resolve, reject) => {
100
- let settled = false;
101
- const finish = (error?: Error) => {
102
- if (settled) return;
103
- settled = true;
104
- clearTimeout(timer);
105
- if (error) reject(error); else resolve();
106
- };
107
-
108
- const timer = setTimeout(
109
- () => finish(new Error(`The SSH host did not open a shell within ${Math.round(timeoutMs / 1000)}s.`)),
110
- timeoutMs
111
- );
112
-
113
- this.session.addEventListener('ready', () => finish());
114
- this.session.addEventListener('close', (event: any) => {
115
- finish(new Error(this.lastError || event.detail?.message || 'The SSH connection closed during sign-in.'));
116
- });
117
-
118
- this.session.connect();
119
- });
120
- }
121
-
122
- /**
123
- * Run one command and wait for its output and exit status.
124
- *
125
- * The first command against a Windows host usually fails: the engine frames
126
- * commands POSIX-style to find where the output starts and ends, cmd.exe
127
- * rejects that, and the engine notes the shell family and asks to be called
128
- * again. That is a detail of how the shell was discovered, not something a
129
- * caller did wrong, so the retry happens here instead of reaching them.
130
- */
131
- async runCommand(command: string): Promise<CommandResult> {
132
- let result;
133
- try {
134
- result = await this.session.runCommand(command);
135
- } catch (error) {
136
- const message = error instanceof Error ? error.message : String(error);
137
- if (!/run the command again|not a POSIX shell/i.test(message)) throw error;
138
-
139
- // Discovering the shell family restarts it, so the retry has to wait for
140
- // the prompt to come back rather than firing straight into a shell that
141
- // is still opening.
142
- await this.#waitForPrompt();
143
- result = await this.session.runCommand(command);
144
- }
145
- return {
146
- output: result.output ?? '',
147
- exitStatus: result.exitStatus ?? null,
148
- timedOut: Boolean(result.timedOut),
149
- durationMs: result.durationMs ?? null,
150
- };
151
- }
152
-
153
- /** Wait until the shell will accept a framed command again. */
154
- async #waitForPrompt(timeoutMs = 15_000): Promise<void> {
155
- const deadline = Date.now() + timeoutMs;
156
- while (Date.now() < deadline) {
157
- if (this.session.shellOpen && this.session.readySignaled !== false) return;
158
- await new Promise((resolve) => setTimeout(resolve, 100));
159
- }
160
- throw new Error('The SSH shell did not come back after its family was discovered.');
161
- }
162
-
163
- /** Raw keystrokes, for an interactive terminal. */
164
- write(text: string): void {
165
- this.session.write(text);
166
- }
167
-
168
- resize(columns: number, rows: number): void {
169
- this.session.resize?.(columns, rows);
170
- }
171
-
172
- disconnect(): void {
173
- try { this.session.disconnect(); } catch { /* already gone */ }
174
- }
175
-
176
- #remember(bytes: Uint8Array): void {
177
- const CAP = 256 * 1024;
178
- this.#scrollback.push(bytes);
179
- this.#scrollbackBytes += bytes.length;
180
- while (this.#scrollbackBytes > CAP && this.#scrollback.length > 1) {
181
- this.#scrollbackBytes -= this.#scrollback.shift()!.length;
182
- }
183
- }
184
- }
@@ -1,86 +0,0 @@
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
- }
@@ -1,4 +0,0 @@
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';
@@ -1,323 +0,0 @@
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 scaleBtn = $('scale');
25
- const ctlBtn = $('control');
26
- const termBtn = $('term');
27
- const fsBtn = $('full');
28
-
29
- /**
30
- * The toolbar has no room for a sentence, so a button's tooltip carries what a
31
- * status line used to say.
32
- */
33
- function tip(id: string, text: string): void {
34
- const element = $(id);
35
- element.title = text;
36
- element.setAttribute('aria-label', text);
37
- }
38
- const termPane = $('terminal');
39
- const termHost = $('termHost');
40
-
41
- let control = false;
42
- let painted = 0;
43
-
44
- // The window hides its own titlebar on macOS and lets the page draw the bar,
45
- // which means leaving room for the traffic lights that float over it.
46
- if (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)) {
47
- document.body.classList.add('macos');
48
- }
49
-
50
- const socket = new WebSocket(location.href.replace(/^http/, 'ws'));
51
- socket.binaryType = 'arraybuffer';
52
-
53
- const send = (message: Json) => {
54
- if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
55
- };
56
-
57
- // --- terminal ---------------------------------------------------------------
58
-
59
- const terminal = new Terminal({
60
- convertEol: false,
61
- cursorBlink: true,
62
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
63
- fontSize: 12,
64
- scrollback: 5000,
65
- theme: {
66
- background: '#0d0f13',
67
- foreground: '#d6dae3',
68
- cursor: '#7ee2a8',
69
- selectionBackground: '#2f4a7a',
70
- },
71
- });
72
- const fitAddon = new FitAddon();
73
- terminal.loadAddon(fitAddon);
74
-
75
- let terminalOpened = false;
76
- let terminalAttached = false;
77
-
78
- function openTerminal(open: boolean): void {
79
- termPane.classList.toggle('open', open);
80
- termBtn.setAttribute('aria-pressed', String(open));
81
- if (!open) return;
82
-
83
- if (!terminalOpened) {
84
- terminalOpened = true;
85
- terminal.open(termHost);
86
- // Keystrokes go straight to the shell, so the terminal behaves like a
87
- // terminal: Ctrl-C interrupts, arrows reach the shell's own history.
88
- terminal.onData((data) => {
89
- if (!control) {
90
- terminal.write('\r\n\x1b[33m[take control first — the terminal types on the remote machine]\x1b[m\r\n');
91
- return;
92
- }
93
- send({ type: 'shell-input', text: data });
94
- });
95
- }
96
-
97
- // Two frames, because the pane has only just been given a size and the
98
- // first frame measures the layout it had before that. Fitting too early
99
- // computes one column, and a shell told it has one column wraps every line
100
- // of its output into a vertical ribbon.
101
- requestAnimationFrame(() => requestAnimationFrame(() => {
102
- fitAddon.fit();
103
- reportSize();
104
- if (!terminalAttached) {
105
- terminalAttached = true;
106
- send({ type: 'shell-attach' });
107
- }
108
- terminal.focus();
109
- }));
110
- }
111
-
112
- /**
113
- * Tell the shell how wide its terminal is -- but never a size that cannot be
114
- * real. A bad measurement is worse than a stale one: the shell reflows its
115
- * output to whatever it is told.
116
- */
117
- function reportSize(): void {
118
- if (terminal.cols < 20 || terminal.rows < 5) return;
119
- send({ type: 'shell-resize', columns: terminal.cols, rows: terminal.rows });
120
- }
121
-
122
- new ResizeObserver(() => {
123
- if (!terminalOpened || !termPane.classList.contains('open')) return;
124
- fitAddon.fit();
125
- reportSize();
126
- }).observe(termPane);
127
-
128
- termBtn.addEventListener('click', () => openTerminal(termBtn.getAttribute('aria-pressed') !== 'true'));
129
-
130
- // --- the desktop ------------------------------------------------------------
131
-
132
- socket.addEventListener('open', () => {
133
- stateEl.className = 'dot live';
134
- stateEl.title = 'connected';
135
- });
136
-
137
- socket.addEventListener('close', () => {
138
- stateEl.className = 'dot dead';
139
- stateEl.title = 'the session ended';
140
- terminal.write('\r\n\x1b[31m[the session ended]\x1b[m\r\n');
141
- });
142
-
143
- socket.addEventListener('message', async (event) => {
144
- if (typeof event.data !== 'string') {
145
- await paint(new Uint8Array(event.data as ArrayBuffer));
146
- return;
147
- }
148
-
149
- const message: Json = JSON.parse(event.data);
150
- switch (message.type) {
151
- case 'hello':
152
- case 'resize':
153
- resize(message);
154
- if (message.host) hostEl.textContent = message.host;
155
- break;
156
- case 'control':
157
- setControl(Boolean(message.on));
158
- break;
159
- case 'shell-data':
160
- terminal.write(Uint8Array.from(atob(message.base64), (c) => c.charCodeAt(0)));
161
- break;
162
- case 'shell-status':
163
- if (message.state === 'opening') terminal.write('\x1b[90m[opening a shell…]\x1b[m\r\n');
164
- break;
165
- case 'shell-error':
166
- terminal.write(`\r\n\x1b[31m[${message.message}]\x1b[m\r\n`);
167
- break;
168
- default:
169
- break;
170
- }
171
- });
172
-
173
- function resize({ width, height }: Json): void {
174
- if (!width || !height) return;
175
- canvas.width = width;
176
- canvas.height = height;
177
- sizeEl.textContent = `${width}×${height}`;
178
- document.title = `${hostEl.textContent} — ${width}×${height}`;
179
- }
180
-
181
- /** Inflate with the platform's own decompressor; no library involved. */
182
- async function inflate(bytes: Uint8Array): Promise<Uint8Array> {
183
- const stream = new Blob([bytes as BlobPart]).stream()
184
- .pipeThrough(new DecompressionStream('deflate'));
185
- return new Uint8Array(await new Response(stream).arrayBuffer());
186
- }
187
-
188
- async function paint(frame: Uint8Array): Promise<void> {
189
- const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
190
- const left = view.getUint16(1, true);
191
- const top = view.getUint16(3, true);
192
- const width = view.getUint16(5, true);
193
- const height = view.getUint16(7, true);
194
-
195
- const rgba = await inflate(frame.subarray(9));
196
- if (rgba.length < width * height * 4) return;
197
-
198
- // The buffer came from a DecompressionStream, so it is a plain ArrayBuffer;
199
- // the cast is only needed because a Uint8Array may in general be backed by a
200
- // SharedArrayBuffer, which ImageData does not accept.
201
- const pixels = new Uint8ClampedArray(rgba.buffer as ArrayBuffer, 0, width * height * 4);
202
- context.putImageData(new ImageData(pixels, width, height), left, top);
203
-
204
- if (++painted === 1) canvas.focus();
205
- }
206
-
207
- // --- input ------------------------------------------------------------------
208
- //
209
- // Positions are in desktop pixels. The canvas may be scaled to fit, so every
210
- // pointer event is mapped back through the element's real size.
211
-
212
- function at(event: MouseEvent): { x: number; y: number } {
213
- const box = canvas.getBoundingClientRect();
214
- return {
215
- x: Math.round((event.clientX - box.left) * (canvas.width / box.width)),
216
- y: Math.round((event.clientY - box.top) * (canvas.height / box.height)),
217
- };
218
- }
219
-
220
- const desktop = (message: Json) => { if (control) send(message); };
221
-
222
- canvas.addEventListener('mousemove', (e) => desktop({ type: 'move', ...at(e) }));
223
- canvas.addEventListener('mousedown', (e) => {
224
- e.preventDefault();
225
- canvas.focus();
226
- desktop({ type: 'button', ...at(e), button: e.button, pressed: true });
227
- });
228
- canvas.addEventListener('mouseup', (e) => {
229
- e.preventDefault();
230
- desktop({ type: 'button', ...at(e), button: e.button, pressed: false });
231
- });
232
- canvas.addEventListener('contextmenu', (e) => e.preventDefault());
233
- canvas.addEventListener('wheel', (e) => {
234
- e.preventDefault();
235
- desktop({ type: 'scroll', ...at(e), dx: Math.sign(e.deltaX), dy: Math.sign(e.deltaY) });
236
- }, { passive: false });
237
-
238
- canvas.addEventListener('keydown', (e) => {
239
- if (!control) return;
240
- e.preventDefault();
241
- desktop({ type: 'key', code: e.code, pressed: true });
242
- });
243
- canvas.addEventListener('keyup', (e) => {
244
- if (!control) return;
245
- e.preventDefault();
246
- desktop({ type: 'key', code: e.code, pressed: false });
247
- });
248
-
249
- // --- fullscreen -------------------------------------------------------------
250
- //
251
- // What a remote-desktop client does with the key: the desktop takes the whole
252
- // display, the page chrome gets out of the way, and keystrokes go to the host
253
- // rather than to the browser. Fit is forced on while it lasts, because a 1:1
254
- // desktop larger than the screen would otherwise be a fullscreen scrollbar.
255
-
256
- let fitBeforeFullscreen = false;
257
-
258
- async function toggleFullscreen(): Promise<void> {
259
- if (document.fullscreenElement) {
260
- await document.exitFullscreen().catch(() => {});
261
- return;
262
- }
263
- fitBeforeFullscreen = scaleBtn.getAttribute('aria-pressed') === 'true';
264
- await document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
265
- }
266
-
267
- document.addEventListener('fullscreenchange', () => {
268
- const on = Boolean(document.fullscreenElement);
269
- document.body.classList.toggle('fullscreen', on);
270
- fsBtn.setAttribute('aria-pressed', String(on));
271
- setFit(on ? true : fitBeforeFullscreen);
272
- if (!termPane.classList.contains('open')) canvas.focus();
273
- });
274
-
275
- fsBtn.addEventListener('click', () => void toggleFullscreen());
276
-
277
- /**
278
- * F11 belongs to the host once it has the keyboard, so fullscreen is on a
279
- * chord the remote desktop has no use for. Escape leaves, which is what every
280
- * fullscreen surface does anyway.
281
- */
282
- window.addEventListener('keydown', (event) => {
283
- if (event.key === 'F11' && !control) { event.preventDefault(); void toggleFullscreen(); }
284
- if (event.shiftKey && event.ctrlKey && event.code === 'KeyF') {
285
- event.preventDefault();
286
- void toggleFullscreen();
287
- }
288
- }, true);
289
-
290
- // --- controls ---------------------------------------------------------------
291
-
292
- function setFit(on: boolean): void {
293
- scaleBtn.setAttribute('aria-pressed', String(on));
294
- canvas.classList.toggle('fit', on);
295
- tip('scale', on ? 'Showing scaled to fit — click for 100%' : 'Showing at 100% — click to scale to fit');
296
- }
297
-
298
- scaleBtn.addEventListener('click', () => setFit(scaleBtn.getAttribute('aria-pressed') !== 'true'));
299
-
300
- // Ask; the server decides. The button reflects what came back, so it can never
301
- // claim control the session has not actually granted.
302
- ctlBtn.addEventListener('click', () => send({ type: 'control', on: !control }));
303
-
304
- function setControl(on: boolean): void {
305
- control = on;
306
- ctlBtn.setAttribute('aria-pressed', String(on));
307
-
308
- tip('control', on
309
- ? 'You have control — click to go back to watching'
310
- : 'Watching. Click to send keyboard and pointer to the remote machine');
311
- if (on && !termPane.classList.contains('open')) canvas.focus();
312
- }
313
-
314
- $('cad').addEventListener('click', () => {
315
- if (!control) {
316
- tip('cad', 'Take control first — Ctrl+Alt+Del is input like any other');
317
- return;
318
- }
319
- send({ type: 'cad' });
320
- });
321
-
322
- // The desktop starts at 100%, and the window is sized to hold it.
323
- setFit(false);