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
@@ -1,1371 +0,0 @@
1
- // The RDP client connection state machine (MS-RDPBCGR 1.3.1.1).
2
- //
3
- // It runs in the browser and talks to the gateway's /rdp-ws relay, which moves
4
- // bytes to and from TCP port 3389 and performs the TLS upgrade when X.224
5
- // negotiation asks for one. Everything above the transport -- MCS, the security
6
- // layer, capability exchange, updates and input -- happens here.
7
-
8
- import { Reader, concatBytes } from './buffer';
9
- import { rsaEncrypt } from './crypto';
10
- import { NEG_FAILURE, NEG_PROTOCOL, connectionRequest, parseConnectionConfirm, splitFrames } from './x224';
11
- import { Tls12Client } from './tls';
12
- import { CredSspClient } from './credssp';
13
- import {
14
- MCS_BASE_CHANNEL_ID,
15
- attachUserRequest,
16
- channelJoinRequest,
17
- connectInitial,
18
- erectDomainRequest,
19
- parseAttachUserConfirm,
20
- parseChannelJoinConfirm,
21
- parseConnectResponse,
22
- parseDomainPdu,
23
- sendDataRequest,
24
- } from './mcs';
25
- import { ENCRYPTION_LEVEL, clientData, parseServerData } from './gcc';
26
- import { ClipboardChannel } from './cliprdr';
27
- import { RailChannel, SHOW_STATE, SYS_COMMAND, isPrimaryWindow, parseRemoteAppWindowOrders } from './rail';
28
- import {
29
- CHANNEL_FLAG_SHOW_PROTOCOL,
30
- CHANNEL_OPTION,
31
- ChannelReassembler,
32
- CHANNEL_CHUNK_LENGTH,
33
- chunkChannelData,
34
- } from './vchannel';
35
- import { SEC_FLAG, SecurityLayer } from './sec';
36
- import { buildClientCapabilities, parseServerCapabilities } from './caps';
37
- import {
38
- CONTROL_ACTION,
39
- PDU_TYPE,
40
- PDU_TYPE2,
41
- POINTER_FLAG,
42
- UPDATE_TYPE,
43
- clientInfoPdu,
44
- confirmActivePdu,
45
- controlPdu,
46
- errorInfoMessage,
47
- extendedMouseEvent,
48
- fontListPdu,
49
- inputEventsPdu,
50
- mouseEvent,
51
- parseBitmapUpdate,
52
- parseDemandActive,
53
- parsePaletteUpdate,
54
- parsePointerUpdate,
55
- pointerWheelFlags,
56
- parseShareControlHeader,
57
- parseShareDataHeader,
58
- refreshRectPdu,
59
- scancodeEvent,
60
- shareControlPdu,
61
- shareDataPdu,
62
- suppressOutputPdu,
63
- syncEvent,
64
- synchronizePdu,
65
- unicodeEvent,
66
- } from './pdu';
67
- import { CTRL_ALT_DEL, lookupScancode } from './keymap';
68
- import {
69
- GATEWAY_KEEPALIVE_SIGNAL,
70
- GATEWAY_READY_SIGNAL,
71
- KEEPALIVE_INTERVAL_MS,
72
- } from '../../shared/signals';
73
-
74
-
75
- const STATE = {
76
- IDLE: 'idle',
77
- NEGOTIATING: 'negotiating',
78
- TLS_HANDSHAKE: 'tls-handshake',
79
- CREDSSP: 'credssp',
80
- MCS_CONNECT: 'mcs-connect',
81
- ATTACH_USER: 'attach-user',
82
- JOIN_CHANNELS: 'join-channels',
83
- SECURE: 'secure-exchange',
84
- LICENSING: 'licensing',
85
- CAPABILITIES: 'capabilities',
86
- CONNECTED: 'connected',
87
- CLOSED: 'closed',
88
- };
89
-
90
- const LICENSE_ERROR_ALERT = 0xff;
91
-
92
- /**
93
- * The clipboard channel. ENCRYPT_RDP only matters under the RDP security layer,
94
- * where the host would otherwise send this channel in the clear.
95
- *
96
- * SHOW_PROTOCOL is not optional, and it has to be matched by
97
- * CHANNEL_FLAG_SHOW_PROTOCOL on every chunk the client sends. Windows silently
98
- * discards clipboard data that arrives without the pair: the channel opens, the
99
- * host announces itself, and then nothing the client sends is ever answered.
100
- *
101
- * Compression is absent because this client advertises no decompressor.
102
- */
103
- const CLIPBOARD_CHANNEL = {
104
- name: 'cliprdr',
105
- options: CHANNEL_OPTION.INITIALIZED | CHANNEL_OPTION.ENCRYPT_RDP | CHANNEL_OPTION.SHOW_PROTOCOL,
106
- };
107
-
108
- const RAIL_CHANNEL = {
109
- name: 'rail',
110
- options: CHANNEL_OPTION.INITIALIZED | CHANNEL_OPTION.ENCRYPT_RDP | CHANNEL_OPTION.SHOW_PROTOCOL,
111
- };
112
-
113
- const PERF_DISABLE_WALLPAPER = 0x00000001;
114
- const PERF_DISABLE_FULLWINDOWDRAG = 0x00000002;
115
- const PERF_DISABLE_MENUANIMATIONS = 0x00000004;
116
- const REMOTE_APP_CLOSE_GRACE_MS = 1200;
117
-
118
- // A window that has just been restored from maximized is not movable until the
119
- // host has finished the restore, so the move that fits it is sent one beat
120
- // later. A repeated fit is coalesced over the same delay.
121
- const REMOTE_APP_FIT_DELAY_MS = 120;
122
-
123
- // How far the host may place the window from what was asked for before the
124
- // client asks again, and how many times it is worth asking.
125
- const FIT_TOLERANCE_PIXELS = 2;
126
- const REMOTE_APP_FIT_ATTEMPTS = 4;
127
- const REMOTE_APP_FIT_MOVES = 12;
128
- const MIN_REMOTE_APP_SIZE = 320;
129
-
130
- /**
131
- * A single RDP connection. Emits:
132
- * 'ready' { width, height } -- the desktop size is known
133
- * 'bitmap' { rects } -- decoded rectangles to paint
134
- * 'palette' Uint8Array -- 8bpp palette update
135
- * 'pointer' pointer update -- shape, position or visibility
136
- * 'error' { message } -- fatal, always followed by 'close'
137
- * 'close' { clean, message }
138
- */
139
- export class RdpClient extends EventTarget {
140
- constructor(url: string, options = {}) {
141
- super();
142
-
143
- this.url = url;
144
- this.options = {
145
- width: 1024,
146
- height: 768,
147
- username: '',
148
- password: '',
149
- domain: '',
150
- keyboardLayout: 0x409,
151
- clientName: 'cf-gateway',
152
- security: 'auto',
153
- /** Returns a WebSocket-shaped transport for `url`; default is a real one. */
154
- openTransport: null,
155
- clipboard: true,
156
- remoteApp: null,
157
- performance: PERF_DISABLE_WALLPAPER | PERF_DISABLE_FULLWINDOWDRAG | PERF_DISABLE_MENUANIMATIONS,
158
- ...options,
159
- };
160
-
161
- this.state = STATE.IDLE;
162
- this.legacyRetryUsed = false;
163
- this.desktopWidth = this.options.width;
164
- this.desktopHeight = this.options.height;
165
- this.palette = null;
166
-
167
- this.userId = MCS_BASE_CHANNEL_ID;
168
- this.ioChannelId = 1003;
169
- this.serverChannelId = 0x03ea;
170
- this.shareId = 0;
171
- this.channelsToJoin = [];
172
- this.requestedChannels = [];
173
- /** Server-assigned virtual channel ids, keyed by the name we asked for. */
174
- this.channelNamesById = new Map();
175
- this.channelReassemblers = new Map();
176
- this.clipboard = null;
177
- this.rail = null;
178
- /** Live RemoteApp windows and their last reported geometry, by window id. */
179
- this.remoteAppWindows = new Map();
180
- this.remoteAppHadWindow = false;
181
- this.remoteAppCloseTimer = null;
182
- this.remoteAppFit = null;
183
- this.remoteAppFitTimer = null;
184
- this.clientRandom = null;
185
- this.serverData = null;
186
- this.fastPathFragments = [];
187
-
188
- this.receiveBuffer = new Uint8Array(0);
189
- this.keepaliveTimer = null;
190
- this.closeReported = false;
191
-
192
- // TLS and CredSSP run here rather than at the edge: the gateway's runtime
193
- // hides the host certificate that CredSSP must bind to, and validates it
194
- // against public CAs, which no self-signed RDP host can satisfy.
195
- this.tls = null;
196
- this.credssp = null;
197
- this.inboundQueue = Promise.resolve();
198
-
199
- // "auto" offers both modern layers and lets the host choose, which is what
200
- // the X.224 negotiation is for. HYBRID_EX is left out on purpose: a host
201
- // offered it will pick it, and it needs a round trip this client does not
202
- // implement.
203
- this.requestedProtocol = {
204
- auto: NEG_PROTOCOL.SSL | NEG_PROTOCOL.HYBRID,
205
- nla: NEG_PROTOCOL.SSL | NEG_PROTOCOL.HYBRID,
206
- tls: NEG_PROTOCOL.SSL,
207
- rdp: NEG_PROTOCOL.RDP,
208
- }[this.options.security] ?? (NEG_PROTOCOL.SSL | NEG_PROTOCOL.HYBRID);
209
-
210
- this.#openTransport();
211
- }
212
-
213
- /**
214
- * Open the transport and reset everything that belongs to one connection.
215
- * Falling back to another security layer needs a second socket, because a
216
- * host that refuses the negotiation closes the first.
217
- */
218
- #openTransport() {
219
- this.receiveBuffer = new Uint8Array(0);
220
- this.inboundQueue = Promise.resolve();
221
- this.tls = null;
222
- this.credssp = null;
223
- this.security = new SecurityLayer();
224
- this.replacingTransport = false;
225
- this.channelNamesById = new Map();
226
- this.channelReassemblers = new Map();
227
- this.clipboard = null;
228
- this.rail = null;
229
- this.remoteAppWindows.clear();
230
- this.remoteAppHadWindow = false;
231
- clearTimeout(this.remoteAppCloseTimer);
232
- clearTimeout(this.remoteAppFitTimer);
233
- this.remoteAppFitTimer = null;
234
- this.remoteAppCloseTimer = null;
235
-
236
- // The transport is injected so this client can run anywhere. In a browser
237
- // it is a WebSocket to the gateway's relay; in the CLI it is a TCP socket
238
- // straight at the host. Everything above it -- X.224, TLS, CredSSP, MCS --
239
- // is the same either way, which is the whole reason it is a seam.
240
- this.socket = this.options.openTransport
241
- ? this.options.openTransport(this.url)
242
- : new WebSocket(this.url);
243
- this.socket.binaryType = 'arraybuffer';
244
- this.socket.addEventListener('open', () => this.#onOpen());
245
- this.socket.addEventListener('message', (event) => this.#onMessage(event));
246
- this.socket.addEventListener('close', (event) => this.#onSocketClose(event));
247
- this.socket.addEventListener('error', () => {
248
- // The browser never exposes the underlying error; the close event that
249
- // follows carries the gateway's reason.
250
- console.warn('[RDP] WebSocket transport error', { target: this.url });
251
- });
252
- }
253
-
254
- /** Reconnect asking for the only security layer this host will accept. */
255
- #retryWithSecurityLayer(security) {
256
- console.info('[RDP] Reconnecting with a different security layer', { security });
257
- this.legacyRetryUsed = true;
258
- this.options.security = security;
259
- this.requestedProtocol = security === 'rdp' ? NEG_PROTOCOL.RDP : NEG_PROTOCOL.SSL;
260
- this.state = STATE.IDLE;
261
-
262
- // The old socket's close must not be reported as the session ending.
263
- this.replacingTransport = true;
264
- clearInterval(this.keepaliveTimer);
265
- try { this.socket.close(1000, 'Retrying with another security layer'); } catch {}
266
-
267
- this.#openTransport();
268
- }
269
-
270
- // --- transport ------------------------------------------------------------
271
-
272
- #onOpen() {
273
- // The relay holds off reading the host until the browser says it is ready,
274
- // so the negotiation response cannot be lost.
275
- this.socket.send(GATEWAY_READY_SIGNAL);
276
-
277
- this.state = STATE.NEGOTIATING;
278
- this.#send(connectionRequest(this.requestedProtocol));
279
-
280
- this.keepaliveTimer = setInterval(() => {
281
- if (this.socket.readyState !== WebSocket.OPEN) return;
282
-
283
- // Keeps the browser-to-gateway WebSocket from idling out at the edge.
284
- // The gateway absorbs this frame; it never reaches the host.
285
- this.socket.send(GATEWAY_KEEPALIVE_SIGNAL);
286
-
287
- // Which is why the session also needs traffic of its own. A desktop
288
- // nobody is touching sends nothing for minutes at a time, and the TCP
289
- // connection carrying it -- through the tunnel, through the VPC binding
290
- // -- is dropped as idle long before the user has finished reading the
291
- // screen. A sync event is what mstsc sends for the same reason: it
292
- // restates the toggle-key state, changes nothing, and is traffic.
293
- if (this.state === STATE.CONNECTED) {
294
- try {
295
- this.#sendInput([syncEvent(this.toggleFlags || 0)]);
296
- } catch {
297
- // A keepalive is not worth breaking a working session over.
298
- }
299
- }
300
- }, KEEPALIVE_INTERVAL_MS);
301
- }
302
-
303
- /** RDP-level send: once a secure channel exists everything rides inside it. */
304
- #send(bytes: Uint8Array) {
305
- if (this.tls && this.tls.established) {
306
- this.tls.write(bytes);
307
- return;
308
- }
309
- this.#sendRaw(bytes);
310
- }
311
-
312
- #sendRaw(bytes: Uint8Array) {
313
- if (this.socket.readyState !== WebSocket.OPEN) return;
314
- this.socket.send(bytes);
315
- }
316
-
317
- #onSocketClose(event) {
318
- clearInterval(this.keepaliveTimer);
319
- clearTimeout(this.remoteAppCloseTimer);
320
- clearTimeout(this.remoteAppFitTimer);
321
- this.remoteAppFitTimer = null;
322
- if (this.replacingTransport) return;
323
- if (this.state === STATE.CLOSED && this.closeReported) return;
324
-
325
- const wasConnected = this.state === STATE.CONNECTED;
326
- this.state = STATE.CLOSED;
327
- this.closeReported = true;
328
-
329
- this.dispatchEvent(new CustomEvent('close', {
330
- detail: {
331
- clean: event.wasClean && wasConnected,
332
- code: event.code,
333
- message: event.reason || '',
334
- phase: wasConnected ? 'session' : 'handshake',
335
- },
336
- }));
337
- }
338
-
339
- #fail(error: unknown) {
340
- if (this.state === STATE.CLOSED) return;
341
- const message = error instanceof Error ? error.message : String(error);
342
- console.error('[RDP] Protocol failure', { state: this.state, message });
343
-
344
- this.dispatchEvent(new CustomEvent('error', { detail: { message, phase: this.state } }));
345
- this.state = STATE.CLOSED;
346
- this.closeReported = true;
347
- clearInterval(this.keepaliveTimer);
348
- clearTimeout(this.remoteAppCloseTimer);
349
- clearTimeout(this.remoteAppFitTimer);
350
- this.remoteAppFitTimer = null;
351
-
352
- try { this.socket.close(4000, message.slice(0, 100)); } catch {}
353
- this.dispatchEvent(new CustomEvent('close', {
354
- detail: { clean: false, code: 4000, message, phase: 'protocol' },
355
- }));
356
- }
357
-
358
- disconnect() {
359
- if (this.state === STATE.CLOSED) return;
360
- this.state = STATE.CLOSED;
361
- this.closeReported = true;
362
- clearInterval(this.keepaliveTimer);
363
- clearTimeout(this.remoteAppCloseTimer);
364
- clearTimeout(this.remoteAppFitTimer);
365
- this.remoteAppFitTimer = null;
366
- try { this.socket.close(1000, 'Client disconnected'); } catch {}
367
- }
368
-
369
- /**
370
- * Transport bytes are processed strictly in order. Nothing awaited in here
371
- * may itself wait for a later message, or the pipeline would deadlock -- the
372
- * TLS and CredSSP startup is therefore detached and driven by callbacks.
373
- */
374
- #onMessage(event) {
375
- if (this.state === STATE.CLOSED) return;
376
-
377
- this.inboundQueue = this.inboundQueue
378
- .then(async () => {
379
- if (this.state === STATE.CLOSED) return;
380
-
381
- const chunk = typeof event.data === 'string'
382
- ? new TextEncoder().encode(event.data)
383
- : new Uint8Array(event.data);
384
-
385
- // Before the secure channel exists these are RDP frames; afterwards
386
- // they are TLS records carrying them.
387
- if (this.tls) await this.tls.receive(chunk);
388
- else await this.#ingest(chunk);
389
- })
390
- .catch((error: unknown) => this.#fail(error));
391
- }
392
-
393
- /** Plaintext coming out of the secure channel. */
394
- #onSecureData(bytes: Uint8Array) {
395
- if (this.state === STATE.CREDSSP && this.credssp) {
396
- this.credssp.receive(bytes);
397
- return;
398
- }
399
- return this.#ingest(bytes);
400
- }
401
-
402
- async #ingest(chunk: Uint8Array) {
403
- this.receiveBuffer = this.receiveBuffer.length === 0
404
- ? chunk
405
- : concatBytes(this.receiveBuffer, chunk);
406
-
407
- const { frames, rest } = splitFrames(this.receiveBuffer);
408
- this.receiveBuffer = rest.length === this.receiveBuffer.length ? this.receiveBuffer : rest.slice();
409
-
410
- for (const { fastPath, frame } of frames) {
411
- if (this.state === STATE.CLOSED) return;
412
- if (fastPath) this.#handleFastPath(frame);
413
- else await this.#handleX224(frame);
414
- }
415
- }
416
-
417
- // --- connection sequence --------------------------------------------------
418
-
419
- async #handleX224(frame: Uint8Array) {
420
- if (this.state === STATE.NEGOTIATING) {
421
- let selectedProtocol;
422
- try {
423
- ({ selectedProtocol } = parseConnectionConfirm(frame));
424
- } catch (error) {
425
- // A host offering only the legacy RDP security layer refuses TLS
426
- // outright. Negotiating automatically, that is an answer rather than a
427
- // failure: reconnect asking for the layer it does have.
428
- if (
429
- this.options.security === 'auto' &&
430
- !this.legacyRetryUsed &&
431
- error.negotiationFailure === NEG_FAILURE.SSL_NOT_ALLOWED
432
- ) {
433
- this.#retryWithSecurityLayer('rdp');
434
- return;
435
- }
436
- throw error;
437
- }
438
-
439
- this.selectedProtocol = selectedProtocol;
440
- console.info('[RDP] X.224 negotiation complete', {
441
- requested: this.requestedProtocol,
442
- selected: selectedProtocol,
443
- layer: selectedProtocol & NEG_PROTOCOL.HYBRID
444
- ? 'NLA / CredSSP'
445
- : selectedProtocol & NEG_PROTOCOL.SSL ? 'TLS' : 'RDP standard',
446
- });
447
-
448
- if (selectedProtocol & NEG_PROTOCOL.HYBRID_EX) {
449
- // CredSSP-with-early-user-auth adds a round trip this client does not
450
- // implement; asking for HYBRID alone avoids it.
451
- throw new Error('This host selected CredSSP with early user authorization, which this client does not implement.');
452
- }
453
-
454
- // A host that chose TLS or NLA gets its own TLS session, established
455
- // here so the certificate is visible and self-signed is acceptable.
456
- if (selectedProtocol & (NEG_PROTOCOL.SSL | NEG_PROTOCOL.HYBRID)) {
457
- void this.#startSecureChannel(selectedProtocol);
458
- return;
459
- }
460
-
461
- this.#startMcsConnect(selectedProtocol);
462
- return;
463
- }
464
-
465
- if (this.state === STATE.MCS_CONNECT) {
466
- this.serverData = parseServerData(parseConnectResponse(frame));
467
- console.info('[RDP] MCS connect response', {
468
- encryptionMethod: this.serverData.encryptionMethod,
469
- encryptionLevel: this.serverData.encryptionLevel,
470
- ioChannelId: this.serverData.ioChannelId,
471
- certificate: this.serverData.publicKey?.source || 'none',
472
- virtualChannels: this.serverData.channelIds,
473
- });
474
-
475
- this.ioChannelId = this.serverData.ioChannelId;
476
-
477
- // The host answers with one id per requested channel, in the order asked.
478
- this.channelNamesById = new Map();
479
- this.requestedChannels.forEach((channel, index) => {
480
- const channelId = this.serverData.channelIds[index];
481
- if (channelId !== undefined) this.channelNamesById.set(channelId, channel.name);
482
- });
483
-
484
- this.security.configure(
485
- this.serverData.encryptionMethod,
486
- this.serverData.encryptionLevel,
487
- // The client random is only needed when the RDP security layer is live.
488
- this.serverData.encryptionMethod ? this.#makeClientRandom() : new Uint8Array(32),
489
- this.serverData.serverRandom || new Uint8Array(32)
490
- );
491
-
492
- this.state = STATE.ATTACH_USER;
493
- this.#send(erectDomainRequest());
494
- this.#send(attachUserRequest());
495
- return;
496
- }
497
-
498
- if (this.state === STATE.ATTACH_USER) {
499
- ({ userId: this.userId } = parseAttachUserConfirm(frame));
500
- this.channelsToJoin = [this.userId, this.ioChannelId, ...this.serverData.channelIds];
501
- this.joinedChannels = 0;
502
- this.state = STATE.JOIN_CHANNELS;
503
- this.#send(channelJoinRequest(this.userId, this.channelsToJoin[0]));
504
- return;
505
- }
506
-
507
- if (this.state === STATE.JOIN_CHANNELS) {
508
- parseChannelJoinConfirm(frame);
509
- this.joinedChannels += 1;
510
-
511
- if (this.joinedChannels < this.channelsToJoin.length) {
512
- this.#send(channelJoinRequest(this.userId, this.channelsToJoin[this.joinedChannels]));
513
- return;
514
- }
515
-
516
- this.#startSecureSession();
517
- return;
518
- }
519
-
520
- // Past the connect sequence everything arrives as a send-data indication.
521
- const pdu = parseDomainPdu(frame);
522
- if (pdu.type === 'disconnect') {
523
- this.#fail(new Error(pdu.message));
524
- return;
525
- }
526
- if (pdu.type !== 'send-data') return;
527
-
528
- // Virtual channels are routed by id, before the security header heuristic
529
- // below: a channel PDU's own length field can look exactly like one.
530
- const channelName = this.channelNamesById.get(pdu.channelId);
531
- if (channelName) {
532
- const body = this.security.headerPresent
533
- ? this.security.unwrap(pdu.payload).payload
534
- : pdu.payload;
535
- this.#handleChannelData(channelName, body);
536
- return;
537
- }
538
-
539
- const { flags, payload } = this.#unwrapSecurity(pdu.payload);
540
- if (payload.length === 0) return;
541
-
542
- if (flags & SEC_FLAG.LICENSE_PKT) {
543
- this.#handleLicensing(payload);
544
- return;
545
- }
546
-
547
- this.#handleSharePdu(payload);
548
- }
549
-
550
- /**
551
- * Basic security headers are mandatory on licensing PDUs even when the host
552
- * reported no RDP-level encryption, so during that phase the header has to be
553
- * recognised rather than assumed. A share control PDU never has a zero pduType,
554
- * which is what tells the two apart.
555
- */
556
- #unwrapSecurity(bytes: Uint8Array) {
557
- if (this.security.headerPresent) return this.security.unwrap(bytes);
558
-
559
- const KNOWN_FLAGS =
560
- SEC_FLAG.EXCHANGE_PKT | SEC_FLAG.ENCRYPT | SEC_FLAG.INFO_PKT | SEC_FLAG.LICENSE_PKT |
561
- SEC_FLAG.LICENSE_ENCRYPT_CS | SEC_FLAG.REDIRECTION_PKT | SEC_FLAG.AUTODETECT_REQ |
562
- SEC_FLAG.HEARTBEAT;
563
-
564
- if (bytes.length >= 4) {
565
- const flags = bytes[0] | (bytes[1] << 8);
566
- const flagsHi = bytes[2] | (bytes[3] << 8);
567
- if (flagsHi === 0 && (flags & KNOWN_FLAGS) !== 0) {
568
- return this.security.unwrap(bytes, true);
569
- }
570
- }
571
-
572
- return { flags: 0, payload: bytes };
573
- }
574
-
575
- /**
576
- * Establish TLS and, when the host asked for NLA, authenticate with CredSSP
577
- * before the RDP connection sequence starts. Detached from the inbound
578
- * pipeline on purpose: it waits for messages that the pipeline delivers.
579
- */
580
- async #startSecureChannel(selectedProtocol: number) {
581
- try {
582
- this.state = STATE.TLS_HANDSHAKE;
583
- this.tls = new Tls12Client({
584
- send: (bytes: Uint8Array) => this.#sendRaw(bytes),
585
- onData: (bytes: Uint8Array) => this.#onSecureData(bytes),
586
- onError: (error: unknown) => this.#fail(error),
587
- });
588
-
589
- const session = await this.tls.start();
590
- console.info('[RDP] TLS established', {
591
- cipherSuite: session.cipherSuite,
592
- certificates: session.certificates.length,
593
- modulusBits: session.publicKey.modulus.length * 8,
594
- });
595
-
596
- if (selectedProtocol & NEG_PROTOCOL.HYBRID) {
597
- this.state = STATE.CREDSSP;
598
- this.credssp = new CredSspClient({
599
- username: this.options.username,
600
- domain: this.options.domain,
601
- password: this.options.password,
602
- serverPublicKey: session.publicKey.subjectPublicKey,
603
- send: (bytes: Uint8Array) => this.tls.write(bytes),
604
- log: (step: number, detail) => console.info(`[RDP] ${step}`, detail),
605
- });
606
-
607
- if (!this.options.password) {
608
- throw new Error('This host authenticates before the desktop appears (NLA), so it needs a username and password.');
609
- }
610
-
611
- const outcome = await this.credssp.authenticate();
612
- console.info('[RDP] Network Level Authentication succeeded', outcome);
613
- this.credssp = null;
614
- }
615
-
616
- this.#startMcsConnect(selectedProtocol);
617
- } catch (error) {
618
- this.#fail(error);
619
- }
620
- }
621
-
622
- #startMcsConnect(selectedProtocol: number) {
623
- this.state = STATE.MCS_CONNECT;
624
- this.requestedChannels = [];
625
- if (this.options.clipboard) this.requestedChannels.push(CLIPBOARD_CHANNEL);
626
- if (this.options.remoteApp?.program) this.requestedChannels.push(RAIL_CHANNEL);
627
- this.#send(connectInitial(clientData({
628
- width: this.options.width,
629
- height: this.options.height,
630
- clientName: this.options.clientName,
631
- keyboardLayout: this.options.keyboardLayout,
632
- selectedProtocol,
633
- // Only the bare RDP security layer needs RDP's own encryption; with TLS
634
- // underneath, the host reports no encryption method at all.
635
- encryptionMethods: selectedProtocol === NEG_PROTOCOL.RDP ? 0x0000000b : 0,
636
- channels: this.requestedChannels,
637
- })));
638
- }
639
-
640
- #makeClientRandom() {
641
- this.clientRandom = new Uint8Array(32);
642
- crypto.getRandomValues(this.clientRandom);
643
- return this.clientRandom;
644
- }
645
-
646
- /**
647
- * Send the security exchange (when the RDP security layer is in use) followed
648
- * by the client info PDU that carries the credentials.
649
- */
650
- #startSecureSession() {
651
- if (this.security.encryptionEnabled) {
652
- const key = this.serverData.publicKey;
653
- if (!key) throw new Error('RDP: the host asked for encryption but sent no public key');
654
-
655
- const encryptedRandom = rsaEncrypt(this.clientRandom, key.modulus, key.exponent);
656
- this.#sendOnIoChannel(SecurityLayer.securityExchange(encryptedRandom));
657
- }
658
-
659
- const info = clientInfoPdu({
660
- domain: this.options.domain,
661
- username: this.options.username,
662
- password: this.options.password,
663
- performanceFlags: this.options.performance,
664
- autoLogon: Boolean(this.options.password),
665
- remoteApp: Boolean(this.options.remoteApp?.program),
666
- });
667
-
668
- this.state = STATE.LICENSING;
669
- this.#sendOnIoChannel(this.security.wrap(info, SEC_FLAG.INFO_PKT));
670
- }
671
-
672
- #sendOnIoChannel(payload: Uint8Array) {
673
- this.#send(sendDataRequest(this.userId, this.ioChannelId, payload));
674
- }
675
-
676
- #handleLicensing(payload: Uint8Array) {
677
- if (payload.length < 4) return;
678
- const messageType = payload[0];
679
-
680
- if (messageType === LICENSE_ERROR_ALERT) {
681
- // The expected answer for a host that needs no per-device licence.
682
- console.info('[RDP] Licensing finished with an error alert (the normal path)');
683
- this.state = STATE.CAPABILITIES;
684
- return;
685
- }
686
-
687
- throw new Error(
688
- 'This host requires Remote Desktop licensing negotiation (per-device CAL), which this client does not implement.'
689
- );
690
- }
691
-
692
- // --- share layer ----------------------------------------------------------
693
-
694
- #handleSharePdu(payload: Uint8Array) {
695
- const control = parseShareControlHeader(payload);
696
- if (control.pduSource) this.serverChannelId = control.pduSource;
697
-
698
- if (control.pduType === PDU_TYPE.DEMAND_ACTIVE) {
699
- this.#handleDemandActive(control.payload);
700
- return;
701
- }
702
-
703
- if (control.pduType === PDU_TYPE.DEACTIVATE_ALL) {
704
- // The host is about to re-run capability exchange, typically because the
705
- // desktop was resized or the session was reconnected.
706
- console.info('[RDP] Host deactivated the share; waiting for a new demand active');
707
- this.state = STATE.CAPABILITIES;
708
- return;
709
- }
710
-
711
- if (control.pduType === PDU_TYPE.SERVER_REDIRECT) {
712
- throw new Error('This host redirected the session to another server, which this client cannot follow.');
713
- }
714
-
715
- if (control.pduType !== PDU_TYPE.DATA) return;
716
-
717
- const data = parseShareDataHeader(control.payload);
718
- if (data.compressedType & 0x20) {
719
- throw new Error('RDP: the host compressed a PDU although this client advertised no compression support');
720
- }
721
-
722
- this.#handleDataPdu(data);
723
- }
724
-
725
- #handleDemandActive(payload: Uint8Array) {
726
- const { shareId, capabilities } = parseDemandActive(payload);
727
- this.shareId = shareId;
728
-
729
- const serverCaps = parseServerCapabilities(capabilities);
730
- if (this.options.remoteApp?.program && (!(serverCaps.railSupportLevel & 1) || !serverCaps.windowSupportLevel)) {
731
- throw new Error('This RDP host did not accept RemoteApp mode. Publish the application on the host or connect without a RemoteApp program.');
732
- }
733
- if (serverCaps.desktopWidth && serverCaps.desktopHeight) {
734
- const resized = this.state === STATE.CONNECTED &&
735
- (serverCaps.desktopWidth !== this.desktopWidth || serverCaps.desktopHeight !== this.desktopHeight);
736
- this.desktopWidth = serverCaps.desktopWidth;
737
- this.desktopHeight = serverCaps.desktopHeight;
738
-
739
- // A host may re-run capability exchange with a different desktop size.
740
- // The canvas has to follow it, or every later update lands at the wrong
741
- // coordinates on a framebuffer of the old size.
742
- if (resized) {
743
- console.info('[RDP] Host resized the desktop', {
744
- desktop: `${this.desktopWidth}x${this.desktopHeight}`,
745
- });
746
- this.dispatchEvent(new CustomEvent('resize', {
747
- detail: { width: this.desktopWidth, height: this.desktopHeight },
748
- }));
749
- }
750
- }
751
- this.refreshRectSupported = serverCaps.refreshRectSupport;
752
-
753
- console.info('[RDP] Demand active', {
754
- shareId,
755
- desktop: `${this.desktopWidth}x${this.desktopHeight}`,
756
- bitsPerPixel: serverCaps.preferredBitsPerPixel,
757
- refreshRectSupport: serverCaps.refreshRectSupport,
758
- remoteApp: Boolean(this.options.remoteApp?.program),
759
- });
760
-
761
- const { bytes, count } = buildClientCapabilities({
762
- width: this.desktopWidth,
763
- height: this.desktopHeight,
764
- keyboardLayout: this.options.keyboardLayout,
765
- remoteApp: Boolean(this.options.remoteApp?.program),
766
- remoteAppCaps: serverCaps,
767
- });
768
-
769
- this.#sendShareControl(
770
- PDU_TYPE.CONFIRM_ACTIVE,
771
- confirmActivePdu(this.shareId, bytes, count)
772
- );
773
-
774
- // The host expects this exact sequence before it will send any output.
775
- this.#sendShareData(PDU_TYPE2.SYNCHRONIZE, synchronizePdu(this.serverChannelId));
776
- this.#sendShareData(PDU_TYPE2.CONTROL, controlPdu(CONTROL_ACTION.COOPERATE));
777
- this.#sendShareData(PDU_TYPE2.CONTROL, controlPdu(CONTROL_ACTION.REQUEST_CONTROL));
778
- this.#sendShareData(PDU_TYPE2.FONT_LIST, fontListPdu());
779
- }
780
-
781
- #handleDataPdu(data: Uint8Array) {
782
- switch (data.pduType2) {
783
- case PDU_TYPE2.UPDATE:
784
- this.#handleUpdate(new Reader(data.payload));
785
- return;
786
-
787
- case PDU_TYPE2.POINTER:
788
- this.dispatchEvent(new CustomEvent('pointer', { detail: parsePointerUpdate(new Reader(data.payload)) }));
789
- return;
790
-
791
- case PDU_TYPE2.FONT_MAP:
792
- this.#markConnected();
793
- return;
794
-
795
- case PDU_TYPE2.SET_ERROR_INFO: {
796
- const errorInfo = new Reader(data.payload).u32le();
797
- if (errorInfo === 0) return;
798
- this.#fail(new Error(errorInfoMessage(errorInfo)));
799
- return;
800
- }
801
-
802
- case PDU_TYPE2.SAVE_SESSION_INFO:
803
- console.info('[RDP] Host reported a completed sign-in');
804
- return;
805
-
806
- case PDU_TYPE2.SHUTDOWN_DENIED:
807
- console.info('[RDP] Host denied the shutdown request');
808
- return;
809
-
810
- case PDU_TYPE2.CONTROL:
811
- case PDU_TYPE2.SYNCHRONIZE:
812
- case PDU_TYPE2.PLAY_SOUND:
813
- case PDU_TYPE2.MONITOR_LAYOUT:
814
- return;
815
-
816
- default:
817
- return;
818
- }
819
- }
820
-
821
- #handleUpdate(reader: Reader) {
822
- const updateType = reader.u16le();
823
-
824
- if (!this.firstUpdateLogged) {
825
- this.firstUpdateLogged = true;
826
- console.info('[RDP] First host update received', { updateType });
827
- }
828
-
829
- if (updateType === UPDATE_TYPE.BITMAP) {
830
- const rects = parseBitmapUpdate(reader);
831
- if (rects.length > 0) {
832
- this.dispatchEvent(new CustomEvent('bitmap', { detail: { rects, palette: this.palette } }));
833
- }
834
- return;
835
- }
836
-
837
- if (updateType === UPDATE_TYPE.PALETTE) {
838
- this.palette = parsePaletteUpdate(reader);
839
- this.dispatchEvent(new CustomEvent('palette', { detail: this.palette }));
840
- return;
841
- }
842
-
843
- if (updateType === UPDATE_TYPE.ORDERS) {
844
- if (this.options.remoteApp?.program) {
845
- this.#handleRemoteAppWindowEvents(parseRemoteAppWindowOrders(reader));
846
- } else {
847
- // No primary order support was advertised. Windowing Alternate
848
- // Secondary orders are the only exception, and only in RemoteApp mode.
849
- console.warn('[RDP] Ignoring a drawing-order update the client did not advertise');
850
- }
851
- return;
852
- }
853
- }
854
-
855
- #handleRemoteAppWindowEvents(events) {
856
- for (const event of events) {
857
- if (event.type === 'deleted') {
858
- this.remoteAppWindows.delete(event.windowId);
859
- this.dispatchEvent(new CustomEvent('remoteappwindow', {
860
- detail: { ...event, count: this.remoteAppWindows.size },
861
- }));
862
- continue;
863
- }
864
-
865
- if (event.type === 'new') {
866
- clearTimeout(this.remoteAppCloseTimer);
867
- this.remoteAppCloseTimer = null;
868
- this.remoteAppHadWindow = true;
869
- } else if (!this.remoteAppWindows.has(event.windowId)) {
870
- // An update for a window whose creation order was never seen. Tracking
871
- // it anyway keeps the session alive and the geometry usable.
872
- this.remoteAppHadWindow = true;
873
- }
874
-
875
- // Only the changed fields are present in an update, so each one is
876
- // folded into what the window was already known to be.
877
- const known = this.remoteAppWindows.get(event.windowId) || { windowId: event.windowId };
878
- const tracked = { ...known, ...event, type: undefined };
879
- this.remoteAppWindows.set(event.windowId, tracked);
880
-
881
- this.dispatchEvent(new CustomEvent('remoteappwindow', {
882
- detail: { ...event, window: tracked, count: this.remoteAppWindows.size },
883
- }));
884
-
885
- // The host may place a new window anywhere, and a user-driven maximize
886
- // sizes it to the work area rather than to the browser. Either way the
887
- // pending fit is what the browser window currently is, so re-apply it.
888
- if (this.remoteAppFit && this.#isFittedWindow(tracked) && !this.#matchesFit(tracked)) {
889
- this.#scheduleRemoteAppFit();
890
- }
891
- }
892
-
893
- this.#publishRemoteAppViewport();
894
-
895
- if (!this.remoteAppHadWindow || this.remoteAppWindows.size > 0 || this.remoteAppCloseTimer) return;
896
-
897
- // Chrome and Electron apps can replace a splash/initial window during
898
- // startup. Give a replacement window a short chance to arrive, then end
899
- // the otherwise blank RDP connection when the final window stays gone.
900
- this.remoteAppCloseTimer = setTimeout(() => {
901
- this.remoteAppCloseTimer = null;
902
- if (this.state !== STATE.CONNECTED || this.remoteAppWindows.size > 0) return;
903
- console.info('[RDP] Last RemoteApp window closed; ending the session');
904
- this.dispatchEvent(new CustomEvent('remoteappclosed', {
905
- detail: { application: this.options.remoteApp },
906
- }));
907
- }, REMOTE_APP_CLOSE_GRACE_MS);
908
- }
909
-
910
- /**
911
- * The window the session exists to show: the largest top-level application
912
- * window. Menus, tooltips and drag images are RAIL windows too, and fitting
913
- * one of those to the browser instead of the application would be worse than
914
- * doing nothing.
915
- */
916
- get remoteAppPrimaryWindow() {
917
- let best = null;
918
- let bestArea = -1;
919
- for (const candidate of this.remoteAppWindows.values()) {
920
- if (!isPrimaryWindow(candidate)) continue;
921
- if (candidate.showState === SHOW_STATE.HIDDEN) continue;
922
- const area = (candidate.width || 0) * (candidate.height || 0);
923
- if (area > bestArea) {
924
- best = candidate;
925
- bestArea = area;
926
- }
927
- }
928
- return best;
929
- }
930
-
931
- #isFittedWindow(candidate) {
932
- const target = this.remoteAppPrimaryWindow;
933
- return Boolean(target) && target.windowId === candidate.windowId;
934
- }
935
-
936
- /** The fit always asks for the desktop's top-left corner. */
937
- #matchesFit(candidate) {
938
- const fit = this.remoteAppFit;
939
- if (!fit) return true;
940
- return Math.abs(candidate.x || 0) <= FIT_TOLERANCE_PIXELS &&
941
- Math.abs(candidate.y || 0) <= FIT_TOLERANCE_PIXELS &&
942
- Math.abs((candidate.width || 0) - fit.width) <= FIT_TOLERANCE_PIXELS &&
943
- Math.abs((candidate.height || 0) - fit.height) <= FIT_TOLERANCE_PIXELS;
944
- }
945
-
946
- #scheduleRemoteAppFit() {
947
- if (this.remoteAppFitTimer) return;
948
- this.remoteAppFitTimer = setTimeout(() => {
949
- this.remoteAppFitTimer = null;
950
- this.#applyRemoteAppFit();
951
- }, REMOTE_APP_FIT_DELAY_MS);
952
- }
953
-
954
- #applyRemoteAppFit() {
955
- const fit = this.remoteAppFit;
956
- if (!fit || !this.rail || this.state !== STATE.CONNECTED) return;
957
-
958
- // Maximize, whether the user asks for it or the application does at
959
- // startup, resizes into the work area, so it has to follow the browser
960
- // window even when there is no window to move yet.
961
- this.rail.setWorkArea(fit.width, fit.height);
962
-
963
- const target = this.remoteAppPrimaryWindow;
964
- if (!target || target.showState === SHOW_STATE.MINIMIZED) return;
965
-
966
- // A host that will not honour the move must not be asked forever: every
967
- // later window order would otherwise start the exchange again. A move that
968
- // did change the window is not counted, because a host that is responding
969
- // -- an application enforcing a minimum size, a restore still settling --
970
- // deserves the next attempt.
971
- const geometry = `${target.width || 0}x${target.height || 0}+${target.x || 0}+${target.y || 0}`;
972
- if (geometry !== fit.lastGeometry) fit.attempts = 0;
973
- fit.lastGeometry = geometry;
974
- if (fit.attempts >= REMOTE_APP_FIT_ATTEMPTS || fit.moves >= REMOTE_APP_FIT_MOVES) return;
975
- fit.attempts += 1;
976
- fit.moves += 1;
977
-
978
- if (target.showState === SHOW_STATE.MAXIMIZED) {
979
- // Windows ignores a move applied to a maximized window. Restoring it
980
- // first produces a window order, which schedules this fit again.
981
- console.info('[RDP] Restoring the maximized RemoteApp window before fitting it');
982
- this.rail.sysCommand(target.windowId, SYS_COMMAND.RESTORE);
983
- return;
984
- }
985
-
986
- console.info('[RDP] Fitting the RemoteApp window to the browser', {
987
- windowId: target.windowId,
988
- from: `${target.width || 0}x${target.height || 0}+${target.x || 0}+${target.y || 0}`,
989
- to: `${fit.width}x${fit.height}+0+0`,
990
- attempt: fit.attempts,
991
- });
992
- this.rail.activate(target.windowId);
993
- this.rail.moveWindow(target.windowId, {
994
- left: 0,
995
- top: 0,
996
- right: fit.width,
997
- bottom: fit.height,
998
- });
999
- }
1000
-
1001
- /**
1002
- * Show only the primary window's own rectangle. RemoteApp paints nothing
1003
- * outside its windows, so without this the canvas is the whole negotiated
1004
- * desktop with the application somewhere inside it.
1005
- */
1006
- #publishRemoteAppViewport() {
1007
- const target = this.remoteAppPrimaryWindow;
1008
- if (!target || !target.width || !target.height) return;
1009
- if (target.showState === SHOW_STATE.MINIMIZED || target.showState === SHOW_STATE.HIDDEN) return;
1010
-
1011
- const x = Math.max(0, Math.min(this.desktopWidth - 1, target.x || 0));
1012
- const y = Math.max(0, Math.min(this.desktopHeight - 1, target.y || 0));
1013
- const viewport = {
1014
- x,
1015
- y,
1016
- width: Math.max(1, Math.min(this.desktopWidth - x, target.width)),
1017
- height: Math.max(1, Math.min(this.desktopHeight - y, target.height)),
1018
- };
1019
-
1020
- const previous = this.remoteAppViewport;
1021
- if (previous && previous.x === viewport.x && previous.y === viewport.y &&
1022
- previous.width === viewport.width && previous.height === viewport.height) {
1023
- return;
1024
- }
1025
- this.remoteAppViewport = viewport;
1026
- this.dispatchEvent(new CustomEvent('remoteappviewport', { detail: viewport }));
1027
- }
1028
-
1029
- /**
1030
- * Size the remote application's window to the room the browser has for it.
1031
- *
1032
- * The RDP desktop is fixed for the life of a connection -- resizing it needs
1033
- * the dynamic virtual channel this client does not speak -- so the desktop is
1034
- * negotiated large at connect time and the application window is moved and
1035
- * resized inside it instead. The canvas then shows only that window.
1036
- */
1037
- fitRemoteApp(width: number, height: number) {
1038
- if (!this.options.remoteApp?.program) return false;
1039
- if (!Number.isFinite(width) || !Number.isFinite(height)) return false;
1040
-
1041
- const fit = {
1042
- width: Math.max(MIN_REMOTE_APP_SIZE, Math.min(this.desktopWidth, Math.floor(width))),
1043
- height: Math.max(MIN_REMOTE_APP_SIZE, Math.min(this.desktopHeight, Math.floor(height))),
1044
- attempts: 0,
1045
- moves: 0,
1046
- lastGeometry: '',
1047
- };
1048
- this.remoteAppFit = fit;
1049
- this.#scheduleRemoteAppFit();
1050
- return true;
1051
- }
1052
-
1053
- #markConnected() {
1054
- if (this.state === STATE.CONNECTED) return;
1055
- this.state = STATE.CONNECTED;
1056
-
1057
- console.info('[RDP] Session established', {
1058
- desktop: `${this.desktopWidth}x${this.desktopHeight}`,
1059
- encryption: this.security.encryptionEnabled ? `RC4 ${this.security.keys.keyLength * 8}-bit` : 'TLS transport',
1060
- });
1061
-
1062
- this.dispatchEvent(new CustomEvent('ready', {
1063
- detail: { width: this.desktopWidth, height: this.desktopHeight },
1064
- }));
1065
-
1066
- // Nothing has been painted yet: the host only sends what changed, and the
1067
- // session's first frame is a change the client has to ask for. Enabling
1068
- // output first covers a host that starts the share suppressed.
1069
- this.setOutputEnabled(true);
1070
- this.refresh();
1071
- }
1072
-
1073
- // --- fast-path output (defensive) ----------------------------------------
1074
-
1075
- /**
1076
- * This client does not advertise fast-path output, so a well-behaved host
1077
- * never uses it. Some do anyway, and dropping those frames would leave a
1078
- * blank screen with no explanation.
1079
- */
1080
- #handleFastPath(frame: Uint8Array) {
1081
- const encryptionFlags = (frame[0] >> 6) & 0x03;
1082
- const lengthBytes = frame[1] & 0x80 ? 2 : 1;
1083
-
1084
- let payload = frame.subarray(1 + lengthBytes);
1085
- if (encryptionFlags & 0x02) payload = this.security.decryptFastPath(payload);
1086
-
1087
- const reader = new Reader(payload);
1088
- while (reader.remaining >= 3) {
1089
- const header = reader.u8();
1090
- const updateCode = header & 0x0f;
1091
- const fragmentation = (header >> 4) & 0x03;
1092
- const compression = (header >> 6) & 0x03;
1093
-
1094
- if (compression & 0x02) reader.u8(); // compressionFlags
1095
- const size = reader.u16le();
1096
- let updateData = reader.raw(Math.min(size, reader.remaining));
1097
-
1098
- // 0 single, 1 last, 2 first, 3 next
1099
- if (fragmentation !== 0) {
1100
- if (fragmentation === 2) this.fastPathFragments = [];
1101
- this.fastPathFragments.push(updateData.slice());
1102
- if (fragmentation !== 1) continue;
1103
- updateData = concatBytes(...this.fastPathFragments);
1104
- this.fastPathFragments = [];
1105
- }
1106
-
1107
- this.#handleFastPathUpdate(updateCode, updateData);
1108
- }
1109
- }
1110
-
1111
- #handleFastPathUpdate(updateCode, updateData) {
1112
- const reader = new Reader(updateData);
1113
-
1114
- switch (updateCode) {
1115
- case 1: { // FASTPATH_UPDATETYPE_BITMAP
1116
- reader.u16le(); // updateType, repeated inside the update
1117
- const rects = parseBitmapUpdate(reader);
1118
- if (rects.length > 0) {
1119
- this.dispatchEvent(new CustomEvent('bitmap', { detail: { rects, palette: this.palette } }));
1120
- }
1121
- return;
1122
- }
1123
- case 2: // FASTPATH_UPDATETYPE_PALETTE
1124
- this.palette = parsePaletteUpdate(reader);
1125
- this.dispatchEvent(new CustomEvent('palette', { detail: this.palette }));
1126
- return;
1127
- case 5: // FASTPATH_UPDATETYPE_PTR_NULL
1128
- this.dispatchEvent(new CustomEvent('pointer', { detail: { kind: 'system', hidden: true } }));
1129
- return;
1130
- case 6: // FASTPATH_UPDATETYPE_PTR_DEFAULT
1131
- this.dispatchEvent(new CustomEvent('pointer', { detail: { kind: 'system', hidden: false } }));
1132
- return;
1133
- case 8: // FASTPATH_UPDATETYPE_PTR_POSITION
1134
- this.dispatchEvent(new CustomEvent('pointer', {
1135
- detail: { kind: 'position', x: reader.u16le(), y: reader.u16le() },
1136
- }));
1137
- return;
1138
- case 9: // FASTPATH_UPDATETYPE_COLOR
1139
- case 0x0a: // FASTPATH_UPDATETYPE_CACHED
1140
- case 0x0b: { // FASTPATH_UPDATETYPE_POINTER
1141
- // The slow-path parser expects the message type these updates imply.
1142
- const messageType = { 9: 0x06, 0x0a: 0x07, 0x0b: 0x08 }[updateCode];
1143
- const header = new Uint8Array([messageType, 0x00, 0x00, 0x00]);
1144
- this.dispatchEvent(new CustomEvent('pointer', {
1145
- detail: parsePointerUpdate(new Reader(concatBytes(header, updateData))),
1146
- }));
1147
- return;
1148
- }
1149
- default:
1150
- return;
1151
- }
1152
- }
1153
-
1154
- // --- virtual channels -----------------------------------------------------
1155
-
1156
- #handleChannelData(name: string, bytes: Uint8Array) {
1157
- let reassembler = this.channelReassemblers.get(name);
1158
- if (!reassembler) {
1159
- reassembler = new ChannelReassembler();
1160
- this.channelReassemblers.set(name, reassembler);
1161
- }
1162
-
1163
- const message = reassembler.push(bytes);
1164
- if (!message) return;
1165
-
1166
- if (name === CLIPBOARD_CHANNEL.name) {
1167
- this.#clipboardChannel().receive(message);
1168
- return;
1169
- }
1170
-
1171
- if (name === RAIL_CHANNEL.name) {
1172
- this.#railChannel().receive(message);
1173
- }
1174
- }
1175
-
1176
- #sendVirtualChannel(name: string, bytes: Uint8Array) {
1177
- const channelId = [...this.channelNamesById].find(([, channelName]) => channelName === name)?.[0];
1178
- if (channelId === undefined) return false;
1179
-
1180
- for (const chunk of chunkChannelData(bytes, CHANNEL_CHUNK_LENGTH, CHANNEL_FLAG_SHOW_PROTOCOL)) {
1181
- this.#send(sendDataRequest(this.userId, channelId, this.security.wrap(chunk)));
1182
- }
1183
- return true;
1184
- }
1185
-
1186
- #railChannel() {
1187
- if (this.rail) return this.rail;
1188
-
1189
- this.rail = new RailChannel({
1190
- application: this.options.remoteApp,
1191
- // The work area is what the host maximizes into, so it is the browser's
1192
- // room for the window rather than the whole negotiated desktop. A fit
1193
- // requested before the channel opened is already known here.
1194
- width: this.remoteAppFit?.width || this.desktopWidth,
1195
- height: this.remoteAppFit?.height || this.desktopHeight,
1196
- send: (bytes: Uint8Array) => this.#sendVirtualChannel(RAIL_CHANNEL.name, bytes),
1197
- onReady: ({ buildNumber, extended, flags }) => {
1198
- console.info('[RDP] RemoteApp channel ready', { buildNumber, extended: Boolean(extended), flags: flags || 0 });
1199
- this.dispatchEvent(new CustomEvent('remoteappready', {
1200
- detail: { application: this.options.remoteApp },
1201
- }));
1202
- },
1203
- onResult: (result) => {
1204
- const detail = { ...result, application: this.options.remoteApp };
1205
- this.dispatchEvent(new CustomEvent('remoteappresult', { detail }));
1206
- if (!result.ok) this.#fail(new Error(result.message));
1207
- else console.info('[RDP] Remote application launched', detail);
1208
- },
1209
- });
1210
-
1211
- return this.rail;
1212
- }
1213
-
1214
- /** Created on first use, so a host without the channel costs nothing. */
1215
- #clipboardChannel() {
1216
- if (this.clipboard) return this.clipboard;
1217
-
1218
- this.clipboard = new ClipboardChannel({
1219
- send: (bytes: Uint8Array) => this.#sendVirtualChannel(CLIPBOARD_CHANNEL.name, bytes),
1220
- log: (step: number, detail) => console.info(`[RDP] ${step}`, detail),
1221
- });
1222
-
1223
- this.clipboard.addEventListener('text', (event) => {
1224
- this.dispatchEvent(new CustomEvent('clipboard', { detail: event.detail }));
1225
- });
1226
-
1227
- return this.clipboard;
1228
- }
1229
-
1230
- /**
1231
- * Hand the host our clipboard text. Resolves once the host has acknowledged
1232
- * owning it, which is what makes it safe to send a paste keystroke next.
1233
- */
1234
- setClipboardText(text: string) {
1235
- if (!this.connected || !this.channelNamesById.size) return Promise.resolve(false);
1236
- return this.#clipboardChannel().setLocalText(text || '');
1237
- }
1238
-
1239
- get clipboardShared() {
1240
- return Boolean(this.clipboard?.ready);
1241
- }
1242
-
1243
- /**
1244
- * Deliver text into the session the way a person would: put it on the host's
1245
- * clipboard, wait for the host to acknowledge owning it, then press the paste
1246
- * shortcut. Waiting is what stops the keystroke from pasting the previous
1247
- * contents. A host with no clipboard channel gets the text typed instead.
1248
- */
1249
- pasteToHost(text: string) {
1250
- if (!text || !this.connected) return Promise.resolve(false);
1251
-
1252
- const pressPaste = () => {
1253
- if (!this.connected) return;
1254
- this.sendKeyByCode('ControlLeft', true);
1255
- this.sendKeyByCode('KeyV', true);
1256
- this.sendKeyByCode('KeyV', false);
1257
- this.sendKeyByCode('ControlLeft', false);
1258
- };
1259
-
1260
- return this.setClipboardText(text).then((acknowledged: boolean) => {
1261
- if (acknowledged) pressPaste();
1262
- else this.sendUnicode(text);
1263
- return true;
1264
- });
1265
- }
1266
-
1267
- // --- outbound share PDUs --------------------------------------------------
1268
-
1269
- #sendShareControl(pduType, payload: Uint8Array) {
1270
- this.#sendOnIoChannel(this.security.wrap(shareControlPdu(pduType, this.userId, payload)));
1271
- }
1272
-
1273
- #sendShareData(pduType2, payload: Uint8Array) {
1274
- this.#sendOnIoChannel(
1275
- this.security.wrap(shareDataPdu(this.shareId, this.userId, pduType2, payload))
1276
- );
1277
- }
1278
-
1279
- // --- input ----------------------------------------------------------------
1280
-
1281
- get connected() {
1282
- return this.state === STATE.CONNECTED;
1283
- }
1284
-
1285
- #sendInput(events) {
1286
- if (!this.connected || events.length === 0) return;
1287
- this.#sendShareData(PDU_TYPE2.INPUT, inputEventsPdu(events));
1288
- }
1289
-
1290
- sendKeyByCode(code: number, pressed: boolean) {
1291
- const mapped = lookupScancode(code);
1292
- if (!mapped) return false;
1293
- this.#sendInput([scancodeEvent(mapped.scancode, pressed, mapped.extended)]);
1294
- return true;
1295
- }
1296
-
1297
- sendUnicode(text: string) {
1298
- const events = [];
1299
- for (const character of text) {
1300
- for (let i = 0; i < character.length; i++) {
1301
- const unit = character.charCodeAt(i);
1302
- events.push(unicodeEvent(unit, true));
1303
- events.push(unicodeEvent(unit, false));
1304
- }
1305
- }
1306
- // Long pastes are split so no single PDU grows past the MCS limit.
1307
- for (let i = 0; i < events.length; i += 40) this.#sendInput(events.slice(i, i + 40));
1308
- }
1309
-
1310
- sendCtrlAltDel() {
1311
- const events = CTRL_ALT_DEL.map(({ code, pressed }) => {
1312
- const mapped = lookupScancode(code);
1313
- return scancodeEvent(mapped.scancode, pressed, mapped.extended);
1314
- });
1315
- this.#sendInput(events);
1316
- }
1317
-
1318
- sendMouseMove(x: number, y: number) {
1319
- this.#sendInput([mouseEvent(POINTER_FLAG.MOVE, x, y)]);
1320
- }
1321
-
1322
- sendMouseButton(button: number, pressed: boolean, x: number, y: number) {
1323
- const buttonFlag = {
1324
- 0: POINTER_FLAG.BUTTON1,
1325
- 1: POINTER_FLAG.BUTTON3, // middle
1326
- 2: POINTER_FLAG.BUTTON2, // right
1327
- }[button];
1328
-
1329
- if (buttonFlag === undefined) {
1330
- // Buttons 4 and 5 (back / forward) travel as extended mouse events.
1331
- const extendedFlag = button === 3 ? 0x0001 : 0x0002;
1332
- this.#sendInput([extendedMouseEvent(extendedFlag | (pressed ? POINTER_FLAG.DOWN : 0), x, y)]);
1333
- return;
1334
- }
1335
-
1336
- this.#sendInput([mouseEvent(buttonFlag | (pressed ? POINTER_FLAG.DOWN : 0), x, y)]);
1337
- }
1338
-
1339
- sendWheel(delta: number, x: number, y: number, horizontal = false) {
1340
- const flags = pointerWheelFlags(delta, horizontal);
1341
- if (!flags) return;
1342
- this.#sendInput([mouseEvent(flags, x, y)]);
1343
- }
1344
-
1345
- /** Tell the host which lock keys are already on, so they do not drift. */
1346
- sendKeyboardSync(toggleFlags: number) {
1347
- // Remembered so the idle keepalive restates the real state rather than
1348
- // quietly telling the host every lock key is off.
1349
- this.toggleFlags = toggleFlags >>> 0;
1350
- this.#sendInput([syncEvent(this.toggleFlags)]);
1351
- }
1352
-
1353
- /** Ask the host to resend a region, used after the canvas is re-created. */
1354
- refresh(rect) {
1355
- if (!this.connected || !this.refreshRectSupported) return;
1356
- this.#sendShareData(PDU_TYPE2.REFRESH_RECT, refreshRectPdu([
1357
- rect || { left: 0, top: 0, right: this.desktopWidth - 1, bottom: this.desktopHeight - 1 },
1358
- ]));
1359
- }
1360
-
1361
- /** Pause or resume host output, e.g. while the tab is hidden. */
1362
- setOutputEnabled(enabled: boolean) {
1363
- if (!this.connected) return;
1364
- this.#sendShareData(PDU_TYPE2.SUPPRESS_OUTPUT, suppressOutputPdu(enabled, {
1365
- left: 0,
1366
- top: 0,
1367
- right: this.desktopWidth - 1,
1368
- bottom: this.desktopHeight - 1,
1369
- }));
1370
- }
1371
- }