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.
- package/README.md +50 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +7 -0
- package/dist/protocols/index.js +35 -0
- package/dist/protocols/index.js.map +7 -0
- package/dist/protocols/rdp/buffer.js +170 -0
- package/dist/protocols/rdp/buffer.js.map +7 -0
- package/dist/protocols/rdp/caps.js +260 -0
- package/dist/protocols/rdp/caps.js.map +7 -0
- package/dist/protocols/rdp/cert.js +135 -0
- package/dist/protocols/rdp/cert.js.map +7 -0
- package/dist/protocols/rdp/client.js +1068 -0
- package/dist/protocols/rdp/client.js.map +7 -0
- package/dist/protocols/rdp/cliprdr.js +240 -0
- package/dist/protocols/rdp/cliprdr.js.map +7 -0
- package/dist/protocols/rdp/credssp.js +254 -0
- package/dist/protocols/rdp/credssp.js.map +7 -0
- package/dist/protocols/rdp/crypto.js +369 -0
- package/dist/protocols/rdp/crypto.js.map +7 -0
- package/dist/protocols/rdp/display.js +589 -0
- package/dist/protocols/rdp/display.js.map +7 -0
- package/dist/protocols/rdp/gcc.js +132 -0
- package/dist/protocols/rdp/gcc.js.map +7 -0
- package/dist/protocols/rdp/keymap.js +132 -0
- package/dist/protocols/rdp/keymap.js.map +7 -0
- package/dist/protocols/rdp/mcs.js +223 -0
- package/dist/protocols/rdp/mcs.js.map +7 -0
- package/dist/protocols/rdp/ntlm.js +305 -0
- package/dist/protocols/rdp/ntlm.js.map +7 -0
- package/dist/protocols/rdp/pdu.js +444 -0
- package/dist/protocols/rdp/pdu.js.map +7 -0
- package/dist/protocols/rdp/rail.js +383 -0
- package/dist/protocols/rdp/rail.js.map +7 -0
- package/dist/protocols/rdp/rle.js +276 -0
- package/dist/protocols/rdp/rle.js.map +7 -0
- package/dist/protocols/rdp/sec.js +156 -0
- package/dist/protocols/rdp/sec.js.map +7 -0
- package/dist/protocols/rdp/session.js +307 -0
- package/dist/protocols/rdp/session.js.map +7 -0
- package/dist/protocols/rdp/tls.js +354 -0
- package/dist/protocols/rdp/tls.js.map +7 -0
- package/dist/protocols/rdp/vchannel.js +59 -0
- package/dist/protocols/rdp/vchannel.js.map +7 -0
- package/dist/protocols/rdp/x224.js +110 -0
- package/dist/protocols/rdp/x224.js.map +7 -0
- package/dist/protocols/ssh/kex.js +177 -0
- package/dist/protocols/ssh/kex.js.map +7 -0
- package/dist/protocols/ssh/messages.js +55 -0
- package/dist/protocols/ssh/messages.js.map +7 -0
- package/dist/protocols/ssh/session.js +608 -0
- package/dist/protocols/ssh/session.js.map +7 -0
- package/dist/protocols/ssh/terminal.js +191 -0
- package/dist/protocols/ssh/terminal.js.map +7 -0
- package/dist/protocols/ssh/transport.js +611 -0
- package/dist/protocols/ssh/transport.js.map +7 -0
- package/dist/protocols/ssh/wire.js +189 -0
- package/dist/protocols/ssh/wire.js.map +7 -0
- package/dist/protocols/types.js +1 -0
- package/dist/protocols/types.js.map +7 -0
- package/dist/protocols/vnc/input.js +16 -0
- package/dist/protocols/vnc/input.js.map +7 -0
- package/dist/protocols/vnc/session.js +259 -0
- package/dist/protocols/vnc/session.js.map +7 -0
- package/dist/shared/connection.js +114 -0
- package/dist/shared/connection.js.map +7 -0
- package/dist/shared/device.js +1 -0
- package/dist/shared/device.js.map +7 -0
- package/dist/shared/hosts.js +133 -0
- package/dist/shared/hosts.js.map +7 -0
- package/dist/shared/icons.js +42 -0
- package/dist/shared/icons.js.map +7 -0
- package/dist/shared/protocol.js +40 -0
- package/dist/shared/protocol.js.map +7 -0
- package/dist/shared/signals.js +42 -0
- package/dist/shared/signals.js.map +7 -0
- package/package.json +82 -0
- package/src/index.ts +14 -0
- package/src/protocols/index.ts +72 -0
- package/src/protocols/rdp/buffer.ts +204 -0
- package/src/protocols/rdp/caps.ts +304 -0
- package/src/protocols/rdp/cert.ts +190 -0
- package/src/protocols/rdp/client.ts +1363 -0
- package/src/protocols/rdp/cliprdr.ts +344 -0
- package/src/protocols/rdp/credssp.ts +319 -0
- package/src/protocols/rdp/crypto.ts +416 -0
- package/src/protocols/rdp/display.ts +750 -0
- package/src/protocols/rdp/gcc.ts +162 -0
- package/src/protocols/rdp/keymap.ts +68 -0
- package/src/protocols/rdp/mcs.ts +283 -0
- package/src/protocols/rdp/ntlm.ts +380 -0
- package/src/protocols/rdp/pdu.ts +497 -0
- package/src/protocols/rdp/rail.ts +480 -0
- package/src/protocols/rdp/rle.ts +343 -0
- package/src/protocols/rdp/sec.ts +184 -0
- package/src/protocols/rdp/session.ts +393 -0
- package/src/protocols/rdp/tls.ts +452 -0
- package/src/protocols/rdp/vchannel.ts +89 -0
- package/src/protocols/rdp/x224.ts +139 -0
- package/src/protocols/ssh/kex.ts +227 -0
- package/src/protocols/ssh/messages.ts +54 -0
- package/src/protocols/ssh/session.ts +878 -0
- package/src/protocols/ssh/terminal.ts +242 -0
- package/src/protocols/ssh/transport.ts +732 -0
- package/src/protocols/ssh/wire.ts +220 -0
- package/src/protocols/types.ts +146 -0
- package/src/protocols/vnc/input.ts +13 -0
- package/src/protocols/vnc/novnc.d.ts +75 -0
- package/src/protocols/vnc/session.ts +367 -0
- package/src/shared/connection.ts +206 -0
- package/src/shared/device.ts +28 -0
- package/src/shared/hosts.ts +240 -0
- package/src/shared/icons.ts +49 -0
- package/src/shared/protocol.ts +55 -0
- 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
|