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