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.
@@ -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 && !this.overlay.contains(event.target)) {
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. They would stay down there.
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.indicator?.dispose();
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 = null;
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) => p?.type === targets_1.RDP_PROFILE_TYPE && (p.options?.account === id || p.options?.gatewayAccount === id || inherited(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
- // A gateway that signed in with it takes the desktop's sign-in from now on.
1075
+ // Keep the gateway's credentials separate after its saved account is removed.
975
1076
  if (rest.gatewayAccount === id) {
976
- delete rest.gatewayAccount;
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 = null;
1088
+ profile.options.gatewayAccount = accounts_1.ASK_GATEWAY_ACCOUNT;
988
1089
  }
989
1090
  }
990
- // A profile group's default for remote desktop profiles, too.
991
- for (const group of this.config.store.groups ?? []) {
992
- const options = group?.defaults?.[targets_1.RDP_PROFILE_TYPE]?.options;
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
- // The file's name comes with the file, and is shown: what could reorder or hide part of it shows as �.
1220
- const fileName = rawFileName.replace(new RegExp(unshowable_1.UNSHOWABLE.source, 'gu'), '�');
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
- /** Whether `session` is the desktop of the pane the user is in (see inFront). */
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.onDispose(() => {
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
- this.ironrdp ??= (async () => {
2654
- // The WebAssembly ships as its own file (not inlined in the bundle). It is read here and handed to init,
2655
- // rather than fetched by the bundle from next to itself: fetch() of file:// URLs is up to the Electron
2656
- // build (a fuse turns it off), and a plain file path needs no URL escaping for spaces or drive letters.
2657
- const [rdp, wasm] = await Promise.all([
2658
- importESM(vendor('iron-remote-desktop.js')).then(() => importESM(vendor('iron-remote-desktop-rdp.js'))),
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). Undefined when that account no
2923
- * longer exists (the desktop's own sign-in is then tried); null when the form was cancelled.
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: its saved account no longer exists; signing in with the desktop\'s');
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 ? null : await (0, signin_1.loadCredentials)(signIn.saveKey);
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, signIn.accountRevision === undefined ? accountRevisions_1.ACCOUNT_REMEMBER_UNAVAILABLE : ''].filter(Boolean).join(' '),
2942
- canRemember: signIn.accountRevision !== undefined,
2943
- account: account.name,
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 saved account' : 'the desktop\'s sign-in'}`);
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
- (0, signin_1.saveCredentialsIf)(own.saveKey, own.credentials, () => this.accountCurrent(own.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}`));
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
- finally {
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) };
@@ -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: offered once there is a gateway and a saved account to choose.
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;
@@ -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 (!pane || focused?.tagName !== 'IRON-REMOTE-DESKTOP' || !pane.element.nativeElement.contains(focused)) {
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;
@@ -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
- raw.destroy();
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
- for (const stream of streams) {
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;