tabby-rdp 0.5.2 → 0.5.3
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/CHANGELOG.md +49 -0
- package/README.md +2 -1
- package/TROUBLESHOOTING.md +7 -2
- package/dist/accounts.js +3 -0
- package/dist/connectionStatus.js +21 -4
- package/dist/desktop.service.js +241 -73
- package/dist/desktopForm.js +3 -3
- package/dist/ironrdp.js +26 -0
- package/dist/keyboard.js +12 -1
- package/dist/rdcleanpath.js +17 -4
- package/dist/rdpFile.js +13 -4
- package/dist/rdpProfile.js +44 -14
- package/dist/releases.js +3 -0
- package/dist/settingsPage.js +19 -14
- package/package.json +3 -3
- package/vendor/SHA256SUMS +1 -0
- package/vendor/ironrdp-factory.js +3237 -0
package/dist/desktop.service.js
CHANGED
|
@@ -23,6 +23,7 @@ const url_1 = require("url");
|
|
|
23
23
|
const targets_1 = require("./targets");
|
|
24
24
|
const deskScript_1 = require("./deskScript");
|
|
25
25
|
const audio_1 = require("./audio");
|
|
26
|
+
const ironrdp_1 = require("./ironrdp");
|
|
26
27
|
const clipboard_1 = require("./clipboard");
|
|
27
28
|
const connectionStatus_1 = require("./connectionStatus");
|
|
28
29
|
const microphone_1 = require("./microphone");
|
|
@@ -84,6 +85,7 @@ const STYLE = `
|
|
|
84
85
|
.trd-overlay { position: absolute; inset: 0; z-index: 30; display: flex; background: #111; }
|
|
85
86
|
.trd-host { flex: auto; min-width: 0; min-height: 0; display: flex; }
|
|
86
87
|
.trd-host iron-remote-desktop { flex: auto; }
|
|
88
|
+
.trd-last-frame { max-width: 100%; max-height: 100%; object-fit: contain; align-self: center; margin: auto; }
|
|
87
89
|
.trd-status { position: absolute; inset: 0; display: flex; flex-direction: column; gap: 14px; align-items: center;
|
|
88
90
|
justify-content: center; padding: 2em; text-align: center; color: #bbb; font-size: 13px; white-space: pre-line;
|
|
89
91
|
pointer-events: none; }
|
|
@@ -107,6 +109,10 @@ const STYLE = `
|
|
|
107
109
|
.trd-overlay.trd-mic-on > .trd-toast { right: 40px; max-width: min(420px, calc(100% - 52px)); }
|
|
108
110
|
.trd-floating-mic-on .trd-overlay.trd-broadcast-on::before { top: 40px; }
|
|
109
111
|
`;
|
|
112
|
+
/** A file's name as it is shown: it comes with the file, and what could reorder or hide part of it shows as �. */
|
|
113
|
+
function showableFileName(raw) {
|
|
114
|
+
return raw.replace(new RegExp(unshowable_1.UNSHOWABLE.source, 'gu'), '�');
|
|
115
|
+
}
|
|
110
116
|
/**
|
|
111
117
|
* The name a desktop connected to directly goes by, as its certificate is checked against it (see
|
|
112
118
|
* RDCleanPathOptions.serverName): an address as it is, a host name as it resolves (lower case, punycode, without a
|
|
@@ -261,11 +267,24 @@ class DesktopSession {
|
|
|
261
267
|
/** Keys this desktop was sent and not yet let go of (by `code`): see RemoteDesktopService.broadcastKey. */
|
|
262
268
|
this.keysDown = new Map();
|
|
263
269
|
this.reclaimFocus = (event) => {
|
|
264
|
-
if (this.visible
|
|
270
|
+
if (!this.visible) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!this.overlay.contains(event.target)) {
|
|
265
274
|
this.focusSoon();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// The focus came to the desktop while its pane isn't the one the user is in: IronRDP's canvas takes it as the
|
|
278
|
+
// pointer passes over it (its `mouseenter`), without a click, and with it the keys typed and this computer's
|
|
279
|
+
// clipboard (its monitor follows the focus). It goes back where it came from. A click hands it over still:
|
|
280
|
+
// Tabby makes the pane the one in front on the click, and the pane's focus gives the desktop the keyboard. (The
|
|
281
|
+
// layer's own fields and buttons, a sign-in form's, send nothing anywhere until they are used.)
|
|
282
|
+
if (event.target?.tagName === 'IRON-REMOTE-DESKTOP' && !this.inFront()) {
|
|
283
|
+
this.letGo(event.relatedTarget);
|
|
266
284
|
}
|
|
267
285
|
};
|
|
268
286
|
this.disposers = [];
|
|
287
|
+
this.connectionDisposers = [];
|
|
269
288
|
this.disposed = new Promise(resolve => { this.markDisposed = resolve; });
|
|
270
289
|
this.overlay = document.createElement('div');
|
|
271
290
|
this.overlay.className = 'trd-overlay';
|
|
@@ -304,7 +323,9 @@ class DesktopSession {
|
|
|
304
323
|
}
|
|
305
324
|
/**
|
|
306
325
|
* Lets go of every key still held on this desktop. For when their keyups won't reach it: view only coming on, or
|
|
307
|
-
* typing into all going off, by a shortcut whose modifiers are down at that moment
|
|
326
|
+
* typing into all going off, by a shortcut whose modifiers are down at that moment, or the user leaving its pane
|
|
327
|
+
* by one (see RemoteDesktopService.letGoUnlessInFront). They would stay down there. Sent through IronRDP's
|
|
328
|
+
* component, which takes a key sent this way whether or not it has the focus.
|
|
308
329
|
*/
|
|
309
330
|
releaseKeys() {
|
|
310
331
|
for (const [code, init] of [...this.keysDown]) {
|
|
@@ -334,6 +355,33 @@ class DesktopSession {
|
|
|
334
355
|
canvas() {
|
|
335
356
|
return this.host.querySelector('iron-remote-desktop')?.shadowRoot?.querySelector('canvas') ?? null;
|
|
336
357
|
}
|
|
358
|
+
/** Whether the focus is on the desktop: IronRDP's component, its sign-in form, a question's buttons. */
|
|
359
|
+
holdsFocus() {
|
|
360
|
+
return this.overlay.contains(document.activeElement);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Takes the focus from the desktop, if it has it, back to `to` (where it came from) when that is still there to
|
|
364
|
+
* take it, or else to nothing. On IronRDP's component it is its canvas, in the component's shadow root, that has
|
|
365
|
+
* it, and has to let go. The keys held on the remote stay held: IronRDP lets go of them as the window loses the
|
|
366
|
+
* focus or the pointer leaves the canvas, not as the canvas loses the focus. Where the focus goes back to the
|
|
367
|
+
* desktop in front (the pointer passed over this one), their keyups still come from there, copied when typing
|
|
368
|
+
* into all; where the user left the pane, they don't, and the keys are let go of (see
|
|
369
|
+
* RemoteDesktopService.letGoUnlessInFront).
|
|
370
|
+
*/
|
|
371
|
+
letGo(to) {
|
|
372
|
+
const active = document.activeElement;
|
|
373
|
+
if (!active || !this.overlay.contains(active)) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const back = to;
|
|
377
|
+
if (typeof back?.focus === 'function' && back.isConnected !== false && !this.overlay.contains(back)) {
|
|
378
|
+
back.focus({ preventScroll: true });
|
|
379
|
+
}
|
|
380
|
+
if (this.overlay.contains(document.activeElement)) {
|
|
381
|
+
;
|
|
382
|
+
(active.shadowRoot?.activeElement ?? active).blur();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
337
385
|
/**
|
|
338
386
|
* Focuses the remote desktop in a moment, if it is still in the pane the user is in then (see inFront). `visible`
|
|
339
387
|
* stays true in a tab in the background, and a focus that was waiting (a retry, or Tabby's re-focus of the terminal
|
|
@@ -386,6 +434,58 @@ class DesktopSession {
|
|
|
386
434
|
onDispose(f) {
|
|
387
435
|
this.disposers.push(f);
|
|
388
436
|
}
|
|
437
|
+
onConnectionEnd(f) {
|
|
438
|
+
this.connectionDisposers.push(f);
|
|
439
|
+
}
|
|
440
|
+
/** Release browser resources and references to one backend, including when its WASM has trapped. */
|
|
441
|
+
endConnection(keepPicture = true) {
|
|
442
|
+
const clean = (f) => {
|
|
443
|
+
try {
|
|
444
|
+
f();
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
this.log.push(`cleanup: ${error?.message ?? error}`);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
let picture = keepPicture ? this.host.querySelector('.trd-last-frame') : null;
|
|
451
|
+
// Preserve the dimmed disconnect picture as plain pixels, without the component's backend or listeners.
|
|
452
|
+
clean(() => {
|
|
453
|
+
const frame = keepPicture ? this.canvas() : null;
|
|
454
|
+
if (!frame?.width || !frame.height) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const copy = document.createElement('canvas');
|
|
458
|
+
copy.width = frame.width;
|
|
459
|
+
copy.height = frame.height;
|
|
460
|
+
copy.className = 'trd-last-frame';
|
|
461
|
+
const rect = frame.getBoundingClientRect();
|
|
462
|
+
copy.style.width = `${rect.width || frame.width}px`;
|
|
463
|
+
copy.style.height = `${rect.height || frame.height}px`;
|
|
464
|
+
const ctx = copy.getContext('2d');
|
|
465
|
+
if (ctx) {
|
|
466
|
+
ctx.drawImage(frame, 0, 0);
|
|
467
|
+
picture = copy;
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
this.connectionDisposers.splice(0).forEach(clean);
|
|
471
|
+
clean(() => this.indicator?.setActive(false));
|
|
472
|
+
clean(() => this.ui?.shutdown());
|
|
473
|
+
// Removing the component stops its clipboard loop and removes its global listeners.
|
|
474
|
+
clean(() => this.host.replaceChildren(...picture ? [picture] : []));
|
|
475
|
+
this.ui = null;
|
|
476
|
+
this.stopSending = null;
|
|
477
|
+
clean(() => this.audio?.close());
|
|
478
|
+
this.audio = null;
|
|
479
|
+
clean(() => this.h264?.close());
|
|
480
|
+
this.h264 = null;
|
|
481
|
+
clean(() => this.mic?.close());
|
|
482
|
+
this.mic = null;
|
|
483
|
+
clean(() => this.files?.dispose());
|
|
484
|
+
this.files = null;
|
|
485
|
+
clean(() => this.drives?.dispose());
|
|
486
|
+
this.drives = null;
|
|
487
|
+
this.remoteSize = null;
|
|
488
|
+
}
|
|
389
489
|
/** Focuses the remote desktop. IronRDP reveals its canvas asynchronously, so retry for about half a second. */
|
|
390
490
|
focusDesktop(attempts = 30) {
|
|
391
491
|
// While the sign-in form shows, it is what takes focus back (the user name, or the password once filled).
|
|
@@ -417,16 +517,7 @@ class DesktopSession {
|
|
|
417
517
|
clearTimeout(this.labelTimer);
|
|
418
518
|
this.container.removeEventListener('focusin', this.reclaimFocus, true);
|
|
419
519
|
this.disposers.forEach(f => f());
|
|
420
|
-
this.
|
|
421
|
-
try {
|
|
422
|
-
this.ui?.shutdown();
|
|
423
|
-
}
|
|
424
|
-
catch { }
|
|
425
|
-
this.audio?.close();
|
|
426
|
-
this.h264?.close();
|
|
427
|
-
this.mic?.close();
|
|
428
|
-
this.files?.dispose();
|
|
429
|
-
this.drives?.dispose();
|
|
520
|
+
this.endConnection(false);
|
|
430
521
|
this.proxy?.close();
|
|
431
522
|
this.overlay.remove();
|
|
432
523
|
}
|
|
@@ -451,7 +542,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
451
542
|
/** Emits whenever a pane's desktop is opened, shown, hidden, connected, ended or disconnected. */
|
|
452
543
|
this.changed$ = new rxjs_1.Subject();
|
|
453
544
|
this.sessions = new Map();
|
|
454
|
-
this.ironrdp =
|
|
545
|
+
this.ironrdp = new ironrdp_1.IronRDPLoader(async () => {
|
|
546
|
+
const [factory, bytes] = await Promise.all([
|
|
547
|
+
importESM(vendor('iron-remote-desktop.js')).then(() => importESM(vendor('ironrdp-factory.js'))),
|
|
548
|
+
fs.promises.readFile(vendorFile('ironrdp_web_bg.wasm')),
|
|
549
|
+
]);
|
|
550
|
+
return { module: await WebAssembly.compile(bytes), createBackend: factory.createBackend };
|
|
551
|
+
});
|
|
455
552
|
/** Whether the browser decodes H.264 (asked once). */
|
|
456
553
|
this.h264Support = null;
|
|
457
554
|
/** Desktops (session keys) where H.264 decoding failed: they connect without it until Tabby restarts. */
|
|
@@ -849,10 +946,14 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
849
946
|
// Names as text whatever the config holds (see entryText): the accounts list and the question before removing
|
|
850
947
|
// one show them all.
|
|
851
948
|
const desktops = this.configuredDesktops().flatMap((d, i) => (0, desktops_1.isDesktopEntry)(d) && (d.account === id || d.gatewayAccount === id) ? [{ name: (0, desktops_1.entryText)(d).name, desktopIndex: i }] : []);
|
|
852
|
-
const groups = this.config.store.groups ?? [];
|
|
853
|
-
const inherited = (p) => p.options?.account === undefined && groups.find(g => g.id === p.group)?.defaults?.[targets_1.RDP_PROFILE_TYPE]?.options?.account === id;
|
|
854
949
|
const profiles = (this.config.store.profiles ?? [])
|
|
855
|
-
.filter((p) =>
|
|
950
|
+
.filter((p) => {
|
|
951
|
+
if (p?.type !== targets_1.RDP_PROFILE_TYPE) {
|
|
952
|
+
return false;
|
|
953
|
+
}
|
|
954
|
+
const options = this.profiles.getConfigProxyForProfile(p).options;
|
|
955
|
+
return options?.account === id || options?.gatewayAccount === id;
|
|
956
|
+
})
|
|
856
957
|
.map((p) => ({ name: (0, desktops_1.configText)(p.name) || (0, desktops_1.configText)(p.options?.host), profileId: (0, desktops_1.configText)(p.id) || undefined }));
|
|
857
958
|
return [...desktops, ...profiles];
|
|
858
959
|
}
|
|
@@ -971,9 +1072,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
971
1072
|
if (rest.account === id) {
|
|
972
1073
|
delete rest.account;
|
|
973
1074
|
}
|
|
974
|
-
//
|
|
1075
|
+
// Keep the gateway's credentials separate after its saved account is removed.
|
|
975
1076
|
if (rest.gatewayAccount === id) {
|
|
976
|
-
|
|
1077
|
+
rest.gatewayAccount = accounts_1.ASK_GATEWAY_ACCOUNT;
|
|
977
1078
|
}
|
|
978
1079
|
return rest;
|
|
979
1080
|
});
|
|
@@ -984,15 +1085,19 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
984
1085
|
profile.options.account = null;
|
|
985
1086
|
}
|
|
986
1087
|
if (profile?.type === targets_1.RDP_PROFILE_TYPE && profile.options?.gatewayAccount === id) {
|
|
987
|
-
profile.options.gatewayAccount =
|
|
1088
|
+
profile.options.gatewayAccount = accounts_1.ASK_GATEWAY_ACCOUNT;
|
|
988
1089
|
}
|
|
989
1090
|
}
|
|
990
|
-
//
|
|
991
|
-
|
|
992
|
-
|
|
1091
|
+
// Group and global defaults keep separate gateway authentication for their inheriting profiles, too.
|
|
1092
|
+
const defaults = [this.config.store.profileDefaults, ...(this.config.store.groups ?? []).map((group) => group?.defaults)];
|
|
1093
|
+
for (const entry of defaults) {
|
|
1094
|
+
const options = entry?.[targets_1.RDP_PROFILE_TYPE]?.options;
|
|
993
1095
|
if (options?.account === id) {
|
|
994
1096
|
options.account = '';
|
|
995
1097
|
}
|
|
1098
|
+
if (options?.gatewayAccount === id) {
|
|
1099
|
+
options.gatewayAccount = accounts_1.ASK_GATEWAY_ACCOUNT;
|
|
1100
|
+
}
|
|
996
1101
|
}
|
|
997
1102
|
await this.config.save();
|
|
998
1103
|
this.changed$.next();
|
|
@@ -1170,6 +1275,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
1170
1275
|
}
|
|
1171
1276
|
}
|
|
1172
1277
|
}
|
|
1278
|
+
/** Says that the .rdp file named `rawFileName` is too large to be one (see MAX_RDP_FILE). */
|
|
1279
|
+
refuseLargeRdp(rawFileName, size) {
|
|
1280
|
+
this.notifications.error(`${showableFileName(rawFileName)}: at ${Math.ceil(size / 1024)} KB, far larger than an .rdp file is, so it wasn't imported`);
|
|
1281
|
+
}
|
|
1173
1282
|
/** "Import an .rdp file…": picks one and adds it as a remote desktop profile (see importRdp). */
|
|
1174
1283
|
async importRdpFile() {
|
|
1175
1284
|
const file = await new Promise(resolve => {
|
|
@@ -1183,6 +1292,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
1183
1292
|
input.click();
|
|
1184
1293
|
});
|
|
1185
1294
|
if (file) {
|
|
1295
|
+
// Not even read when it is far larger than an .rdp file is (see importRdp).
|
|
1296
|
+
if (file.size > rdpFile_1.MAX_RDP_FILE) {
|
|
1297
|
+
this.refuseLargeRdp(file.name, file.size);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1186
1300
|
// A reading or parsing failure is shown, not dropped as an unhandled rejection: this is the user's action.
|
|
1187
1301
|
try {
|
|
1188
1302
|
await this.importRdp(new Uint8Array(await file.arrayBuffer()), file.name);
|
|
@@ -1216,8 +1330,12 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
1216
1330
|
* and opens it; or opens the profile that already has that address, user and domain. Returns the profile, or null.
|
|
1217
1331
|
*/
|
|
1218
1332
|
async importRdp(data, rawFileName) {
|
|
1219
|
-
|
|
1220
|
-
|
|
1333
|
+
const fileName = showableFileName(rawFileName);
|
|
1334
|
+
// Far larger than an .rdp file is: not parsed, on the thread that runs Tabby's window (see MAX_RDP_FILE).
|
|
1335
|
+
if (data.length > rdpFile_1.MAX_RDP_FILE) {
|
|
1336
|
+
this.refuseLargeRdp(rawFileName, data.length);
|
|
1337
|
+
return null;
|
|
1338
|
+
}
|
|
1221
1339
|
const parsed = (0, rdpFile_1.parseRdpFile)(data);
|
|
1222
1340
|
if (!parsed) {
|
|
1223
1341
|
this.notifications.error(`${fileName}: no address ("full address") in it`);
|
|
@@ -1542,6 +1660,16 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
1542
1660
|
}
|
|
1543
1661
|
});
|
|
1544
1662
|
created.onDispose(() => focused.unsubscribe());
|
|
1663
|
+
// The pane is no longer the one the user is in: another tab, another pane of the split. Tabby only moves a
|
|
1664
|
+
// tab it leaves off screen, and a tab that takes no focus of its own (Settings, the welcome tab) leaves the
|
|
1665
|
+
// focus where it was: on the desktop, which would go on taking the keys typed and this computer's
|
|
1666
|
+
// clipboard. It lets go then, of the keys it holds on the remote too: the user left by a shortcut whose
|
|
1667
|
+
// modifier is still down, and its keyup goes where they went. A moment later: Tabby says so before it says
|
|
1668
|
+
// which tab is the active one.
|
|
1669
|
+
const blurred = pane.blurred$.subscribe(() => {
|
|
1670
|
+
setTimeout(() => this.letGoUnlessInFront(created));
|
|
1671
|
+
});
|
|
1672
|
+
created.onDispose(() => blurred.unsubscribe());
|
|
1545
1673
|
this.connect(pane, target, spec, created);
|
|
1546
1674
|
}
|
|
1547
1675
|
if (options.background) {
|
|
@@ -1609,10 +1737,29 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
1609
1737
|
inFront(pane) {
|
|
1610
1738
|
return (0, targets_1.desktopPaneOf)(this.app.activeTab) === pane;
|
|
1611
1739
|
}
|
|
1612
|
-
/**
|
|
1740
|
+
/**
|
|
1741
|
+
* Whether `session` is the desktop of the pane the user is in (see inFront), and not behind a pane maximized over
|
|
1742
|
+
* it (see behindMaximized).
|
|
1743
|
+
*/
|
|
1613
1744
|
showsInFront(session) {
|
|
1614
1745
|
const pane = (0, targets_1.desktopPaneOf)(this.app.activeTab);
|
|
1615
|
-
return !!pane && this.sessions.get(pane) === session;
|
|
1746
|
+
return !!pane && this.sessions.get(pane) === session && !this.behindMaximized(pane);
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* A desktop whose pane stopped being the one the user is in (see showsInFront): the focus is taken from it, if it
|
|
1750
|
+
* has it, and the keys it holds on the remote are let go of. Their keyups go where the user went, whether the
|
|
1751
|
+
* desktop kept the focus until now (a tab that takes none) or lost it to what they went to (a terminal), and
|
|
1752
|
+
* IronRDP lets go of nothing as its canvas loses the focus (see DesktopSession.letGo). With typing broadcast to
|
|
1753
|
+
* every desktop of the tab, leaving the tab leaves all of its panes, so each of them is let go of here too.
|
|
1754
|
+
*/
|
|
1755
|
+
letGoUnlessInFront(session) {
|
|
1756
|
+
if (this.showsInFront(session)) {
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
if (session.holdsFocus()) {
|
|
1760
|
+
session.letGo();
|
|
1761
|
+
}
|
|
1762
|
+
session.releaseKeys();
|
|
1616
1763
|
}
|
|
1617
1764
|
/**
|
|
1618
1765
|
* Gives a desktop that just connected the keyboard, if it shows in the pane the user is in. Not when another pane is
|
|
@@ -2175,7 +2322,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
2175
2322
|
timer = setTimeout(() => this.followPane(pane, session), 300);
|
|
2176
2323
|
});
|
|
2177
2324
|
observer.observe(session.overlay);
|
|
2178
|
-
session.
|
|
2325
|
+
session.onConnectionEnd(() => {
|
|
2179
2326
|
clearTimeout(timer);
|
|
2180
2327
|
observer.disconnect();
|
|
2181
2328
|
});
|
|
@@ -2650,25 +2797,12 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
2650
2797
|
return this.sessions.get(pane)?.log ?? [];
|
|
2651
2798
|
}
|
|
2652
2799
|
loadIronRDP() {
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
fs.promises.readFile(vendorFile('ironrdp_web_bg.wasm')),
|
|
2660
|
-
]);
|
|
2661
|
-
// Troubleshooting: localStorage.trdLogLevel = 'DEBUG' (or TRACE), then restart Tabby.
|
|
2662
|
-
let level = 'INFO';
|
|
2663
|
-
try {
|
|
2664
|
-
level = localStorage.getItem('trdLogLevel') || level;
|
|
2665
|
-
}
|
|
2666
|
-
catch { }
|
|
2667
|
-
await rdp.init(level, wasm);
|
|
2668
|
-
return rdp;
|
|
2669
|
-
})();
|
|
2670
|
-
this.ironrdp.catch(() => { this.ironrdp = null; });
|
|
2671
|
-
return this.ironrdp;
|
|
2800
|
+
let level = 'INFO';
|
|
2801
|
+
try {
|
|
2802
|
+
level = localStorage.getItem('trdLogLevel') || level;
|
|
2803
|
+
}
|
|
2804
|
+
catch { }
|
|
2805
|
+
return this.ironrdp.create(level);
|
|
2672
2806
|
}
|
|
2673
2807
|
/**
|
|
2674
2808
|
* Where to connect and as whom. The host's own desktop: the setup script (grd, generated credentials), or, where
|
|
@@ -2919,35 +3053,35 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
2919
3053
|
}
|
|
2920
3054
|
/**
|
|
2921
3055
|
* The sign-in to a desktop's RD Gateway with the saved account it names: the account's password from the keychain,
|
|
2922
|
-
* or asked for (when none is saved, or `retryError`: the gateway just refused it).
|
|
2923
|
-
*
|
|
3056
|
+
* or asked for (when none is saved, or `retryError`: the gateway just refused it). A removed or missing account
|
|
3057
|
+
* always asks for separate credentials; null when the form was cancelled.
|
|
2924
3058
|
*/
|
|
2925
3059
|
async gatewayAccountFor(spec, session, gateway, retryError) {
|
|
2926
3060
|
const account = this.accounts().find(a => a.id === spec.gatewayAccount);
|
|
2927
3061
|
if (!account) {
|
|
2928
|
-
session.log.push('gateway:
|
|
2929
|
-
return undefined;
|
|
3062
|
+
session.log.push('gateway: asking for a separate account');
|
|
2930
3063
|
}
|
|
2931
|
-
const signIn = { domain: account.domain, saveKey: (0, accounts_1.accountKey)(account.id), saveLabel: `saved account ${account.name}`, saveFor: (0, accounts_1.signInName)(account), accountRevision: this.accountRevisions.capture(account) };
|
|
2932
|
-
const saved = retryError
|
|
2933
|
-
if (saved && saved.username === (0, accounts_1.signInName)(account)) {
|
|
3064
|
+
const signIn = account ? { domain: account.domain, saveKey: (0, accounts_1.accountKey)(account.id), saveLabel: `saved account ${account.name}`, saveFor: (0, accounts_1.signInName)(account), accountRevision: this.accountRevisions.capture(account) } : undefined;
|
|
3065
|
+
const saved = !retryError && signIn ? await (0, signin_1.loadCredentials)(signIn.saveKey) : null;
|
|
3066
|
+
if (saved && account && saved.username === (0, accounts_1.signInName)(account)) {
|
|
2934
3067
|
session.log.push(`gateway: the saved account "${account.name}"`);
|
|
2935
3068
|
return { ...signIn, credentials: { username: (0, accounts_1.signInName)(account), password: saved.password }, remember: false };
|
|
2936
3069
|
}
|
|
2937
3070
|
session.status('');
|
|
2938
3071
|
const asked = (0, signin_1.askCredentials)(session.overlay, {
|
|
2939
3072
|
title: `Sign in to the gateway ${gateway.host}, to reach ${spec.name}`,
|
|
2940
|
-
username: (0, accounts_1.signInName)(account),
|
|
2941
|
-
error: [retryError,
|
|
2942
|
-
|
|
2943
|
-
|
|
3073
|
+
username: account ? (0, accounts_1.signInName)(account) : '',
|
|
3074
|
+
error: [retryError, !account && spec.gatewayAccount !== accounts_1.ASK_GATEWAY_ACCOUNT ? 'The saved gateway account no longer exists. Enter a separate account for the gateway.' : '',
|
|
3075
|
+
account && signIn?.accountRevision === undefined ? accountRevisions_1.ACCOUNT_REMEMBER_UNAVAILABLE : ''].filter(Boolean).join(' '),
|
|
3076
|
+
canRemember: !!signIn && signIn.accountRevision !== undefined,
|
|
3077
|
+
account: account?.name,
|
|
2944
3078
|
}, session.disposed);
|
|
2945
3079
|
// Only in the pane in front (see endpointFor).
|
|
2946
3080
|
if (session.visible && this.showsInFront(session)) {
|
|
2947
3081
|
setTimeout(() => session.visible && this.showsInFront(session) && session.focusDesktop());
|
|
2948
3082
|
}
|
|
2949
3083
|
const entered = await asked;
|
|
2950
|
-
return entered && { ...signIn, credentials: entered, remember: entered.remember };
|
|
3084
|
+
return entered && { ...signIn, credentials: entered, remember: !!signIn && entered.remember };
|
|
2951
3085
|
}
|
|
2952
3086
|
/**
|
|
2953
3087
|
* The proxy's stream to a desktop behind an RD Gateway (see gateway.ts): to the gateway as the SSH host (or this
|
|
@@ -2956,14 +3090,18 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
2956
3090
|
async throughGateway(target, spec, session, gateway, endpoint, signal) {
|
|
2957
3091
|
session.gatewayRefused = null;
|
|
2958
3092
|
const own = endpoint.gatewayAccount;
|
|
3093
|
+
if (spec.gatewayAccount && !own) {
|
|
3094
|
+
throw new Error('A separate gateway sign-in is required before connecting.');
|
|
3095
|
+
}
|
|
2959
3096
|
const credentials = own?.credentials ?? endpoint.credentials;
|
|
2960
|
-
session.log.push(`gateway: ${gateway.host}:${gateway.port}, signing in with ${own ? 'its
|
|
3097
|
+
session.log.push(`gateway: ${gateway.host}:${gateway.port}, signing in with ${own ? 'its separate account' : 'the desktop\'s sign-in'}`);
|
|
2961
3098
|
try {
|
|
2962
3099
|
const stream = await (0, gateway_1.openThroughGateway)(() => target.openTcp(gateway.host, gateway.port), gateway, { username: credentials.username, password: credentials.password, domain: (own ? own.domain : endpoint.domain ?? spec.domain) || undefined }, { host: endpoint.host, port: endpoint.port }, (fingerprint, valid, reason, details) => this.checkGatewayCertificate(session, gateway, fingerprint, valid, reason, details), { log: m => session.log.push(m), clientName: os.hostname(), signal });
|
|
2963
3100
|
// Entered a moment ago and taken by the gateway: kept, while the account is still what it was signed in as.
|
|
2964
|
-
if (own?.remember) {
|
|
3101
|
+
if (own?.remember && own.saveKey) {
|
|
2965
3102
|
own.remember = false;
|
|
2966
|
-
|
|
3103
|
+
const saveKey = own.saveKey;
|
|
3104
|
+
(0, signin_1.saveCredentialsIf)(saveKey, own.credentials, () => this.accountCurrent(saveKey, own.saveFor, own.accountRevision), own.saveLabel).then(saved => session.log.push(saved ? `gateway: saved as the account's password in ${(0, signin_1.storeName)()}` : 'gateway: not saved: the account changed meanwhile'), e => session.log.push(`gateway: ${(0, signin_1.storeName)()}: ${e?.message ?? e}`));
|
|
2967
3105
|
}
|
|
2968
3106
|
return stream;
|
|
2969
3107
|
}
|
|
@@ -3421,8 +3559,40 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
3421
3559
|
}
|
|
3422
3560
|
/** One RDP connection attempt, until it ends. */
|
|
3423
3561
|
async run(pane, target, spec, session, rdp, endpoint) {
|
|
3562
|
+
const attempt = new AbortController();
|
|
3563
|
+
let connectedAt;
|
|
3564
|
+
let unwatch;
|
|
3565
|
+
const trapped = new Promise(resolve => {
|
|
3566
|
+
unwatch = rdp.runtime?.onTrap((error) => {
|
|
3567
|
+
session.log.push(`wasm: this desktop's instance trapped: ${error.message}`);
|
|
3568
|
+
// Drop the socket even if Rust's run() promise can no longer settle after a trap.
|
|
3569
|
+
session.proxy?.close();
|
|
3570
|
+
session.proxy = null;
|
|
3571
|
+
resolve({ connected: session.state === 'connected', error: `WebAssembly instance failed: ${error.message}` });
|
|
3572
|
+
});
|
|
3573
|
+
});
|
|
3574
|
+
try {
|
|
3575
|
+
return await Promise.race([
|
|
3576
|
+
this.runConnection(pane, target, spec, session, rdp, endpoint, attempt.signal, at => { connectedAt = at; }),
|
|
3577
|
+
trapped,
|
|
3578
|
+
session.disposed.then(() => ({ connected: false })),
|
|
3579
|
+
]);
|
|
3580
|
+
}
|
|
3581
|
+
finally {
|
|
3582
|
+
attempt.abort();
|
|
3583
|
+
unwatch?.();
|
|
3584
|
+
// Rust's run() can stay pending after an asynchronous trap. Account for the completed attempt here,
|
|
3585
|
+
// exactly once, and never update retry state belonging to a replacement session in this pane.
|
|
3586
|
+
if (connectedAt !== undefined && this.sessions.get(pane) === session) {
|
|
3587
|
+
this.reconnectedUntilNow(pane, connectedAt);
|
|
3588
|
+
}
|
|
3589
|
+
session.endConnection?.();
|
|
3590
|
+
rdp.runtime?.dispose?.();
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
async runConnection(pane, target, spec, session, rdp, endpoint, signal, onConnected) {
|
|
3424
3594
|
const credentials = endpoint.credentials;
|
|
3425
|
-
const alive = () => this.sessions.get(pane) === session;
|
|
3595
|
+
const alive = () => !signal.aborted && this.sessions.get(pane) === session;
|
|
3426
3596
|
session.certificateProblem = null;
|
|
3427
3597
|
session.gatewayRefused = null;
|
|
3428
3598
|
session.withoutNla = false;
|
|
@@ -3436,7 +3606,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
3436
3606
|
session.host.replaceChildren(el);
|
|
3437
3607
|
// The element only gets ready in the page: one whose desktop was closed meanwhile (the layer gone) never does.
|
|
3438
3608
|
// Closed as it got ready, it isn't given the clipboard, files and the rest that closing it has ended already.
|
|
3439
|
-
session.ui = await Promise.race([ready, session.disposed.then(() => null)
|
|
3609
|
+
session.ui = await Promise.race([ready, session.disposed.then(() => null), new Promise(resolve => {
|
|
3610
|
+
signal.addEventListener('abort', () => resolve(null), { once: true });
|
|
3611
|
+
})]);
|
|
3440
3612
|
if (!session.ui || !alive()) {
|
|
3441
3613
|
return { connected: false };
|
|
3442
3614
|
}
|
|
@@ -3576,6 +3748,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
3576
3748
|
if (!alive()) {
|
|
3577
3749
|
return { connected: false };
|
|
3578
3750
|
}
|
|
3751
|
+
const connectedAt = Date.now();
|
|
3752
|
+
onConnected(connectedAt);
|
|
3579
3753
|
session.ui.setVisibility(true);
|
|
3580
3754
|
this.applyKeyboardMode(session);
|
|
3581
3755
|
session.remoteSize = { width, height, scale: 100 };
|
|
@@ -3617,12 +3791,14 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
3617
3791
|
}, e => session.log.push(`sign-in: ${(0, signin_1.storeName)()}: ${e?.message ?? e}`));
|
|
3618
3792
|
}
|
|
3619
3793
|
this.focusIfInFront(pane, session);
|
|
3620
|
-
const connectedAt = Date.now();
|
|
3621
3794
|
let end;
|
|
3622
3795
|
try {
|
|
3623
3796
|
end = await info.run();
|
|
3624
3797
|
}
|
|
3625
3798
|
catch (e) {
|
|
3799
|
+
if (!alive()) {
|
|
3800
|
+
return { connected: false };
|
|
3801
|
+
}
|
|
3626
3802
|
if (session.h264?.failed) {
|
|
3627
3803
|
// Reconnects (automatically, like any dropped connection) without H.264, unless the browser only
|
|
3628
3804
|
// took an idle decoder back (a hidden window): then with it, as a new stream starts with a key frame.
|
|
@@ -3643,15 +3819,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
3643
3819
|
}
|
|
3644
3820
|
return { connected: true, error };
|
|
3645
3821
|
}
|
|
3646
|
-
|
|
3647
|
-
// The server can't close the microphone, or its files, once the connection is gone.
|
|
3648
|
-
session.mic?.close();
|
|
3649
|
-
session.drives?.dispose();
|
|
3650
|
-
if (alive()) {
|
|
3651
|
-
this.reconnectedUntilNow(pane, connectedAt);
|
|
3652
|
-
}
|
|
3653
|
-
}
|
|
3654
|
-
return { connected: true, reason: end?.reason?.() };
|
|
3822
|
+
return alive() ? { connected: true, reason: end?.reason?.() } : { connected: false };
|
|
3655
3823
|
}
|
|
3656
3824
|
catch (e) {
|
|
3657
3825
|
return { connected: false, error: e?.message ?? String(e) };
|
package/dist/desktopForm.js
CHANGED
|
@@ -110,13 +110,13 @@ function askDesktop(pane, options) {
|
|
|
110
110
|
account.hidden = account.options.length < 2;
|
|
111
111
|
}
|
|
112
112
|
ownAccount();
|
|
113
|
-
// The gateway's own account
|
|
113
|
+
// The gateway's own account, including a separate prompt when no saved account is chosen.
|
|
114
114
|
const gateway = field('gateway');
|
|
115
115
|
const gatewayAccount = field('gatewayAccount');
|
|
116
116
|
gateway.value = (0, desktops_1.configText)(initial.gateway);
|
|
117
|
-
gatewayAccount.append(new Option('Gateway account: the same as the desktop\'s', ''), ...accounts.map(a => new Option(`Gateway account: ${a.name} (${(0, accounts_1.signInName)(a)})`, a.id)));
|
|
117
|
+
gatewayAccount.append(new Option('Gateway account: the same as the desktop\'s', ''), new Option('Gateway account: ask when connecting', accounts_1.ASK_GATEWAY_ACCOUNT), ...accounts.map(a => new Option(`Gateway account: ${a.name} (${(0, accounts_1.signInName)(a)})`, a.id)));
|
|
118
118
|
const initialGatewayAccount = (0, desktops_1.configText)(initial.gatewayAccount);
|
|
119
|
-
if (initialGatewayAccount && !accounts.some(a => a.id === initialGatewayAccount)) {
|
|
119
|
+
if (initialGatewayAccount && initialGatewayAccount !== accounts_1.ASK_GATEWAY_ACCOUNT && !accounts.some(a => a.id === initialGatewayAccount)) {
|
|
120
120
|
gatewayAccount.append(new Option('(a saved account that no longer exists)', initialGatewayAccount));
|
|
121
121
|
}
|
|
122
122
|
gatewayAccount.value = initialGatewayAccount;
|
package/dist/ironrdp.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.IronRDPLoader = void 0;
|
|
4
|
+
/** Loads and compiles once; every connection attempt gets fresh glue, classes, memory and allocator. */
|
|
5
|
+
class IronRDPLoader {
|
|
6
|
+
constructor(load) {
|
|
7
|
+
this.load = load;
|
|
8
|
+
this.source = null;
|
|
9
|
+
}
|
|
10
|
+
async create(level) {
|
|
11
|
+
const pending = this.source ??= this.load();
|
|
12
|
+
let source;
|
|
13
|
+
try {
|
|
14
|
+
source = await pending;
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (this.source === pending) {
|
|
18
|
+
this.source = null;
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
// A failed/trapped instance does not invalidate shared compiled code or another desktop's instance.
|
|
23
|
+
return source.createBackend(source.module, level);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
exports.IronRDPLoader = IronRDPLoader;
|
package/dist/keyboard.js
CHANGED
|
@@ -117,7 +117,18 @@ let DesktopKeyboard = class DesktopKeyboard {
|
|
|
117
117
|
route(event) {
|
|
118
118
|
const pane = this.startedOn.has(event) ? this.startedOn.get(event) : this.coveredPane();
|
|
119
119
|
const focused = document.activeElement;
|
|
120
|
-
if (
|
|
120
|
+
if (focused?.tagName !== 'IRON-REMOTE-DESKTOP') {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!pane || !pane.element.nativeElement.contains(focused)) {
|
|
124
|
+
// A desktop has the focus though its pane isn't the one the user is in (see
|
|
125
|
+
// RemoteDesktopService.letGoUnlessInFront, which takes it back a moment after the pane is left): IronRDP,
|
|
126
|
+
// whose listener comes after this one and only looks at the focus, would send it the key, view only or not.
|
|
127
|
+
// It goes nowhere instead, a keyup too: IronRDP lets go of nothing as its canvas loses the focus (only as
|
|
128
|
+
// the window does, or as the pointer leaves the canvas), so the keys the desktop still holds on the remote
|
|
129
|
+
// are let go of as the focus is taken back, this one among them.
|
|
130
|
+
event.stopImmediatePropagation();
|
|
131
|
+
event.preventDefault();
|
|
121
132
|
return;
|
|
122
133
|
}
|
|
123
134
|
this.routing = pane;
|
package/dist/rdcleanpath.js
CHANGED
|
@@ -527,6 +527,8 @@ async function handshake(openUpstream, destination, pcb, x224Request, legacyTls,
|
|
|
527
527
|
}
|
|
528
528
|
const raw = await openUpstream(destination, signal);
|
|
529
529
|
track(raw);
|
|
530
|
+
// What this handshake opened, in order, for destroyAll if it fails.
|
|
531
|
+
const opened = [raw];
|
|
530
532
|
try {
|
|
531
533
|
if (gone()) {
|
|
532
534
|
throw new Error('the client left');
|
|
@@ -556,6 +558,7 @@ async function handshake(openUpstream, destination, pcb, x224Request, legacyTls,
|
|
|
556
558
|
...name ? { host: name, ca: (0, authorities_1.certificateAuthorities)(), ...(0, net_1.isIP)(name) ? {} : { servername: name } } : {},
|
|
557
559
|
...legacyTls ? KEY_ENCIPHERMENT_ONLY_TLS : {}, ...options.tls,
|
|
558
560
|
});
|
|
561
|
+
opened.push(upstream);
|
|
559
562
|
track(upstream);
|
|
560
563
|
// An error after the one the handshake below waits for (the stream under it failing as the socket is destroyed,
|
|
561
564
|
// which a TLS socket over a stream passes on as its own) would have no listener until the relay is up, and an
|
|
@@ -571,10 +574,22 @@ async function handshake(openUpstream, destination, pcb, x224Request, legacyTls,
|
|
|
571
574
|
return { raw, upstream, x224 };
|
|
572
575
|
}
|
|
573
576
|
catch (e) {
|
|
574
|
-
|
|
577
|
+
destroyAll(opened);
|
|
575
578
|
throw e;
|
|
576
579
|
}
|
|
577
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* Destroys streams opened one over the other (the stream to the server, then TLS over it), the last opened first. TLS
|
|
583
|
+
* over a socket works inside the socket's own handle (Node's TLSWrap), and a socket destroyed under TLS still using it
|
|
584
|
+
* frees what TLS then reads: right after the handshake, where a certificate refused at once ends the connection, that
|
|
585
|
+
* crashed Tabby's window (a segmentation fault, in Node as in Electron). TLS destroyed first is done with the socket
|
|
586
|
+
* before the socket goes. (Over a stream of JavaScript, an SSH channel or a gateway's tunnel, the order did no harm.)
|
|
587
|
+
*/
|
|
588
|
+
function destroyAll(streams) {
|
|
589
|
+
for (const stream of [...streams].reverse()) {
|
|
590
|
+
stream.destroy();
|
|
591
|
+
}
|
|
592
|
+
}
|
|
578
593
|
/** Whether `given` is the secret, compared in a time that doesn't tell how much of it was right. */
|
|
579
594
|
function sameSecret(given, secret) {
|
|
580
595
|
const bytes = Buffer.from(given);
|
|
@@ -666,9 +681,7 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
|
|
|
666
681
|
closed = true;
|
|
667
682
|
leaving.abort();
|
|
668
683
|
clearTimeout(deadline);
|
|
669
|
-
|
|
670
|
-
stream.destroy();
|
|
671
|
-
}
|
|
684
|
+
destroyAll(streams);
|
|
672
685
|
// What was handed to this connection went with it: the next starts with all the room.
|
|
673
686
|
if (flowClient === ws) {
|
|
674
687
|
flowClient = null;
|