ai-remote 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +50 -0
  2. package/dist/index.js +7 -0
  3. package/dist/index.js.map +7 -0
  4. package/dist/protocols/index.js +35 -0
  5. package/dist/protocols/index.js.map +7 -0
  6. package/dist/protocols/rdp/buffer.js +170 -0
  7. package/dist/protocols/rdp/buffer.js.map +7 -0
  8. package/dist/protocols/rdp/caps.js +260 -0
  9. package/dist/protocols/rdp/caps.js.map +7 -0
  10. package/dist/protocols/rdp/cert.js +135 -0
  11. package/dist/protocols/rdp/cert.js.map +7 -0
  12. package/dist/protocols/rdp/client.js +1068 -0
  13. package/dist/protocols/rdp/client.js.map +7 -0
  14. package/dist/protocols/rdp/cliprdr.js +240 -0
  15. package/dist/protocols/rdp/cliprdr.js.map +7 -0
  16. package/dist/protocols/rdp/credssp.js +254 -0
  17. package/dist/protocols/rdp/credssp.js.map +7 -0
  18. package/dist/protocols/rdp/crypto.js +369 -0
  19. package/dist/protocols/rdp/crypto.js.map +7 -0
  20. package/dist/protocols/rdp/display.js +589 -0
  21. package/dist/protocols/rdp/display.js.map +7 -0
  22. package/dist/protocols/rdp/gcc.js +132 -0
  23. package/dist/protocols/rdp/gcc.js.map +7 -0
  24. package/dist/protocols/rdp/keymap.js +132 -0
  25. package/dist/protocols/rdp/keymap.js.map +7 -0
  26. package/dist/protocols/rdp/mcs.js +223 -0
  27. package/dist/protocols/rdp/mcs.js.map +7 -0
  28. package/dist/protocols/rdp/ntlm.js +305 -0
  29. package/dist/protocols/rdp/ntlm.js.map +7 -0
  30. package/dist/protocols/rdp/pdu.js +444 -0
  31. package/dist/protocols/rdp/pdu.js.map +7 -0
  32. package/dist/protocols/rdp/rail.js +383 -0
  33. package/dist/protocols/rdp/rail.js.map +7 -0
  34. package/dist/protocols/rdp/rle.js +276 -0
  35. package/dist/protocols/rdp/rle.js.map +7 -0
  36. package/dist/protocols/rdp/sec.js +156 -0
  37. package/dist/protocols/rdp/sec.js.map +7 -0
  38. package/dist/protocols/rdp/session.js +307 -0
  39. package/dist/protocols/rdp/session.js.map +7 -0
  40. package/dist/protocols/rdp/tls.js +354 -0
  41. package/dist/protocols/rdp/tls.js.map +7 -0
  42. package/dist/protocols/rdp/vchannel.js +59 -0
  43. package/dist/protocols/rdp/vchannel.js.map +7 -0
  44. package/dist/protocols/rdp/x224.js +110 -0
  45. package/dist/protocols/rdp/x224.js.map +7 -0
  46. package/dist/protocols/ssh/kex.js +177 -0
  47. package/dist/protocols/ssh/kex.js.map +7 -0
  48. package/dist/protocols/ssh/messages.js +55 -0
  49. package/dist/protocols/ssh/messages.js.map +7 -0
  50. package/dist/protocols/ssh/session.js +608 -0
  51. package/dist/protocols/ssh/session.js.map +7 -0
  52. package/dist/protocols/ssh/terminal.js +191 -0
  53. package/dist/protocols/ssh/terminal.js.map +7 -0
  54. package/dist/protocols/ssh/transport.js +611 -0
  55. package/dist/protocols/ssh/transport.js.map +7 -0
  56. package/dist/protocols/ssh/wire.js +189 -0
  57. package/dist/protocols/ssh/wire.js.map +7 -0
  58. package/dist/protocols/types.js +1 -0
  59. package/dist/protocols/types.js.map +7 -0
  60. package/dist/protocols/vnc/input.js +16 -0
  61. package/dist/protocols/vnc/input.js.map +7 -0
  62. package/dist/protocols/vnc/session.js +259 -0
  63. package/dist/protocols/vnc/session.js.map +7 -0
  64. package/dist/shared/connection.js +114 -0
  65. package/dist/shared/connection.js.map +7 -0
  66. package/dist/shared/device.js +1 -0
  67. package/dist/shared/device.js.map +7 -0
  68. package/dist/shared/hosts.js +133 -0
  69. package/dist/shared/hosts.js.map +7 -0
  70. package/dist/shared/icons.js +42 -0
  71. package/dist/shared/icons.js.map +7 -0
  72. package/dist/shared/protocol.js +40 -0
  73. package/dist/shared/protocol.js.map +7 -0
  74. package/dist/shared/signals.js +42 -0
  75. package/dist/shared/signals.js.map +7 -0
  76. package/package.json +82 -0
  77. package/src/index.ts +14 -0
  78. package/src/protocols/index.ts +72 -0
  79. package/src/protocols/rdp/buffer.ts +204 -0
  80. package/src/protocols/rdp/caps.ts +304 -0
  81. package/src/protocols/rdp/cert.ts +190 -0
  82. package/src/protocols/rdp/client.ts +1363 -0
  83. package/src/protocols/rdp/cliprdr.ts +344 -0
  84. package/src/protocols/rdp/credssp.ts +319 -0
  85. package/src/protocols/rdp/crypto.ts +416 -0
  86. package/src/protocols/rdp/display.ts +750 -0
  87. package/src/protocols/rdp/gcc.ts +162 -0
  88. package/src/protocols/rdp/keymap.ts +68 -0
  89. package/src/protocols/rdp/mcs.ts +283 -0
  90. package/src/protocols/rdp/ntlm.ts +380 -0
  91. package/src/protocols/rdp/pdu.ts +497 -0
  92. package/src/protocols/rdp/rail.ts +480 -0
  93. package/src/protocols/rdp/rle.ts +343 -0
  94. package/src/protocols/rdp/sec.ts +184 -0
  95. package/src/protocols/rdp/session.ts +393 -0
  96. package/src/protocols/rdp/tls.ts +452 -0
  97. package/src/protocols/rdp/vchannel.ts +89 -0
  98. package/src/protocols/rdp/x224.ts +139 -0
  99. package/src/protocols/ssh/kex.ts +227 -0
  100. package/src/protocols/ssh/messages.ts +54 -0
  101. package/src/protocols/ssh/session.ts +878 -0
  102. package/src/protocols/ssh/terminal.ts +242 -0
  103. package/src/protocols/ssh/transport.ts +732 -0
  104. package/src/protocols/ssh/wire.ts +220 -0
  105. package/src/protocols/types.ts +146 -0
  106. package/src/protocols/vnc/input.ts +13 -0
  107. package/src/protocols/vnc/novnc.d.ts +75 -0
  108. package/src/protocols/vnc/session.ts +367 -0
  109. package/src/shared/connection.ts +206 -0
  110. package/src/shared/device.ts +28 -0
  111. package/src/shared/hosts.ts +240 -0
  112. package/src/shared/icons.ts +49 -0
  113. package/src/shared/protocol.ts +55 -0
  114. package/src/shared/signals.ts +67 -0
@@ -0,0 +1,1068 @@
1
+ import { Reader, concatBytes } from "./buffer";
2
+ import { rsaEncrypt } from "./crypto";
3
+ import { NEG_FAILURE, NEG_PROTOCOL, connectionRequest, parseConnectionConfirm, splitFrames } from "./x224";
4
+ import { Tls12Client } from "./tls";
5
+ import { CredSspClient } from "./credssp";
6
+ import {
7
+ MCS_BASE_CHANNEL_ID,
8
+ attachUserRequest,
9
+ channelJoinRequest,
10
+ connectInitial,
11
+ erectDomainRequest,
12
+ parseAttachUserConfirm,
13
+ parseChannelJoinConfirm,
14
+ parseConnectResponse,
15
+ parseDomainPdu,
16
+ sendDataRequest
17
+ } from "./mcs";
18
+ import { ENCRYPTION_LEVEL, clientData, parseServerData } from "./gcc";
19
+ import { ClipboardChannel } from "./cliprdr";
20
+ import { RailChannel, SHOW_STATE, SYS_COMMAND, isPrimaryWindow, parseRemoteAppWindowOrders } from "./rail";
21
+ import {
22
+ CHANNEL_FLAG_SHOW_PROTOCOL,
23
+ CHANNEL_OPTION,
24
+ ChannelReassembler,
25
+ CHANNEL_CHUNK_LENGTH,
26
+ chunkChannelData
27
+ } from "./vchannel";
28
+ import { SEC_FLAG, SecurityLayer } from "./sec";
29
+ import { buildClientCapabilities, parseServerCapabilities } from "./caps";
30
+ import {
31
+ CONTROL_ACTION,
32
+ PDU_TYPE,
33
+ PDU_TYPE2,
34
+ POINTER_FLAG,
35
+ UPDATE_TYPE,
36
+ clientInfoPdu,
37
+ confirmActivePdu,
38
+ controlPdu,
39
+ errorInfoMessage,
40
+ extendedMouseEvent,
41
+ fontListPdu,
42
+ inputEventsPdu,
43
+ mouseEvent,
44
+ parseBitmapUpdate,
45
+ parseDemandActive,
46
+ parsePaletteUpdate,
47
+ parsePointerUpdate,
48
+ pointerWheelFlags,
49
+ parseShareControlHeader,
50
+ parseShareDataHeader,
51
+ refreshRectPdu,
52
+ scancodeEvent,
53
+ shareControlPdu,
54
+ shareDataPdu,
55
+ suppressOutputPdu,
56
+ syncEvent,
57
+ synchronizePdu,
58
+ unicodeEvent
59
+ } from "./pdu";
60
+ import { CTRL_ALT_DEL, lookupScancode } from "./keymap";
61
+ import {
62
+ GATEWAY_KEEPALIVE_SIGNAL,
63
+ GATEWAY_READY_SIGNAL,
64
+ KEEPALIVE_INTERVAL_MS
65
+ } from "../../shared/signals";
66
+ const STATE = {
67
+ IDLE: "idle",
68
+ NEGOTIATING: "negotiating",
69
+ TLS_HANDSHAKE: "tls-handshake",
70
+ CREDSSP: "credssp",
71
+ MCS_CONNECT: "mcs-connect",
72
+ ATTACH_USER: "attach-user",
73
+ JOIN_CHANNELS: "join-channels",
74
+ SECURE: "secure-exchange",
75
+ LICENSING: "licensing",
76
+ CAPABILITIES: "capabilities",
77
+ CONNECTED: "connected",
78
+ CLOSED: "closed"
79
+ };
80
+ const LICENSE_ERROR_ALERT = 255;
81
+ const CLIPBOARD_CHANNEL = {
82
+ name: "cliprdr",
83
+ options: CHANNEL_OPTION.INITIALIZED | CHANNEL_OPTION.ENCRYPT_RDP | CHANNEL_OPTION.SHOW_PROTOCOL
84
+ };
85
+ const RAIL_CHANNEL = {
86
+ name: "rail",
87
+ options: CHANNEL_OPTION.INITIALIZED | CHANNEL_OPTION.ENCRYPT_RDP | CHANNEL_OPTION.SHOW_PROTOCOL
88
+ };
89
+ const PERF_DISABLE_WALLPAPER = 1;
90
+ const PERF_DISABLE_FULLWINDOWDRAG = 2;
91
+ const PERF_DISABLE_MENUANIMATIONS = 4;
92
+ const REMOTE_APP_CLOSE_GRACE_MS = 1200;
93
+ const REMOTE_APP_FIT_DELAY_MS = 120;
94
+ const FIT_TOLERANCE_PIXELS = 2;
95
+ const REMOTE_APP_FIT_ATTEMPTS = 4;
96
+ const REMOTE_APP_FIT_MOVES = 12;
97
+ const MIN_REMOTE_APP_SIZE = 320;
98
+ class RdpClient extends EventTarget {
99
+ constructor(url, options = {}) {
100
+ super();
101
+ this.url = url;
102
+ this.options = {
103
+ width: 1024,
104
+ height: 768,
105
+ username: "",
106
+ password: "",
107
+ domain: "",
108
+ keyboardLayout: 1033,
109
+ clientName: "cf-gateway",
110
+ security: "auto",
111
+ clipboard: true,
112
+ remoteApp: null,
113
+ performance: PERF_DISABLE_WALLPAPER | PERF_DISABLE_FULLWINDOWDRAG | PERF_DISABLE_MENUANIMATIONS,
114
+ ...options
115
+ };
116
+ this.state = STATE.IDLE;
117
+ this.legacyRetryUsed = false;
118
+ this.desktopWidth = this.options.width;
119
+ this.desktopHeight = this.options.height;
120
+ this.palette = null;
121
+ this.userId = MCS_BASE_CHANNEL_ID;
122
+ this.ioChannelId = 1003;
123
+ this.serverChannelId = 1002;
124
+ this.shareId = 0;
125
+ this.channelsToJoin = [];
126
+ this.requestedChannels = [];
127
+ this.channelNamesById = /* @__PURE__ */ new Map();
128
+ this.channelReassemblers = /* @__PURE__ */ new Map();
129
+ this.clipboard = null;
130
+ this.rail = null;
131
+ this.remoteAppWindows = /* @__PURE__ */ new Map();
132
+ this.remoteAppHadWindow = false;
133
+ this.remoteAppCloseTimer = null;
134
+ this.remoteAppFit = null;
135
+ this.remoteAppFitTimer = null;
136
+ this.clientRandom = null;
137
+ this.serverData = null;
138
+ this.fastPathFragments = [];
139
+ this.receiveBuffer = new Uint8Array(0);
140
+ this.keepaliveTimer = null;
141
+ this.closeReported = false;
142
+ this.tls = null;
143
+ this.credssp = null;
144
+ this.inboundQueue = Promise.resolve();
145
+ this.requestedProtocol = {
146
+ auto: NEG_PROTOCOL.SSL | NEG_PROTOCOL.HYBRID,
147
+ nla: NEG_PROTOCOL.SSL | NEG_PROTOCOL.HYBRID,
148
+ tls: NEG_PROTOCOL.SSL,
149
+ rdp: NEG_PROTOCOL.RDP
150
+ }[this.options.security] ?? NEG_PROTOCOL.SSL | NEG_PROTOCOL.HYBRID;
151
+ this.#openTransport();
152
+ }
153
+ /**
154
+ * Open the transport and reset everything that belongs to one connection.
155
+ * Falling back to another security layer needs a second socket, because a
156
+ * host that refuses the negotiation closes the first.
157
+ */
158
+ #openTransport() {
159
+ this.receiveBuffer = new Uint8Array(0);
160
+ this.inboundQueue = Promise.resolve();
161
+ this.tls = null;
162
+ this.credssp = null;
163
+ this.security = new SecurityLayer();
164
+ this.replacingTransport = false;
165
+ this.channelNamesById = /* @__PURE__ */ new Map();
166
+ this.channelReassemblers = /* @__PURE__ */ new Map();
167
+ this.clipboard = null;
168
+ this.rail = null;
169
+ this.remoteAppWindows.clear();
170
+ this.remoteAppHadWindow = false;
171
+ clearTimeout(this.remoteAppCloseTimer);
172
+ clearTimeout(this.remoteAppFitTimer);
173
+ this.remoteAppFitTimer = null;
174
+ this.remoteAppCloseTimer = null;
175
+ this.socket = new WebSocket(this.url);
176
+ this.socket.binaryType = "arraybuffer";
177
+ this.socket.addEventListener("open", () => this.#onOpen());
178
+ this.socket.addEventListener("message", (event) => this.#onMessage(event));
179
+ this.socket.addEventListener("close", (event) => this.#onSocketClose(event));
180
+ this.socket.addEventListener("error", () => {
181
+ console.warn("[RDP] WebSocket transport error", { target: this.url });
182
+ });
183
+ }
184
+ /** Reconnect asking for the only security layer this host will accept. */
185
+ #retryWithSecurityLayer(security) {
186
+ console.info("[RDP] Reconnecting with a different security layer", { security });
187
+ this.legacyRetryUsed = true;
188
+ this.options.security = security;
189
+ this.requestedProtocol = security === "rdp" ? NEG_PROTOCOL.RDP : NEG_PROTOCOL.SSL;
190
+ this.state = STATE.IDLE;
191
+ this.replacingTransport = true;
192
+ clearInterval(this.keepaliveTimer);
193
+ try {
194
+ this.socket.close(1e3, "Retrying with another security layer");
195
+ } catch {
196
+ }
197
+ this.#openTransport();
198
+ }
199
+ // --- transport ------------------------------------------------------------
200
+ #onOpen() {
201
+ this.socket.send(GATEWAY_READY_SIGNAL);
202
+ this.state = STATE.NEGOTIATING;
203
+ this.#send(connectionRequest(this.requestedProtocol));
204
+ this.keepaliveTimer = setInterval(() => {
205
+ if (this.socket.readyState !== WebSocket.OPEN) return;
206
+ this.socket.send(GATEWAY_KEEPALIVE_SIGNAL);
207
+ if (this.state === STATE.CONNECTED) {
208
+ try {
209
+ this.#sendInput([syncEvent(this.toggleFlags || 0)]);
210
+ } catch {
211
+ }
212
+ }
213
+ }, KEEPALIVE_INTERVAL_MS);
214
+ }
215
+ /** RDP-level send: once a secure channel exists everything rides inside it. */
216
+ #send(bytes) {
217
+ if (this.tls && this.tls.established) {
218
+ this.tls.write(bytes);
219
+ return;
220
+ }
221
+ this.#sendRaw(bytes);
222
+ }
223
+ #sendRaw(bytes) {
224
+ if (this.socket.readyState !== WebSocket.OPEN) return;
225
+ this.socket.send(bytes);
226
+ }
227
+ #onSocketClose(event) {
228
+ clearInterval(this.keepaliveTimer);
229
+ clearTimeout(this.remoteAppCloseTimer);
230
+ clearTimeout(this.remoteAppFitTimer);
231
+ this.remoteAppFitTimer = null;
232
+ if (this.replacingTransport) return;
233
+ if (this.state === STATE.CLOSED && this.closeReported) return;
234
+ const wasConnected = this.state === STATE.CONNECTED;
235
+ this.state = STATE.CLOSED;
236
+ this.closeReported = true;
237
+ this.dispatchEvent(new CustomEvent("close", {
238
+ detail: {
239
+ clean: event.wasClean && wasConnected,
240
+ code: event.code,
241
+ message: event.reason || "",
242
+ phase: wasConnected ? "session" : "handshake"
243
+ }
244
+ }));
245
+ }
246
+ #fail(error) {
247
+ if (this.state === STATE.CLOSED) return;
248
+ const message = error instanceof Error ? error.message : String(error);
249
+ console.error("[RDP] Protocol failure", { state: this.state, message });
250
+ this.dispatchEvent(new CustomEvent("error", { detail: { message, phase: this.state } }));
251
+ this.state = STATE.CLOSED;
252
+ this.closeReported = true;
253
+ clearInterval(this.keepaliveTimer);
254
+ clearTimeout(this.remoteAppCloseTimer);
255
+ clearTimeout(this.remoteAppFitTimer);
256
+ this.remoteAppFitTimer = null;
257
+ try {
258
+ this.socket.close(4e3, message.slice(0, 100));
259
+ } catch {
260
+ }
261
+ this.dispatchEvent(new CustomEvent("close", {
262
+ detail: { clean: false, code: 4e3, message, phase: "protocol" }
263
+ }));
264
+ }
265
+ disconnect() {
266
+ if (this.state === STATE.CLOSED) return;
267
+ this.state = STATE.CLOSED;
268
+ this.closeReported = true;
269
+ clearInterval(this.keepaliveTimer);
270
+ clearTimeout(this.remoteAppCloseTimer);
271
+ clearTimeout(this.remoteAppFitTimer);
272
+ this.remoteAppFitTimer = null;
273
+ try {
274
+ this.socket.close(1e3, "Client disconnected");
275
+ } catch {
276
+ }
277
+ }
278
+ /**
279
+ * Transport bytes are processed strictly in order. Nothing awaited in here
280
+ * may itself wait for a later message, or the pipeline would deadlock -- the
281
+ * TLS and CredSSP startup is therefore detached and driven by callbacks.
282
+ */
283
+ #onMessage(event) {
284
+ if (this.state === STATE.CLOSED) return;
285
+ this.inboundQueue = this.inboundQueue.then(async () => {
286
+ if (this.state === STATE.CLOSED) return;
287
+ const chunk = typeof event.data === "string" ? new TextEncoder().encode(event.data) : new Uint8Array(event.data);
288
+ if (this.tls) await this.tls.receive(chunk);
289
+ else await this.#ingest(chunk);
290
+ }).catch((error) => this.#fail(error));
291
+ }
292
+ /** Plaintext coming out of the secure channel. */
293
+ #onSecureData(bytes) {
294
+ if (this.state === STATE.CREDSSP && this.credssp) {
295
+ this.credssp.receive(bytes);
296
+ return;
297
+ }
298
+ return this.#ingest(bytes);
299
+ }
300
+ async #ingest(chunk) {
301
+ this.receiveBuffer = this.receiveBuffer.length === 0 ? chunk : concatBytes(this.receiveBuffer, chunk);
302
+ const { frames, rest } = splitFrames(this.receiveBuffer);
303
+ this.receiveBuffer = rest.length === this.receiveBuffer.length ? this.receiveBuffer : rest.slice();
304
+ for (const { fastPath, frame } of frames) {
305
+ if (this.state === STATE.CLOSED) return;
306
+ if (fastPath) this.#handleFastPath(frame);
307
+ else await this.#handleX224(frame);
308
+ }
309
+ }
310
+ // --- connection sequence --------------------------------------------------
311
+ async #handleX224(frame) {
312
+ if (this.state === STATE.NEGOTIATING) {
313
+ let selectedProtocol;
314
+ try {
315
+ ({ selectedProtocol } = parseConnectionConfirm(frame));
316
+ } catch (error) {
317
+ if (this.options.security === "auto" && !this.legacyRetryUsed && error.negotiationFailure === NEG_FAILURE.SSL_NOT_ALLOWED) {
318
+ this.#retryWithSecurityLayer("rdp");
319
+ return;
320
+ }
321
+ throw error;
322
+ }
323
+ this.selectedProtocol = selectedProtocol;
324
+ console.info("[RDP] X.224 negotiation complete", {
325
+ requested: this.requestedProtocol,
326
+ selected: selectedProtocol,
327
+ layer: selectedProtocol & NEG_PROTOCOL.HYBRID ? "NLA / CredSSP" : selectedProtocol & NEG_PROTOCOL.SSL ? "TLS" : "RDP standard"
328
+ });
329
+ if (selectedProtocol & NEG_PROTOCOL.HYBRID_EX) {
330
+ throw new Error("This host selected CredSSP with early user authorization, which this client does not implement.");
331
+ }
332
+ if (selectedProtocol & (NEG_PROTOCOL.SSL | NEG_PROTOCOL.HYBRID)) {
333
+ void this.#startSecureChannel(selectedProtocol);
334
+ return;
335
+ }
336
+ this.#startMcsConnect(selectedProtocol);
337
+ return;
338
+ }
339
+ if (this.state === STATE.MCS_CONNECT) {
340
+ this.serverData = parseServerData(parseConnectResponse(frame));
341
+ console.info("[RDP] MCS connect response", {
342
+ encryptionMethod: this.serverData.encryptionMethod,
343
+ encryptionLevel: this.serverData.encryptionLevel,
344
+ ioChannelId: this.serverData.ioChannelId,
345
+ certificate: this.serverData.publicKey?.source || "none",
346
+ virtualChannels: this.serverData.channelIds
347
+ });
348
+ this.ioChannelId = this.serverData.ioChannelId;
349
+ this.channelNamesById = /* @__PURE__ */ new Map();
350
+ this.requestedChannels.forEach((channel, index) => {
351
+ const channelId = this.serverData.channelIds[index];
352
+ if (channelId !== void 0) this.channelNamesById.set(channelId, channel.name);
353
+ });
354
+ this.security.configure(
355
+ this.serverData.encryptionMethod,
356
+ this.serverData.encryptionLevel,
357
+ // The client random is only needed when the RDP security layer is live.
358
+ this.serverData.encryptionMethod ? this.#makeClientRandom() : new Uint8Array(32),
359
+ this.serverData.serverRandom || new Uint8Array(32)
360
+ );
361
+ this.state = STATE.ATTACH_USER;
362
+ this.#send(erectDomainRequest());
363
+ this.#send(attachUserRequest());
364
+ return;
365
+ }
366
+ if (this.state === STATE.ATTACH_USER) {
367
+ ({ userId: this.userId } = parseAttachUserConfirm(frame));
368
+ this.channelsToJoin = [this.userId, this.ioChannelId, ...this.serverData.channelIds];
369
+ this.joinedChannels = 0;
370
+ this.state = STATE.JOIN_CHANNELS;
371
+ this.#send(channelJoinRequest(this.userId, this.channelsToJoin[0]));
372
+ return;
373
+ }
374
+ if (this.state === STATE.JOIN_CHANNELS) {
375
+ parseChannelJoinConfirm(frame);
376
+ this.joinedChannels += 1;
377
+ if (this.joinedChannels < this.channelsToJoin.length) {
378
+ this.#send(channelJoinRequest(this.userId, this.channelsToJoin[this.joinedChannels]));
379
+ return;
380
+ }
381
+ this.#startSecureSession();
382
+ return;
383
+ }
384
+ const pdu = parseDomainPdu(frame);
385
+ if (pdu.type === "disconnect") {
386
+ this.#fail(new Error(pdu.message));
387
+ return;
388
+ }
389
+ if (pdu.type !== "send-data") return;
390
+ const channelName = this.channelNamesById.get(pdu.channelId);
391
+ if (channelName) {
392
+ const body = this.security.headerPresent ? this.security.unwrap(pdu.payload).payload : pdu.payload;
393
+ this.#handleChannelData(channelName, body);
394
+ return;
395
+ }
396
+ const { flags, payload } = this.#unwrapSecurity(pdu.payload);
397
+ if (payload.length === 0) return;
398
+ if (flags & SEC_FLAG.LICENSE_PKT) {
399
+ this.#handleLicensing(payload);
400
+ return;
401
+ }
402
+ this.#handleSharePdu(payload);
403
+ }
404
+ /**
405
+ * Basic security headers are mandatory on licensing PDUs even when the host
406
+ * reported no RDP-level encryption, so during that phase the header has to be
407
+ * recognised rather than assumed. A share control PDU never has a zero pduType,
408
+ * which is what tells the two apart.
409
+ */
410
+ #unwrapSecurity(bytes) {
411
+ if (this.security.headerPresent) return this.security.unwrap(bytes);
412
+ const KNOWN_FLAGS = SEC_FLAG.EXCHANGE_PKT | SEC_FLAG.ENCRYPT | SEC_FLAG.INFO_PKT | SEC_FLAG.LICENSE_PKT | SEC_FLAG.LICENSE_ENCRYPT_CS | SEC_FLAG.REDIRECTION_PKT | SEC_FLAG.AUTODETECT_REQ | SEC_FLAG.HEARTBEAT;
413
+ if (bytes.length >= 4) {
414
+ const flags = bytes[0] | bytes[1] << 8;
415
+ const flagsHi = bytes[2] | bytes[3] << 8;
416
+ if (flagsHi === 0 && (flags & KNOWN_FLAGS) !== 0) {
417
+ return this.security.unwrap(bytes, true);
418
+ }
419
+ }
420
+ return { flags: 0, payload: bytes };
421
+ }
422
+ /**
423
+ * Establish TLS and, when the host asked for NLA, authenticate with CredSSP
424
+ * before the RDP connection sequence starts. Detached from the inbound
425
+ * pipeline on purpose: it waits for messages that the pipeline delivers.
426
+ */
427
+ async #startSecureChannel(selectedProtocol) {
428
+ try {
429
+ this.state = STATE.TLS_HANDSHAKE;
430
+ this.tls = new Tls12Client({
431
+ send: (bytes) => this.#sendRaw(bytes),
432
+ onData: (bytes) => this.#onSecureData(bytes),
433
+ onError: (error) => this.#fail(error)
434
+ });
435
+ const session = await this.tls.start();
436
+ console.info("[RDP] TLS established", {
437
+ cipherSuite: session.cipherSuite,
438
+ certificates: session.certificates.length,
439
+ modulusBits: session.publicKey.modulus.length * 8
440
+ });
441
+ if (selectedProtocol & NEG_PROTOCOL.HYBRID) {
442
+ this.state = STATE.CREDSSP;
443
+ this.credssp = new CredSspClient({
444
+ username: this.options.username,
445
+ domain: this.options.domain,
446
+ password: this.options.password,
447
+ serverPublicKey: session.publicKey.subjectPublicKey,
448
+ send: (bytes) => this.tls.write(bytes),
449
+ log: (step, detail) => console.info(`[RDP] ${step}`, detail)
450
+ });
451
+ if (!this.options.password) {
452
+ throw new Error("This host authenticates before the desktop appears (NLA), so it needs a username and password.");
453
+ }
454
+ const outcome = await this.credssp.authenticate();
455
+ console.info("[RDP] Network Level Authentication succeeded", outcome);
456
+ this.credssp = null;
457
+ }
458
+ this.#startMcsConnect(selectedProtocol);
459
+ } catch (error) {
460
+ this.#fail(error);
461
+ }
462
+ }
463
+ #startMcsConnect(selectedProtocol) {
464
+ this.state = STATE.MCS_CONNECT;
465
+ this.requestedChannels = [];
466
+ if (this.options.clipboard) this.requestedChannels.push(CLIPBOARD_CHANNEL);
467
+ if (this.options.remoteApp?.program) this.requestedChannels.push(RAIL_CHANNEL);
468
+ this.#send(connectInitial(clientData({
469
+ width: this.options.width,
470
+ height: this.options.height,
471
+ clientName: this.options.clientName,
472
+ keyboardLayout: this.options.keyboardLayout,
473
+ selectedProtocol,
474
+ // Only the bare RDP security layer needs RDP's own encryption; with TLS
475
+ // underneath, the host reports no encryption method at all.
476
+ encryptionMethods: selectedProtocol === NEG_PROTOCOL.RDP ? 11 : 0,
477
+ channels: this.requestedChannels
478
+ })));
479
+ }
480
+ #makeClientRandom() {
481
+ this.clientRandom = new Uint8Array(32);
482
+ crypto.getRandomValues(this.clientRandom);
483
+ return this.clientRandom;
484
+ }
485
+ /**
486
+ * Send the security exchange (when the RDP security layer is in use) followed
487
+ * by the client info PDU that carries the credentials.
488
+ */
489
+ #startSecureSession() {
490
+ if (this.security.encryptionEnabled) {
491
+ const key = this.serverData.publicKey;
492
+ if (!key) throw new Error("RDP: the host asked for encryption but sent no public key");
493
+ const encryptedRandom = rsaEncrypt(this.clientRandom, key.modulus, key.exponent);
494
+ this.#sendOnIoChannel(SecurityLayer.securityExchange(encryptedRandom));
495
+ }
496
+ const info = clientInfoPdu({
497
+ domain: this.options.domain,
498
+ username: this.options.username,
499
+ password: this.options.password,
500
+ performanceFlags: this.options.performance,
501
+ autoLogon: Boolean(this.options.password),
502
+ remoteApp: Boolean(this.options.remoteApp?.program)
503
+ });
504
+ this.state = STATE.LICENSING;
505
+ this.#sendOnIoChannel(this.security.wrap(info, SEC_FLAG.INFO_PKT));
506
+ }
507
+ #sendOnIoChannel(payload) {
508
+ this.#send(sendDataRequest(this.userId, this.ioChannelId, payload));
509
+ }
510
+ #handleLicensing(payload) {
511
+ if (payload.length < 4) return;
512
+ const messageType = payload[0];
513
+ if (messageType === LICENSE_ERROR_ALERT) {
514
+ console.info("[RDP] Licensing finished with an error alert (the normal path)");
515
+ this.state = STATE.CAPABILITIES;
516
+ return;
517
+ }
518
+ throw new Error(
519
+ "This host requires Remote Desktop licensing negotiation (per-device CAL), which this client does not implement."
520
+ );
521
+ }
522
+ // --- share layer ----------------------------------------------------------
523
+ #handleSharePdu(payload) {
524
+ const control = parseShareControlHeader(payload);
525
+ if (control.pduSource) this.serverChannelId = control.pduSource;
526
+ if (control.pduType === PDU_TYPE.DEMAND_ACTIVE) {
527
+ this.#handleDemandActive(control.payload);
528
+ return;
529
+ }
530
+ if (control.pduType === PDU_TYPE.DEACTIVATE_ALL) {
531
+ console.info("[RDP] Host deactivated the share; waiting for a new demand active");
532
+ this.state = STATE.CAPABILITIES;
533
+ return;
534
+ }
535
+ if (control.pduType === PDU_TYPE.SERVER_REDIRECT) {
536
+ throw new Error("This host redirected the session to another server, which this client cannot follow.");
537
+ }
538
+ if (control.pduType !== PDU_TYPE.DATA) return;
539
+ const data = parseShareDataHeader(control.payload);
540
+ if (data.compressedType & 32) {
541
+ throw new Error("RDP: the host compressed a PDU although this client advertised no compression support");
542
+ }
543
+ this.#handleDataPdu(data);
544
+ }
545
+ #handleDemandActive(payload) {
546
+ const { shareId, capabilities } = parseDemandActive(payload);
547
+ this.shareId = shareId;
548
+ const serverCaps = parseServerCapabilities(capabilities);
549
+ if (this.options.remoteApp?.program && (!(serverCaps.railSupportLevel & 1) || !serverCaps.windowSupportLevel)) {
550
+ throw new Error("This RDP host did not accept RemoteApp mode. Publish the application on the host or connect without a RemoteApp program.");
551
+ }
552
+ if (serverCaps.desktopWidth && serverCaps.desktopHeight) {
553
+ const resized = this.state === STATE.CONNECTED && (serverCaps.desktopWidth !== this.desktopWidth || serverCaps.desktopHeight !== this.desktopHeight);
554
+ this.desktopWidth = serverCaps.desktopWidth;
555
+ this.desktopHeight = serverCaps.desktopHeight;
556
+ if (resized) {
557
+ console.info("[RDP] Host resized the desktop", {
558
+ desktop: `${this.desktopWidth}x${this.desktopHeight}`
559
+ });
560
+ this.dispatchEvent(new CustomEvent("resize", {
561
+ detail: { width: this.desktopWidth, height: this.desktopHeight }
562
+ }));
563
+ }
564
+ }
565
+ this.refreshRectSupported = serverCaps.refreshRectSupport;
566
+ console.info("[RDP] Demand active", {
567
+ shareId,
568
+ desktop: `${this.desktopWidth}x${this.desktopHeight}`,
569
+ bitsPerPixel: serverCaps.preferredBitsPerPixel,
570
+ refreshRectSupport: serverCaps.refreshRectSupport,
571
+ remoteApp: Boolean(this.options.remoteApp?.program)
572
+ });
573
+ const { bytes, count } = buildClientCapabilities({
574
+ width: this.desktopWidth,
575
+ height: this.desktopHeight,
576
+ keyboardLayout: this.options.keyboardLayout,
577
+ remoteApp: Boolean(this.options.remoteApp?.program),
578
+ remoteAppCaps: serverCaps
579
+ });
580
+ this.#sendShareControl(
581
+ PDU_TYPE.CONFIRM_ACTIVE,
582
+ confirmActivePdu(this.shareId, bytes, count)
583
+ );
584
+ this.#sendShareData(PDU_TYPE2.SYNCHRONIZE, synchronizePdu(this.serverChannelId));
585
+ this.#sendShareData(PDU_TYPE2.CONTROL, controlPdu(CONTROL_ACTION.COOPERATE));
586
+ this.#sendShareData(PDU_TYPE2.CONTROL, controlPdu(CONTROL_ACTION.REQUEST_CONTROL));
587
+ this.#sendShareData(PDU_TYPE2.FONT_LIST, fontListPdu());
588
+ }
589
+ #handleDataPdu(data) {
590
+ switch (data.pduType2) {
591
+ case PDU_TYPE2.UPDATE:
592
+ this.#handleUpdate(new Reader(data.payload));
593
+ return;
594
+ case PDU_TYPE2.POINTER:
595
+ this.dispatchEvent(new CustomEvent("pointer", { detail: parsePointerUpdate(new Reader(data.payload)) }));
596
+ return;
597
+ case PDU_TYPE2.FONT_MAP:
598
+ this.#markConnected();
599
+ return;
600
+ case PDU_TYPE2.SET_ERROR_INFO: {
601
+ const errorInfo = new Reader(data.payload).u32le();
602
+ if (errorInfo === 0) return;
603
+ this.#fail(new Error(errorInfoMessage(errorInfo)));
604
+ return;
605
+ }
606
+ case PDU_TYPE2.SAVE_SESSION_INFO:
607
+ console.info("[RDP] Host reported a completed sign-in");
608
+ return;
609
+ case PDU_TYPE2.SHUTDOWN_DENIED:
610
+ console.info("[RDP] Host denied the shutdown request");
611
+ return;
612
+ case PDU_TYPE2.CONTROL:
613
+ case PDU_TYPE2.SYNCHRONIZE:
614
+ case PDU_TYPE2.PLAY_SOUND:
615
+ case PDU_TYPE2.MONITOR_LAYOUT:
616
+ return;
617
+ default:
618
+ return;
619
+ }
620
+ }
621
+ #handleUpdate(reader) {
622
+ const updateType = reader.u16le();
623
+ if (!this.firstUpdateLogged) {
624
+ this.firstUpdateLogged = true;
625
+ console.info("[RDP] First host update received", { updateType });
626
+ }
627
+ if (updateType === UPDATE_TYPE.BITMAP) {
628
+ const rects = parseBitmapUpdate(reader);
629
+ if (rects.length > 0) {
630
+ this.dispatchEvent(new CustomEvent("bitmap", { detail: { rects, palette: this.palette } }));
631
+ }
632
+ return;
633
+ }
634
+ if (updateType === UPDATE_TYPE.PALETTE) {
635
+ this.palette = parsePaletteUpdate(reader);
636
+ this.dispatchEvent(new CustomEvent("palette", { detail: this.palette }));
637
+ return;
638
+ }
639
+ if (updateType === UPDATE_TYPE.ORDERS) {
640
+ if (this.options.remoteApp?.program) {
641
+ this.#handleRemoteAppWindowEvents(parseRemoteAppWindowOrders(reader));
642
+ } else {
643
+ console.warn("[RDP] Ignoring a drawing-order update the client did not advertise");
644
+ }
645
+ return;
646
+ }
647
+ }
648
+ #handleRemoteAppWindowEvents(events) {
649
+ for (const event of events) {
650
+ if (event.type === "deleted") {
651
+ this.remoteAppWindows.delete(event.windowId);
652
+ this.dispatchEvent(new CustomEvent("remoteappwindow", {
653
+ detail: { ...event, count: this.remoteAppWindows.size }
654
+ }));
655
+ continue;
656
+ }
657
+ if (event.type === "new") {
658
+ clearTimeout(this.remoteAppCloseTimer);
659
+ this.remoteAppCloseTimer = null;
660
+ this.remoteAppHadWindow = true;
661
+ } else if (!this.remoteAppWindows.has(event.windowId)) {
662
+ this.remoteAppHadWindow = true;
663
+ }
664
+ const known = this.remoteAppWindows.get(event.windowId) || { windowId: event.windowId };
665
+ const tracked = { ...known, ...event, type: void 0 };
666
+ this.remoteAppWindows.set(event.windowId, tracked);
667
+ this.dispatchEvent(new CustomEvent("remoteappwindow", {
668
+ detail: { ...event, window: tracked, count: this.remoteAppWindows.size }
669
+ }));
670
+ if (this.remoteAppFit && this.#isFittedWindow(tracked) && !this.#matchesFit(tracked)) {
671
+ this.#scheduleRemoteAppFit();
672
+ }
673
+ }
674
+ this.#publishRemoteAppViewport();
675
+ if (!this.remoteAppHadWindow || this.remoteAppWindows.size > 0 || this.remoteAppCloseTimer) return;
676
+ this.remoteAppCloseTimer = setTimeout(() => {
677
+ this.remoteAppCloseTimer = null;
678
+ if (this.state !== STATE.CONNECTED || this.remoteAppWindows.size > 0) return;
679
+ console.info("[RDP] Last RemoteApp window closed; ending the session");
680
+ this.dispatchEvent(new CustomEvent("remoteappclosed", {
681
+ detail: { application: this.options.remoteApp }
682
+ }));
683
+ }, REMOTE_APP_CLOSE_GRACE_MS);
684
+ }
685
+ /**
686
+ * The window the session exists to show: the largest top-level application
687
+ * window. Menus, tooltips and drag images are RAIL windows too, and fitting
688
+ * one of those to the browser instead of the application would be worse than
689
+ * doing nothing.
690
+ */
691
+ get remoteAppPrimaryWindow() {
692
+ let best = null;
693
+ let bestArea = -1;
694
+ for (const candidate of this.remoteAppWindows.values()) {
695
+ if (!isPrimaryWindow(candidate)) continue;
696
+ if (candidate.showState === SHOW_STATE.HIDDEN) continue;
697
+ const area = (candidate.width || 0) * (candidate.height || 0);
698
+ if (area > bestArea) {
699
+ best = candidate;
700
+ bestArea = area;
701
+ }
702
+ }
703
+ return best;
704
+ }
705
+ #isFittedWindow(candidate) {
706
+ const target = this.remoteAppPrimaryWindow;
707
+ return Boolean(target) && target.windowId === candidate.windowId;
708
+ }
709
+ /** The fit always asks for the desktop's top-left corner. */
710
+ #matchesFit(candidate) {
711
+ const fit = this.remoteAppFit;
712
+ if (!fit) return true;
713
+ return Math.abs(candidate.x || 0) <= FIT_TOLERANCE_PIXELS && Math.abs(candidate.y || 0) <= FIT_TOLERANCE_PIXELS && Math.abs((candidate.width || 0) - fit.width) <= FIT_TOLERANCE_PIXELS && Math.abs((candidate.height || 0) - fit.height) <= FIT_TOLERANCE_PIXELS;
714
+ }
715
+ #scheduleRemoteAppFit() {
716
+ if (this.remoteAppFitTimer) return;
717
+ this.remoteAppFitTimer = setTimeout(() => {
718
+ this.remoteAppFitTimer = null;
719
+ this.#applyRemoteAppFit();
720
+ }, REMOTE_APP_FIT_DELAY_MS);
721
+ }
722
+ #applyRemoteAppFit() {
723
+ const fit = this.remoteAppFit;
724
+ if (!fit || !this.rail || this.state !== STATE.CONNECTED) return;
725
+ this.rail.setWorkArea(fit.width, fit.height);
726
+ const target = this.remoteAppPrimaryWindow;
727
+ if (!target || target.showState === SHOW_STATE.MINIMIZED) return;
728
+ const geometry = `${target.width || 0}x${target.height || 0}+${target.x || 0}+${target.y || 0}`;
729
+ if (geometry !== fit.lastGeometry) fit.attempts = 0;
730
+ fit.lastGeometry = geometry;
731
+ if (fit.attempts >= REMOTE_APP_FIT_ATTEMPTS || fit.moves >= REMOTE_APP_FIT_MOVES) return;
732
+ fit.attempts += 1;
733
+ fit.moves += 1;
734
+ if (target.showState === SHOW_STATE.MAXIMIZED) {
735
+ console.info("[RDP] Restoring the maximized RemoteApp window before fitting it");
736
+ this.rail.sysCommand(target.windowId, SYS_COMMAND.RESTORE);
737
+ return;
738
+ }
739
+ console.info("[RDP] Fitting the RemoteApp window to the browser", {
740
+ windowId: target.windowId,
741
+ from: `${target.width || 0}x${target.height || 0}+${target.x || 0}+${target.y || 0}`,
742
+ to: `${fit.width}x${fit.height}+0+0`,
743
+ attempt: fit.attempts
744
+ });
745
+ this.rail.activate(target.windowId);
746
+ this.rail.moveWindow(target.windowId, {
747
+ left: 0,
748
+ top: 0,
749
+ right: fit.width,
750
+ bottom: fit.height
751
+ });
752
+ }
753
+ /**
754
+ * Show only the primary window's own rectangle. RemoteApp paints nothing
755
+ * outside its windows, so without this the canvas is the whole negotiated
756
+ * desktop with the application somewhere inside it.
757
+ */
758
+ #publishRemoteAppViewport() {
759
+ const target = this.remoteAppPrimaryWindow;
760
+ if (!target || !target.width || !target.height) return;
761
+ if (target.showState === SHOW_STATE.MINIMIZED || target.showState === SHOW_STATE.HIDDEN) return;
762
+ const x = Math.max(0, Math.min(this.desktopWidth - 1, target.x || 0));
763
+ const y = Math.max(0, Math.min(this.desktopHeight - 1, target.y || 0));
764
+ const viewport = {
765
+ x,
766
+ y,
767
+ width: Math.max(1, Math.min(this.desktopWidth - x, target.width)),
768
+ height: Math.max(1, Math.min(this.desktopHeight - y, target.height))
769
+ };
770
+ const previous = this.remoteAppViewport;
771
+ if (previous && previous.x === viewport.x && previous.y === viewport.y && previous.width === viewport.width && previous.height === viewport.height) {
772
+ return;
773
+ }
774
+ this.remoteAppViewport = viewport;
775
+ this.dispatchEvent(new CustomEvent("remoteappviewport", { detail: viewport }));
776
+ }
777
+ /**
778
+ * Size the remote application's window to the room the browser has for it.
779
+ *
780
+ * The RDP desktop is fixed for the life of a connection -- resizing it needs
781
+ * the dynamic virtual channel this client does not speak -- so the desktop is
782
+ * negotiated large at connect time and the application window is moved and
783
+ * resized inside it instead. The canvas then shows only that window.
784
+ */
785
+ fitRemoteApp(width, height) {
786
+ if (!this.options.remoteApp?.program) return false;
787
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return false;
788
+ const fit = {
789
+ width: Math.max(MIN_REMOTE_APP_SIZE, Math.min(this.desktopWidth, Math.floor(width))),
790
+ height: Math.max(MIN_REMOTE_APP_SIZE, Math.min(this.desktopHeight, Math.floor(height))),
791
+ attempts: 0,
792
+ moves: 0,
793
+ lastGeometry: ""
794
+ };
795
+ this.remoteAppFit = fit;
796
+ this.#scheduleRemoteAppFit();
797
+ return true;
798
+ }
799
+ #markConnected() {
800
+ if (this.state === STATE.CONNECTED) return;
801
+ this.state = STATE.CONNECTED;
802
+ console.info("[RDP] Session established", {
803
+ desktop: `${this.desktopWidth}x${this.desktopHeight}`,
804
+ encryption: this.security.encryptionEnabled ? `RC4 ${this.security.keys.keyLength * 8}-bit` : "TLS transport"
805
+ });
806
+ this.dispatchEvent(new CustomEvent("ready", {
807
+ detail: { width: this.desktopWidth, height: this.desktopHeight }
808
+ }));
809
+ this.setOutputEnabled(true);
810
+ this.refresh();
811
+ }
812
+ // --- fast-path output (defensive) ----------------------------------------
813
+ /**
814
+ * This client does not advertise fast-path output, so a well-behaved host
815
+ * never uses it. Some do anyway, and dropping those frames would leave a
816
+ * blank screen with no explanation.
817
+ */
818
+ #handleFastPath(frame) {
819
+ const encryptionFlags = frame[0] >> 6 & 3;
820
+ const lengthBytes = frame[1] & 128 ? 2 : 1;
821
+ let payload = frame.subarray(1 + lengthBytes);
822
+ if (encryptionFlags & 2) payload = this.security.decryptFastPath(payload);
823
+ const reader = new Reader(payload);
824
+ while (reader.remaining >= 3) {
825
+ const header = reader.u8();
826
+ const updateCode = header & 15;
827
+ const fragmentation = header >> 4 & 3;
828
+ const compression = header >> 6 & 3;
829
+ if (compression & 2) reader.u8();
830
+ const size = reader.u16le();
831
+ let updateData = reader.raw(Math.min(size, reader.remaining));
832
+ if (fragmentation !== 0) {
833
+ if (fragmentation === 2) this.fastPathFragments = [];
834
+ this.fastPathFragments.push(updateData.slice());
835
+ if (fragmentation !== 1) continue;
836
+ updateData = concatBytes(...this.fastPathFragments);
837
+ this.fastPathFragments = [];
838
+ }
839
+ this.#handleFastPathUpdate(updateCode, updateData);
840
+ }
841
+ }
842
+ #handleFastPathUpdate(updateCode, updateData) {
843
+ const reader = new Reader(updateData);
844
+ switch (updateCode) {
845
+ case 1: {
846
+ reader.u16le();
847
+ const rects = parseBitmapUpdate(reader);
848
+ if (rects.length > 0) {
849
+ this.dispatchEvent(new CustomEvent("bitmap", { detail: { rects, palette: this.palette } }));
850
+ }
851
+ return;
852
+ }
853
+ case 2:
854
+ this.palette = parsePaletteUpdate(reader);
855
+ this.dispatchEvent(new CustomEvent("palette", { detail: this.palette }));
856
+ return;
857
+ case 5:
858
+ this.dispatchEvent(new CustomEvent("pointer", { detail: { kind: "system", hidden: true } }));
859
+ return;
860
+ case 6:
861
+ this.dispatchEvent(new CustomEvent("pointer", { detail: { kind: "system", hidden: false } }));
862
+ return;
863
+ case 8:
864
+ this.dispatchEvent(new CustomEvent("pointer", {
865
+ detail: { kind: "position", x: reader.u16le(), y: reader.u16le() }
866
+ }));
867
+ return;
868
+ case 9:
869
+ // FASTPATH_UPDATETYPE_COLOR
870
+ case 10:
871
+ // FASTPATH_UPDATETYPE_CACHED
872
+ case 11: {
873
+ const messageType = { 9: 6, 10: 7, 11: 8 }[updateCode];
874
+ const header = new Uint8Array([messageType, 0, 0, 0]);
875
+ this.dispatchEvent(new CustomEvent("pointer", {
876
+ detail: parsePointerUpdate(new Reader(concatBytes(header, updateData)))
877
+ }));
878
+ return;
879
+ }
880
+ default:
881
+ return;
882
+ }
883
+ }
884
+ // --- virtual channels -----------------------------------------------------
885
+ #handleChannelData(name, bytes) {
886
+ let reassembler = this.channelReassemblers.get(name);
887
+ if (!reassembler) {
888
+ reassembler = new ChannelReassembler();
889
+ this.channelReassemblers.set(name, reassembler);
890
+ }
891
+ const message = reassembler.push(bytes);
892
+ if (!message) return;
893
+ if (name === CLIPBOARD_CHANNEL.name) {
894
+ this.#clipboardChannel().receive(message);
895
+ return;
896
+ }
897
+ if (name === RAIL_CHANNEL.name) {
898
+ this.#railChannel().receive(message);
899
+ }
900
+ }
901
+ #sendVirtualChannel(name, bytes) {
902
+ const channelId = [...this.channelNamesById].find(([, channelName]) => channelName === name)?.[0];
903
+ if (channelId === void 0) return false;
904
+ for (const chunk of chunkChannelData(bytes, CHANNEL_CHUNK_LENGTH, CHANNEL_FLAG_SHOW_PROTOCOL)) {
905
+ this.#send(sendDataRequest(this.userId, channelId, this.security.wrap(chunk)));
906
+ }
907
+ return true;
908
+ }
909
+ #railChannel() {
910
+ if (this.rail) return this.rail;
911
+ this.rail = new RailChannel({
912
+ application: this.options.remoteApp,
913
+ // The work area is what the host maximizes into, so it is the browser's
914
+ // room for the window rather than the whole negotiated desktop. A fit
915
+ // requested before the channel opened is already known here.
916
+ width: this.remoteAppFit?.width || this.desktopWidth,
917
+ height: this.remoteAppFit?.height || this.desktopHeight,
918
+ send: (bytes) => this.#sendVirtualChannel(RAIL_CHANNEL.name, bytes),
919
+ onReady: ({ buildNumber, extended, flags }) => {
920
+ console.info("[RDP] RemoteApp channel ready", { buildNumber, extended: Boolean(extended), flags: flags || 0 });
921
+ this.dispatchEvent(new CustomEvent("remoteappready", {
922
+ detail: { application: this.options.remoteApp }
923
+ }));
924
+ },
925
+ onResult: (result) => {
926
+ const detail = { ...result, application: this.options.remoteApp };
927
+ this.dispatchEvent(new CustomEvent("remoteappresult", { detail }));
928
+ if (!result.ok) this.#fail(new Error(result.message));
929
+ else console.info("[RDP] Remote application launched", detail);
930
+ }
931
+ });
932
+ return this.rail;
933
+ }
934
+ /** Created on first use, so a host without the channel costs nothing. */
935
+ #clipboardChannel() {
936
+ if (this.clipboard) return this.clipboard;
937
+ this.clipboard = new ClipboardChannel({
938
+ send: (bytes) => this.#sendVirtualChannel(CLIPBOARD_CHANNEL.name, bytes),
939
+ log: (step, detail) => console.info(`[RDP] ${step}`, detail)
940
+ });
941
+ this.clipboard.addEventListener("text", (event) => {
942
+ this.dispatchEvent(new CustomEvent("clipboard", { detail: event.detail }));
943
+ });
944
+ return this.clipboard;
945
+ }
946
+ /**
947
+ * Hand the host our clipboard text. Resolves once the host has acknowledged
948
+ * owning it, which is what makes it safe to send a paste keystroke next.
949
+ */
950
+ setClipboardText(text) {
951
+ if (!this.connected || !this.channelNamesById.size) return Promise.resolve(false);
952
+ return this.#clipboardChannel().setLocalText(text || "");
953
+ }
954
+ get clipboardShared() {
955
+ return Boolean(this.clipboard?.ready);
956
+ }
957
+ /**
958
+ * Deliver text into the session the way a person would: put it on the host's
959
+ * clipboard, wait for the host to acknowledge owning it, then press the paste
960
+ * shortcut. Waiting is what stops the keystroke from pasting the previous
961
+ * contents. A host with no clipboard channel gets the text typed instead.
962
+ */
963
+ pasteToHost(text) {
964
+ if (!text || !this.connected) return Promise.resolve(false);
965
+ const pressPaste = () => {
966
+ if (!this.connected) return;
967
+ this.sendKeyByCode("ControlLeft", true);
968
+ this.sendKeyByCode("KeyV", true);
969
+ this.sendKeyByCode("KeyV", false);
970
+ this.sendKeyByCode("ControlLeft", false);
971
+ };
972
+ return this.setClipboardText(text).then((acknowledged) => {
973
+ if (acknowledged) pressPaste();
974
+ else this.sendUnicode(text);
975
+ return true;
976
+ });
977
+ }
978
+ // --- outbound share PDUs --------------------------------------------------
979
+ #sendShareControl(pduType, payload) {
980
+ this.#sendOnIoChannel(this.security.wrap(shareControlPdu(pduType, this.userId, payload)));
981
+ }
982
+ #sendShareData(pduType2, payload) {
983
+ this.#sendOnIoChannel(
984
+ this.security.wrap(shareDataPdu(this.shareId, this.userId, pduType2, payload))
985
+ );
986
+ }
987
+ // --- input ----------------------------------------------------------------
988
+ get connected() {
989
+ return this.state === STATE.CONNECTED;
990
+ }
991
+ #sendInput(events) {
992
+ if (!this.connected || events.length === 0) return;
993
+ this.#sendShareData(PDU_TYPE2.INPUT, inputEventsPdu(events));
994
+ }
995
+ sendKeyByCode(code, pressed) {
996
+ const mapped = lookupScancode(code);
997
+ if (!mapped) return false;
998
+ this.#sendInput([scancodeEvent(mapped.scancode, pressed, mapped.extended)]);
999
+ return true;
1000
+ }
1001
+ sendUnicode(text) {
1002
+ const events = [];
1003
+ for (const character of text) {
1004
+ for (let i = 0; i < character.length; i++) {
1005
+ const unit = character.charCodeAt(i);
1006
+ events.push(unicodeEvent(unit, true));
1007
+ events.push(unicodeEvent(unit, false));
1008
+ }
1009
+ }
1010
+ for (let i = 0; i < events.length; i += 40) this.#sendInput(events.slice(i, i + 40));
1011
+ }
1012
+ sendCtrlAltDel() {
1013
+ const events = CTRL_ALT_DEL.map(({ code, pressed }) => {
1014
+ const mapped = lookupScancode(code);
1015
+ return scancodeEvent(mapped.scancode, pressed, mapped.extended);
1016
+ });
1017
+ this.#sendInput(events);
1018
+ }
1019
+ sendMouseMove(x, y) {
1020
+ this.#sendInput([mouseEvent(POINTER_FLAG.MOVE, x, y)]);
1021
+ }
1022
+ sendMouseButton(button, pressed, x, y) {
1023
+ const buttonFlag = {
1024
+ 0: POINTER_FLAG.BUTTON1,
1025
+ 1: POINTER_FLAG.BUTTON3,
1026
+ // middle
1027
+ 2: POINTER_FLAG.BUTTON2
1028
+ // right
1029
+ }[button];
1030
+ if (buttonFlag === void 0) {
1031
+ const extendedFlag = button === 3 ? 1 : 2;
1032
+ this.#sendInput([extendedMouseEvent(extendedFlag | (pressed ? POINTER_FLAG.DOWN : 0), x, y)]);
1033
+ return;
1034
+ }
1035
+ this.#sendInput([mouseEvent(buttonFlag | (pressed ? POINTER_FLAG.DOWN : 0), x, y)]);
1036
+ }
1037
+ sendWheel(delta, x, y, horizontal = false) {
1038
+ const flags = pointerWheelFlags(delta, horizontal);
1039
+ if (!flags) return;
1040
+ this.#sendInput([mouseEvent(flags, x, y)]);
1041
+ }
1042
+ /** Tell the host which lock keys are already on, so they do not drift. */
1043
+ sendKeyboardSync(toggleFlags) {
1044
+ this.toggleFlags = toggleFlags >>> 0;
1045
+ this.#sendInput([syncEvent(this.toggleFlags)]);
1046
+ }
1047
+ /** Ask the host to resend a region, used after the canvas is re-created. */
1048
+ refresh(rect) {
1049
+ if (!this.connected || !this.refreshRectSupported) return;
1050
+ this.#sendShareData(PDU_TYPE2.REFRESH_RECT, refreshRectPdu([
1051
+ rect || { left: 0, top: 0, right: this.desktopWidth - 1, bottom: this.desktopHeight - 1 }
1052
+ ]));
1053
+ }
1054
+ /** Pause or resume host output, e.g. while the tab is hidden. */
1055
+ setOutputEnabled(enabled) {
1056
+ if (!this.connected) return;
1057
+ this.#sendShareData(PDU_TYPE2.SUPPRESS_OUTPUT, suppressOutputPdu(enabled, {
1058
+ left: 0,
1059
+ top: 0,
1060
+ right: this.desktopWidth - 1,
1061
+ bottom: this.desktopHeight - 1
1062
+ }));
1063
+ }
1064
+ }
1065
+ export {
1066
+ RdpClient
1067
+ };
1068
+ //# sourceMappingURL=client.js.map