tabby-rdp 0.5.1 → 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,12 +23,14 @@ 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");
29
30
  const drives_1 = require("./drives");
30
31
  const desktopForm_1 = require("./desktopForm");
31
32
  const accounts_1 = require("./accounts");
33
+ const accountRevisions_1 = require("./accountRevisions");
32
34
  const accountForm_1 = require("./accountForm");
33
35
  const fileTransfer_1 = require("./fileTransfer");
34
36
  const desktops_1 = require("./desktops");
@@ -45,6 +47,7 @@ const gateway_1 = require("./gateway");
45
47
  const authorities_1 = require("./authorities");
46
48
  const vms_1 = require("./vms");
47
49
  const sessionLog_1 = require("./sessionLog");
50
+ const hostCompat_1 = require("./hostCompat");
48
51
  const unshowable_1 = require("./unshowable");
49
52
  /**
50
53
  * The keychain entry that marks the passwords 0.5.0 saved for desktops reached through a gateway forgotten from this
@@ -82,6 +85,7 @@ const STYLE = `
82
85
  .trd-overlay { position: absolute; inset: 0; z-index: 30; display: flex; background: #111; }
83
86
  .trd-host { flex: auto; min-width: 0; min-height: 0; display: flex; }
84
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; }
85
89
  .trd-status { position: absolute; inset: 0; display: flex; flex-direction: column; gap: 14px; align-items: center;
86
90
  justify-content: center; padding: 2em; text-align: center; color: #bbb; font-size: 13px; white-space: pre-line;
87
91
  pointer-events: none; }
@@ -105,6 +109,10 @@ const STYLE = `
105
109
  .trd-overlay.trd-mic-on > .trd-toast { right: 40px; max-width: min(420px, calc(100% - 52px)); }
106
110
  .trd-floating-mic-on .trd-overlay.trd-broadcast-on::before { top: 40px; }
107
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
+ }
108
116
  /**
109
117
  * The name a desktop connected to directly goes by, as its certificate is checked against it (see
110
118
  * RDCleanPathOptions.serverName): an address as it is, a host name as it resolves (lower case, punycode, without a
@@ -259,11 +267,24 @@ class DesktopSession {
259
267
  /** Keys this desktop was sent and not yet let go of (by `code`): see RemoteDesktopService.broadcastKey. */
260
268
  this.keysDown = new Map();
261
269
  this.reclaimFocus = (event) => {
262
- if (this.visible && !this.overlay.contains(event.target)) {
270
+ if (!this.visible) {
271
+ return;
272
+ }
273
+ if (!this.overlay.contains(event.target)) {
263
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);
264
284
  }
265
285
  };
266
286
  this.disposers = [];
287
+ this.connectionDisposers = [];
267
288
  this.disposed = new Promise(resolve => { this.markDisposed = resolve; });
268
289
  this.overlay = document.createElement('div');
269
290
  this.overlay.className = 'trd-overlay';
@@ -302,7 +323,9 @@ class DesktopSession {
302
323
  }
303
324
  /**
304
325
  * Lets go of every key still held on this desktop. For when their keyups won't reach it: view only coming on, or
305
- * 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.
306
329
  */
307
330
  releaseKeys() {
308
331
  for (const [code, init] of [...this.keysDown]) {
@@ -332,6 +355,33 @@ class DesktopSession {
332
355
  canvas() {
333
356
  return this.host.querySelector('iron-remote-desktop')?.shadowRoot?.querySelector('canvas') ?? null;
334
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
+ }
335
385
  /**
336
386
  * Focuses the remote desktop in a moment, if it is still in the pane the user is in then (see inFront). `visible`
337
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
@@ -348,7 +398,7 @@ class DesktopSession {
348
398
  setVisible(visible) {
349
399
  this.visible = visible;
350
400
  this.overlay.style.display = visible ? '' : 'none';
351
- this.container.querySelectorAll('textarea.xterm-helper-textarea').forEach(input => {
401
+ (0, hostCompat_1.terminalInputs)(this.container).forEach(input => {
352
402
  input.disabled = visible;
353
403
  });
354
404
  if (visible) {
@@ -384,6 +434,58 @@ class DesktopSession {
384
434
  onDispose(f) {
385
435
  this.disposers.push(f);
386
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
+ }
387
489
  /** Focuses the remote desktop. IronRDP reveals its canvas asynchronously, so retry for about half a second. */
388
490
  focusDesktop(attempts = 30) {
389
491
  // While the sign-in form shows, it is what takes focus back (the user name, or the password once filled).
@@ -415,16 +517,7 @@ class DesktopSession {
415
517
  clearTimeout(this.labelTimer);
416
518
  this.container.removeEventListener('focusin', this.reclaimFocus, true);
417
519
  this.disposers.forEach(f => f());
418
- this.indicator?.dispose();
419
- try {
420
- this.ui?.shutdown();
421
- }
422
- catch { }
423
- this.audio?.close();
424
- this.h264?.close();
425
- this.mic?.close();
426
- this.files?.dispose();
427
- this.drives?.dispose();
520
+ this.endConnection(false);
428
521
  this.proxy?.close();
429
522
  this.overlay.remove();
430
523
  }
@@ -449,7 +542,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
449
542
  /** Emits whenever a pane's desktop is opened, shown, hidden, connected, ended or disconnected. */
450
543
  this.changed$ = new rxjs_1.Subject();
451
544
  this.sessions = new Map();
452
- 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
+ });
453
552
  /** Whether the browser decodes H.264 (asked once). */
454
553
  this.h264Support = null;
455
554
  /** Desktops (session keys) where H.264 decoding failed: they connect without it until Tabby restarts. */
@@ -478,6 +577,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
478
577
  /** VMs found on SSH hosts (see vms.ts), by target key: when, and as desktops; and what the scans learnt of the host. */
479
578
  this.vms = new Map();
480
579
  this.lastUsed = new Map();
580
+ this.accountRevisions = new accountRevisions_1.AccountRevisions();
481
581
  /** Each pane's `desk` requests, one at a time (see DeskGate). */
482
582
  this.deskGates = new WeakMap();
483
583
  /**
@@ -833,6 +933,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
833
933
  accounts() {
834
934
  return (0, accounts_1.accountsOf)(this.config.store.remoteDesktop);
835
935
  }
936
+ accountCurrent(key, name, revision) {
937
+ const account = this.accounts().find(a => (0, accounts_1.accountKey)(a.id) === key);
938
+ return !!account && (0, accounts_1.signInName)(account) === name && (account.credentialRevision ?? '') === revision && this.accountRevisions.current(account.id, revision);
939
+ }
836
940
  /**
837
941
  * The desktops that sign in with a saved account, or sign in to their RD Gateway with it: configured ones behind
838
942
  * SSH hosts, and RDP profiles (their own choice, or their profile group's default when they make none). Each comes
@@ -842,10 +946,14 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
842
946
  // Names as text whatever the config holds (see entryText): the accounts list and the question before removing
843
947
  // one show them all.
844
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 }] : []);
845
- const groups = this.config.store.groups ?? [];
846
- const inherited = (p) => p.options?.account === undefined && groups.find(g => g.id === p.group)?.defaults?.[targets_1.RDP_PROFILE_TYPE]?.options?.account === id;
847
949
  const profiles = (this.config.store.profiles ?? [])
848
- .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
+ })
849
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 }));
850
958
  return [...desktops, ...profiles];
851
959
  }
@@ -863,14 +971,31 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
863
971
  username: account.username.trim(),
864
972
  ...account.domain?.trim() ? { domain: account.domain.trim() } : {},
865
973
  };
974
+ let revision;
975
+ try {
976
+ revision = this.accountRevisions.change(entry.id);
977
+ }
978
+ catch (error) {
979
+ return { id: entry.id, keychainError: error?.message ?? String(error) };
980
+ }
981
+ entry.credentialRevision = revision;
982
+ // Invalidate before any store await. Keep the old sign-in name until changing its password succeeds.
983
+ if (old) {
984
+ this.config.store.remoteDesktop.accounts = list.map(a => a.id === old.id ? { ...a, credentialRevision: revision } : a);
985
+ await this.config.save();
986
+ }
987
+ const current = () => this.accountRevisions.current(entry.id, revision) && (!old || this.accounts().some(a => a.id === entry.id && a.credentialRevision === revision));
866
988
  // The password first: a new user name must not be written while the old password (for the old name) stays, nor
867
989
  // a new password reported as saved when the store refused it.
868
990
  let keychainError;
869
991
  if (password !== undefined && password !== '') {
870
- await (0, signin_1.saveCredentials)((0, accounts_1.accountKey)(entry.id), { username: (0, accounts_1.signInName)(entry), password }, `saved account ${entry.name}`).catch(e => { keychainError = String(e?.message ?? e); });
992
+ await (0, signin_1.saveCredentialsIf)((0, accounts_1.accountKey)(entry.id), { username: (0, accounts_1.signInName)(entry), password }, current, `saved account ${entry.name}`).catch(e => { keychainError = String(e?.message ?? e); });
871
993
  }
872
994
  else if (old && (0, accounts_1.signInName)(old) !== (0, accounts_1.signInName)(entry)) {
873
- await (0, signin_1.forgetCredentialsOrFail)((0, accounts_1.accountKey)(entry.id)).catch(e => { keychainError = `the old password couldn't be removed: ${e?.message ?? e}`; });
995
+ await (0, signin_1.forgetCredentialsIf)((0, accounts_1.accountKey)(entry.id), current).catch(e => { keychainError = `the old password couldn't be removed: ${e?.message ?? e}`; });
996
+ }
997
+ if (!current()) {
998
+ return { id: entry.id, keychainError: 'The account changed again while this edit was being saved. The newer change was kept.' };
874
999
  }
875
1000
  if (keychainError && !old) {
876
1001
  // Not added at all: a retry would otherwise make a second account.
@@ -886,7 +1011,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
886
1011
  delete entry.domain;
887
1012
  }
888
1013
  }
889
- this.config.store.remoteDesktop.accounts = old ? list.map(a => a === old ? entry : a) : [...list, entry];
1014
+ const now = this.accounts();
1015
+ this.config.store.remoteDesktop.accounts = old ? now.map(a => a.id === old.id ? entry : a) : [...now, entry];
890
1016
  await this.config.save();
891
1017
  this.changed$.next();
892
1018
  return { id: entry.id, keychainError };
@@ -923,11 +1049,18 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
923
1049
  if (response !== 0) {
924
1050
  return false;
925
1051
  }
926
- await this.removeAccount(id);
927
- return true;
1052
+ try {
1053
+ await this.removeAccount(id);
1054
+ return true;
1055
+ }
1056
+ catch (error) {
1057
+ this.notifications.error(`The account could not be fully removed: ${error?.message ?? error}`);
1058
+ return false;
1059
+ }
928
1060
  }
929
1061
  /** Removes a saved account and its password; desktops that used it go back to asking. */
930
1062
  async removeAccount(id) {
1063
+ const revision = this.accountRevisions.change(id);
931
1064
  const store = this.config.store.remoteDesktop;
932
1065
  store.accounts = this.accounts().filter(a => a.id !== id);
933
1066
  if (this.configuredDesktops().some(d => (0, desktops_1.isDesktopEntry)(d) && (d.account === id || d.gatewayAccount === id))) {
@@ -939,9 +1072,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
939
1072
  if (rest.account === id) {
940
1073
  delete rest.account;
941
1074
  }
942
- // 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.
943
1076
  if (rest.gatewayAccount === id) {
944
- delete rest.gatewayAccount;
1077
+ rest.gatewayAccount = accounts_1.ASK_GATEWAY_ACCOUNT;
945
1078
  }
946
1079
  return rest;
947
1080
  });
@@ -952,19 +1085,23 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
952
1085
  profile.options.account = null;
953
1086
  }
954
1087
  if (profile?.type === targets_1.RDP_PROFILE_TYPE && profile.options?.gatewayAccount === id) {
955
- profile.options.gatewayAccount = null;
1088
+ profile.options.gatewayAccount = accounts_1.ASK_GATEWAY_ACCOUNT;
956
1089
  }
957
1090
  }
958
- // A profile group's default for remote desktop profiles, too.
959
- for (const group of this.config.store.groups ?? []) {
960
- 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;
961
1095
  if (options?.account === id) {
962
1096
  options.account = '';
963
1097
  }
1098
+ if (options?.gatewayAccount === id) {
1099
+ options.gatewayAccount = accounts_1.ASK_GATEWAY_ACCOUNT;
1100
+ }
964
1101
  }
965
1102
  await this.config.save();
966
1103
  this.changed$.next();
967
- await (0, signin_1.forgetCredentialsOrFail)((0, accounts_1.accountKey)(id)).catch(e => this.notifications.error(`The account is removed, but its password could not be: ${e?.message ?? e}`));
1104
+ await (0, signin_1.forgetCredentialsIf)((0, accounts_1.accountKey)(id), () => this.accountRevisions.current(id, revision)).catch(e => this.notifications.error(`The account is removed, but its password could not be: ${e?.message ?? e}`));
968
1105
  }
969
1106
  /** `remoteDesktop.desktops`: the desktops configured behind SSH hosts. */
970
1107
  configuredDesktops() {
@@ -1138,6 +1275,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1138
1275
  }
1139
1276
  }
1140
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
+ }
1141
1282
  /** "Import an .rdp file…": picks one and adds it as a remote desktop profile (see importRdp). */
1142
1283
  async importRdpFile() {
1143
1284
  const file = await new Promise(resolve => {
@@ -1151,6 +1292,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1151
1292
  input.click();
1152
1293
  });
1153
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
+ }
1154
1300
  // A reading or parsing failure is shown, not dropped as an unhandled rejection: this is the user's action.
1155
1301
  try {
1156
1302
  await this.importRdp(new Uint8Array(await file.arrayBuffer()), file.name);
@@ -1184,8 +1330,12 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1184
1330
  * and opens it; or opens the profile that already has that address, user and domain. Returns the profile, or null.
1185
1331
  */
1186
1332
  async importRdp(data, rawFileName) {
1187
- // The file's name comes with the file, and is shown: what could reorder or hide part of it shows as �.
1188
- 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
+ }
1189
1339
  const parsed = (0, rdpFile_1.parseRdpFile)(data);
1190
1340
  if (!parsed) {
1191
1341
  this.notifications.error(`${fileName}: no address ("full address") in it`);
@@ -1510,6 +1660,16 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1510
1660
  }
1511
1661
  });
1512
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());
1513
1673
  this.connect(pane, target, spec, created);
1514
1674
  }
1515
1675
  if (options.background) {
@@ -1577,10 +1737,29 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1577
1737
  inFront(pane) {
1578
1738
  return (0, targets_1.desktopPaneOf)(this.app.activeTab) === pane;
1579
1739
  }
1580
- /** 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
+ */
1581
1744
  showsInFront(session) {
1582
1745
  const pane = (0, targets_1.desktopPaneOf)(this.app.activeTab);
1583
- 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();
1584
1763
  }
1585
1764
  /**
1586
1765
  * Gives a desktop that just connected the keyboard, if it shows in the pane the user is in. Not when another pane is
@@ -2143,7 +2322,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2143
2322
  timer = setTimeout(() => this.followPane(pane, session), 300);
2144
2323
  });
2145
2324
  observer.observe(session.overlay);
2146
- session.onDispose(() => {
2325
+ session.onConnectionEnd(() => {
2147
2326
  clearTimeout(timer);
2148
2327
  observer.disconnect();
2149
2328
  });
@@ -2618,25 +2797,12 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2618
2797
  return this.sessions.get(pane)?.log ?? [];
2619
2798
  }
2620
2799
  loadIronRDP() {
2621
- this.ironrdp ??= (async () => {
2622
- // The WebAssembly ships as its own file (not inlined in the bundle). It is read here and handed to init,
2623
- // rather than fetched by the bundle from next to itself: fetch() of file:// URLs is up to the Electron
2624
- // build (a fuse turns it off), and a plain file path needs no URL escaping for spaces or drive letters.
2625
- const [rdp, wasm] = await Promise.all([
2626
- importESM(vendor('iron-remote-desktop.js')).then(() => importESM(vendor('iron-remote-desktop-rdp.js'))),
2627
- fs.promises.readFile(vendorFile('ironrdp_web_bg.wasm')),
2628
- ]);
2629
- // Troubleshooting: localStorage.trdLogLevel = 'DEBUG' (or TRACE), then restart Tabby.
2630
- let level = 'INFO';
2631
- try {
2632
- level = localStorage.getItem('trdLogLevel') || level;
2633
- }
2634
- catch { }
2635
- await rdp.init(level, wasm);
2636
- return rdp;
2637
- })();
2638
- this.ironrdp.catch(() => { this.ironrdp = null; });
2639
- return this.ironrdp;
2800
+ let level = 'INFO';
2801
+ try {
2802
+ level = localStorage.getItem('trdLogLevel') || level;
2803
+ }
2804
+ catch { }
2805
+ return this.ironrdp.create(level);
2640
2806
  }
2641
2807
  /**
2642
2808
  * Where to connect and as whom. The host's own desktop: the setup script (grd, generated credentials), or, where
@@ -2721,6 +2887,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2721
2887
  }
2722
2888
  session.status('');
2723
2889
  const mark = (0, signin_1.forgetMark)();
2890
+ const accountRevision = account && this.accountRevisions.capture(account);
2724
2891
  // The host's own desktops (its own, or its xrdp besides GNOME) and direct ones need no "via". A machine reached
2725
2892
  // with ssh typed in a host's console says which host: that host is what leads there.
2726
2893
  const hostOwn = spec.id === desktops_1.OWN_DESKTOP || spec.kind === 'xrdp' && spec.host === '127.0.0.1';
@@ -2729,8 +2896,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2729
2896
  title: spec.hyperv ? `Sign in to ${target.label}${target.via ? ` (via ${target.via})` : ''}, to open ${spec.name}`
2730
2897
  : hostOwn && !target.via || target.direct ? `Sign in to ${spec.name}` : `Sign in to ${spec.name} (via ${via})`,
2731
2898
  username: account ? (0, accounts_1.signInName)(account) : spec.username,
2732
- error: retryError,
2733
- canRemember: true,
2899
+ error: [retryError, account && accountRevision === undefined ? accountRevisions_1.ACCOUNT_REMEMBER_UNAVAILABLE : ''].filter(Boolean).join(' '),
2900
+ canRemember: !account || accountRevision !== undefined,
2734
2901
  account: account?.name,
2735
2902
  }, session.disposed);
2736
2903
  // Only in the pane in front, as when connected (see run()): the form gets the keyboard when its pane does.
@@ -2741,7 +2908,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2741
2908
  return entered && {
2742
2909
  host: spec.host, port: spec.port, credentials: entered, remember: entered.remember, domain: account ? account.domain ?? '' : undefined,
2743
2910
  saveKey: account && (0, accounts_1.accountKey)(account.id), saveLabel: account && `saved account ${account.name}`, saveFor: account && (0, accounts_1.signInName)(account),
2744
- hyperv, mark,
2911
+ hyperv, mark, accountRevision,
2745
2912
  };
2746
2913
  }
2747
2914
  async connect(pane, target, spec, session) {
@@ -2886,35 +3053,35 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2886
3053
  }
2887
3054
  /**
2888
3055
  * The sign-in to a desktop's RD Gateway with the saved account it names: the account's password from the keychain,
2889
- * or asked for (when none is saved, or `retryError`: the gateway just refused it). Undefined when that account no
2890
- * 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.
2891
3058
  */
2892
3059
  async gatewayAccountFor(spec, session, gateway, retryError) {
2893
3060
  const account = this.accounts().find(a => a.id === spec.gatewayAccount);
2894
3061
  if (!account) {
2895
- session.log.push('gateway: its saved account no longer exists; signing in with the desktop\'s');
2896
- return undefined;
3062
+ session.log.push('gateway: asking for a separate account');
2897
3063
  }
2898
- const signIn = { domain: account.domain, saveKey: (0, accounts_1.accountKey)(account.id), saveLabel: `saved account ${account.name}`, saveFor: (0, accounts_1.signInName)(account) };
2899
- const saved = retryError ? null : await (0, signin_1.loadCredentials)(signIn.saveKey);
2900
- 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)) {
2901
3067
  session.log.push(`gateway: the saved account "${account.name}"`);
2902
3068
  return { ...signIn, credentials: { username: (0, accounts_1.signInName)(account), password: saved.password }, remember: false };
2903
3069
  }
2904
3070
  session.status('');
2905
3071
  const asked = (0, signin_1.askCredentials)(session.overlay, {
2906
3072
  title: `Sign in to the gateway ${gateway.host}, to reach ${spec.name}`,
2907
- username: (0, accounts_1.signInName)(account),
2908
- error: retryError,
2909
- canRemember: true,
2910
- 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,
2911
3078
  }, session.disposed);
2912
3079
  // Only in the pane in front (see endpointFor).
2913
3080
  if (session.visible && this.showsInFront(session)) {
2914
3081
  setTimeout(() => session.visible && this.showsInFront(session) && session.focusDesktop());
2915
3082
  }
2916
3083
  const entered = await asked;
2917
- return entered && { ...signIn, credentials: entered, remember: entered.remember };
3084
+ return entered && { ...signIn, credentials: entered, remember: !!signIn && entered.remember };
2918
3085
  }
2919
3086
  /**
2920
3087
  * The proxy's stream to a desktop behind an RD Gateway (see gateway.ts): to the gateway as the SSH host (or this
@@ -2923,17 +3090,18 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2923
3090
  async throughGateway(target, spec, session, gateway, endpoint, signal) {
2924
3091
  session.gatewayRefused = null;
2925
3092
  const own = endpoint.gatewayAccount;
3093
+ if (spec.gatewayAccount && !own) {
3094
+ throw new Error('A separate gateway sign-in is required before connecting.');
3095
+ }
2926
3096
  const credentials = own?.credentials ?? endpoint.credentials;
2927
- 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'}`);
2928
3098
  try {
2929
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 });
2930
3100
  // Entered a moment ago and taken by the gateway: kept, while the account is still what it was signed in as.
2931
- if (own?.remember) {
3101
+ if (own?.remember && own.saveKey) {
2932
3102
  own.remember = false;
2933
- const accountNow = this.accounts().find(a => (0, accounts_1.accountKey)(a.id) === own.saveKey);
2934
- if (accountNow && (0, accounts_1.signInName)(accountNow) === own.saveFor) {
2935
- (0, signin_1.saveCredentials)(own.saveKey, own.credentials, own.saveLabel).then(() => session.log.push(`gateway: saved as the account's password in ${(0, signin_1.storeName)()}`), e => session.log.push(`gateway: ${(0, signin_1.storeName)()}: ${e?.message ?? e}`));
2936
- }
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}`));
2937
3105
  }
2938
3106
  return stream;
2939
3107
  }
@@ -3391,8 +3559,40 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
3391
3559
  }
3392
3560
  /** One RDP connection attempt, until it ends. */
3393
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) {
3394
3594
  const credentials = endpoint.credentials;
3395
- const alive = () => this.sessions.get(pane) === session;
3595
+ const alive = () => !signal.aborted && this.sessions.get(pane) === session;
3396
3596
  session.certificateProblem = null;
3397
3597
  session.gatewayRefused = null;
3398
3598
  session.withoutNla = false;
@@ -3406,7 +3606,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
3406
3606
  session.host.replaceChildren(el);
3407
3607
  // The element only gets ready in the page: one whose desktop was closed meanwhile (the layer gone) never does.
3408
3608
  // Closed as it got ready, it isn't given the clipboard, files and the rest that closing it has ended already.
3409
- 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
+ })]);
3410
3612
  if (!session.ui || !alive()) {
3411
3613
  return { connected: false };
3412
3614
  }
@@ -3546,6 +3748,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
3546
3748
  if (!alive()) {
3547
3749
  return { connected: false };
3548
3750
  }
3751
+ const connectedAt = Date.now();
3752
+ onConnected(connectedAt);
3549
3753
  session.ui.setVisibility(true);
3550
3754
  this.applyKeyboardMode(session);
3551
3755
  session.remoteSize = { width, height, scale: 100 };
@@ -3564,11 +3768,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
3564
3768
  this.changed$.next();
3565
3769
  // Under a saved account only while that account is still there with the same sign-in name: the prompt may
3566
3770
  // have been open while it was removed or changed in Settings.
3567
- const accountNow = endpoint.saveKey ? this.accounts().find(a => (0, accounts_1.accountKey)(a.id) === endpoint.saveKey) : undefined;
3568
- if (endpoint.remember && endpoint.saveKey && (!accountNow || (0, accounts_1.signInName)(accountNow) !== endpoint.saveFor)) {
3569
- session.log.push(`sign-in: not saved: the account ${accountNow ? 'changed' : 'was removed'} meanwhile`);
3771
+ if (endpoint.remember && endpoint.saveKey) {
3772
+ const key = endpoint.saveKey;
3773
+ (0, signin_1.saveCredentialsIf)(key, credentials, () => this.accountCurrent(key, endpoint.saveFor, endpoint.accountRevision), endpoint.saveLabel).then(saved => session.log.push(saved ? `sign-in: saved as the account's password in ${(0, signin_1.storeName)()}` : 'sign-in: not saved: the account changed meanwhile'), e => session.log.push(`sign-in: ${(0, signin_1.storeName)()}: ${e?.message ?? e}`));
3570
3774
  }
3571
- else if (endpoint.remember && !endpoint.saveKey && endpoint.mark !== undefined && (0, signin_1.forgottenSince)(session.credentialKey, endpoint.mark)) {
3775
+ else if (endpoint.remember && endpoint.mark !== undefined && (0, signin_1.forgottenSince)(session.credentialKey, endpoint.mark)) {
3572
3776
  // Edited (its user name, gateway or address) or removed while the server delayed the sign-in: what was
3573
3777
  // forgotten then stays forgotten, and the desktop asks the next time.
3574
3778
  session.log.push('sign-in: not saved: the desktop was changed or removed meanwhile');
@@ -3587,12 +3791,14 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
3587
3791
  }, e => session.log.push(`sign-in: ${(0, signin_1.storeName)()}: ${e?.message ?? e}`));
3588
3792
  }
3589
3793
  this.focusIfInFront(pane, session);
3590
- const connectedAt = Date.now();
3591
3794
  let end;
3592
3795
  try {
3593
3796
  end = await info.run();
3594
3797
  }
3595
3798
  catch (e) {
3799
+ if (!alive()) {
3800
+ return { connected: false };
3801
+ }
3596
3802
  if (session.h264?.failed) {
3597
3803
  // Reconnects (automatically, like any dropped connection) without H.264, unless the browser only
3598
3804
  // took an idle decoder back (a hidden window): then with it, as a new stream starts with a key frame.
@@ -3613,15 +3819,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
3613
3819
  }
3614
3820
  return { connected: true, error };
3615
3821
  }
3616
- finally {
3617
- // The server can't close the microphone, or its files, once the connection is gone.
3618
- session.mic?.close();
3619
- session.drives?.dispose();
3620
- if (alive()) {
3621
- this.reconnectedUntilNow(pane, connectedAt);
3622
- }
3623
- }
3624
- return { connected: true, reason: end?.reason?.() };
3822
+ return alive() ? { connected: true, reason: end?.reason?.() } : { connected: false };
3625
3823
  }
3626
3824
  catch (e) {
3627
3825
  return { connected: false, error: e?.message ?? String(e) };
@@ -3647,7 +3845,7 @@ exports.RemoteDesktopService = RemoteDesktopService = RemoteDesktopService_1 = _
3647
3845
  tabby_core_1.PlatformService])
3648
3846
  ], RemoteDesktopService);
3649
3847
  let styleInstalled = false;
3650
- function installStyle() {
3848
+ function installStyle(compat) {
3651
3849
  if (styleInstalled) {
3652
3850
  return;
3653
3851
  }
@@ -3655,7 +3853,7 @@ function installStyle() {
3655
3853
  const style = document.createElement('style');
3656
3854
  style.textContent = STYLE + signin_1.STYLE + fileTransfer_1.STYLE + connectionStatus_1.STYLE + osd_1.OSD_STYLE + DRAGBAR_STYLE + accountForm_1.STYLE;
3657
3855
  document.head.appendChild(style);
3658
- keepWindowDraggable();
3856
+ keepWindowDraggable(compat);
3659
3857
  }
3660
3858
  /** A strip the height of the tab bar that stays a window drag region over a dialog (the plugin's, and Tabby's). */
3661
3859
  const DRAGBAR_STYLE = `
@@ -3663,12 +3861,14 @@ const DRAGBAR_STYLE = `
3663
3861
  .modal > .trd-form-dragbar { display: block; position: fixed; left: 0; right: 0; top: 0; height: var(--tabs-height, 38px); -webkit-app-region: drag; }
3664
3862
  `;
3665
3863
  /**
3666
- * Tabby's dialogs (ng-bootstrap modals) cover the whole window, tab bar included, with nothing marked as a drag
3667
- * region, so the window can't be moved while one shows. Each gets a strip where the tab bar is; the dialog box itself
3668
- * is below it. The plugin's own dialogs do the same (see settingsPage.ts).
3864
+ * Older Tabby dialogs cover the tab bar without a drag region. Supply their fallback strip, deferring to the host's
3865
+ * modal pseudo-element when it provides one. The plugin's own dialogs still need their strips (see settingsPage.ts).
3669
3866
  */
3670
- function keepWindowDraggable() {
3867
+ function keepWindowDraggable(compat) {
3671
3868
  const strip = (modal) => {
3869
+ if (compat.hasNativeModalDragRegion(modal)) {
3870
+ return;
3871
+ }
3672
3872
  if (!modal.querySelector(':scope > .trd-form-dragbar')) {
3673
3873
  const bar = document.createElement('div');
3674
3874
  bar.className = 'trd-form-dragbar';