tabby-rdp 0.5.0 → 0.5.1

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +490 -0
  2. package/README.md +177 -48
  3. package/dist/accountForm.js +0 -1
  4. package/dist/accounts.js +0 -1
  5. package/dist/audio.js +0 -1
  6. package/dist/authorities.js +331 -0
  7. package/dist/byteQueue.js +102 -0
  8. package/dist/clipboard.js +106 -0
  9. package/dist/connectionStatus.js +0 -1
  10. package/dist/desk.js +16 -47
  11. package/dist/deskScript.js +196 -6
  12. package/dist/desktop.service.js +1442 -244
  13. package/dist/desktopForm.js +51 -17
  14. package/dist/desktops.js +232 -24
  15. package/dist/drives.js +271 -30
  16. package/dist/fileTransfer.js +958 -83
  17. package/dist/gateway.js +216 -57
  18. package/dist/header.js +76 -5
  19. package/dist/help.js +34 -13
  20. package/dist/hyperv.js +65 -15
  21. package/dist/index.js +2 -2
  22. package/dist/keyboard.js +0 -1
  23. package/dist/microphone.js +57 -13
  24. package/dist/ntlm.js +0 -1
  25. package/dist/osd.js +56 -15
  26. package/dist/probe.js +15 -3
  27. package/dist/rdcleanpath.js +319 -44
  28. package/dist/rdpFile.js +22 -7
  29. package/dist/rdpProfile.js +39 -21
  30. package/dist/remoteSetup.js +103 -23
  31. package/dist/sessionLog.js +53 -0
  32. package/dist/settingsPage.js +65 -29
  33. package/dist/signin.js +407 -40
  34. package/dist/ssh.js +99 -18
  35. package/dist/sshChannelStream.js +40 -6
  36. package/dist/targets.js +185 -39
  37. package/dist/ui.js +88 -20
  38. package/dist/unshowable.js +41 -0
  39. package/dist/updates.js +37 -2
  40. package/dist/vms.js +17 -3
  41. package/dist/wake.js +15 -8
  42. package/package.json +4 -3
  43. package/remote/trd-pty.py +74 -13
  44. package/vendor/SHA256SUMS +26 -0
  45. package/vendor/ironrdp_web_bg.wasm +0 -0
  46. package/dist/accountForm.js.map +0 -1
  47. package/dist/accounts.js.map +0 -1
  48. package/dist/audio.js.map +0 -1
  49. package/dist/connectionStatus.js.map +0 -1
  50. package/dist/desk.js.map +0 -1
  51. package/dist/deskScript.js.map +0 -1
  52. package/dist/desktop.service.js.map +0 -1
  53. package/dist/desktopForm.js.map +0 -1
  54. package/dist/desktops.js.map +0 -1
  55. package/dist/drives.js.map +0 -1
  56. package/dist/fileTransfer.js.map +0 -1
  57. package/dist/gateway.js.map +0 -1
  58. package/dist/header.js.map +0 -1
  59. package/dist/help.js.map +0 -1
  60. package/dist/hyperv.js.map +0 -1
  61. package/dist/index.js.map +0 -1
  62. package/dist/keyboard.js.map +0 -1
  63. package/dist/microphone.js.map +0 -1
  64. package/dist/ntlm.js.map +0 -1
  65. package/dist/osd.js.map +0 -1
  66. package/dist/probe.js.map +0 -1
  67. package/dist/rdcleanpath.js.map +0 -1
  68. package/dist/rdpFile.js.map +0 -1
  69. package/dist/rdpProfile.js.map +0 -1
  70. package/dist/remoteSetup.js.map +0 -1
  71. package/dist/settingsPage.js.map +0 -1
  72. package/dist/signin.js.map +0 -1
  73. package/dist/ssh.js.map +0 -1
  74. package/dist/sshChannelStream.js.map +0 -1
  75. package/dist/targets.js.map +0 -1
  76. package/dist/ui.js.map +0 -1
  77. package/dist/updates.js.map +0 -1
  78. package/dist/vms.js.map +0 -1
  79. package/dist/wake.js.map +0 -1
@@ -10,7 +10,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
10
10
  };
11
11
  var RemoteDesktopService_1;
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.RemoteDesktopService = void 0;
13
+ exports.RemoteDesktopService = exports.DesktopSession = exports.GATEWAY_PASSWORDS_MARK = void 0;
14
14
  exports.installStyle = installStyle;
15
15
  const core_1 = require("@angular/core");
16
16
  const rxjs_1 = require("rxjs");
@@ -18,10 +18,12 @@ const tabby_core_1 = require("tabby-core");
18
18
  const fs = require("fs");
19
19
  const os = require("os");
20
20
  const path = require("path");
21
+ const net_1 = require("net");
21
22
  const url_1 = require("url");
22
23
  const targets_1 = require("./targets");
23
24
  const deskScript_1 = require("./deskScript");
24
25
  const audio_1 = require("./audio");
26
+ const clipboard_1 = require("./clipboard");
25
27
  const connectionStatus_1 = require("./connectionStatus");
26
28
  const microphone_1 = require("./microphone");
27
29
  const drives_1 = require("./drives");
@@ -40,7 +42,16 @@ const ssh_1 = require("./ssh");
40
42
  const wake_1 = require("./wake");
41
43
  const hyperv_1 = require("./hyperv");
42
44
  const gateway_1 = require("./gateway");
45
+ const authorities_1 = require("./authorities");
43
46
  const vms_1 = require("./vms");
47
+ const sessionLog_1 = require("./sessionLog");
48
+ const unshowable_1 = require("./unshowable");
49
+ /**
50
+ * The keychain entry that marks the passwords 0.5.0 saved for desktops reached through a gateway forgotten from this
51
+ * computer's keychain (see RemoteDesktopService.gatewayPasswords): under the plugin's service, a name no saved
52
+ * password's key takes (no target's key is `migration`, see keyParts).
53
+ */
54
+ exports.GATEWAY_PASSWORDS_MARK = 'migration#gateway-passwords';
44
55
  /** How long a desktop that was started (`wake`) gets to answer. */
45
56
  const WAKE_TIMEOUT_MS = 3 * 60 * 1000;
46
57
  // The vendored IronRDP bundles are ES modules. tsc turns import() into require(), and Node's import() is refused in
@@ -79,6 +90,7 @@ const STYLE = `
79
90
  .trd-status-help { pointer-events: auto; color: #8ab4f8; font-size: 12px; cursor: pointer; }
80
91
  .trd-status-help:hover { text-decoration: underline; }
81
92
  .trd-overlay.trd-dim .trd-host { opacity: 0.2; }
93
+ .trd-overlay.trd-dim .trd-status-text { pointer-events: auto; user-select: text; cursor: text; }
82
94
  .trd-view-only { display: none; position: absolute; inset: 0; }
83
95
  .trd-overlay.trd-view-only-on .trd-view-only { display: block; }
84
96
  .trd-overlay.trd-broadcast-on::before { content: 'Typing into all desktops in this tab'; position: absolute; top: 8px; left: 50%;
@@ -86,11 +98,26 @@ const STYLE = `
86
98
  font-size: 11px; pointer-events: none; white-space: nowrap; }
87
99
  .trd-view-only::after { content: 'View only'; position: absolute; top: 8px; left: 8px; padding: 2px 8px; border-radius: 4px;
88
100
  background: rgba(30, 30, 30, 0.85); color: #ddd; font-size: 11px; pointer-events: none; }
89
- .trd-mic { position: absolute; top: 8px; right: 8px; width: 22px; height: 22px; border-radius: 50%; display: none;
101
+ .trd-mic { position: absolute; top: 8px; right: 8px; z-index: 5; width: 22px; height: 22px; border-radius: 50%; display: none;
90
102
  align-items: center; justify-content: center; background: rgba(220, 38, 38, 0.85); color: #fff; font-size: 11px;
91
103
  pointer-events: none; }
92
104
  .trd-overlay.trd-mic-on .trd-mic { display: flex; }
105
+ .trd-overlay.trd-mic-on > .trd-toast { right: 40px; max-width: min(420px, calc(100% - 52px)); }
106
+ .trd-floating-mic-on .trd-overlay.trd-broadcast-on::before { top: 40px; }
93
107
  `;
108
+ /**
109
+ * The name a desktop connected to directly goes by, as its certificate is checked against it (see
110
+ * RDCleanPathOptions.serverName): an address as it is, a host name as it resolves (lower case, punycode, without a
111
+ * trailing dot, which SNI doesn't take). A host with a character no host name has stays as it is: the URL parser behind
112
+ * domainToASCII would read part of it as something else (`a?b` as `a`), and the certificate would be checked against
113
+ * another name than the one connected to.
114
+ */
115
+ function serverNameOf(host) {
116
+ if ((0, net_1.isIP)(host) || /[^A-Za-z0-9._-]/.test(host.replace(/[^\x00-\x7f]/g, ''))) {
117
+ return host;
118
+ }
119
+ return (0, url_1.domainToASCII)(host.replace(/\.$/, '')) || host;
120
+ }
94
121
  /** A SHA-256 fingerprint on two lines of 16 bytes, so it fits a narrow pane. */
95
122
  const showFingerprint = (fingerprint) => fingerprint ? `${fingerprint.slice(0, 47)}\n${fingerprint.slice(48)}` : '(none)';
96
123
  /**
@@ -141,6 +168,7 @@ function remoteSizeFor(rect, settings) {
141
168
  scale: Math.round(dpr * 100),
142
169
  };
143
170
  }
171
+ /** Exported for the unit tests, which use its keys (credentialKey) without a page to put a layer in. */
144
172
  class DesktopSession {
145
173
  /**
146
174
  * What a sign-in and a certificate are remembered under: the desktop; for a Hyper-V VM, its host, whose account
@@ -149,6 +177,16 @@ class DesktopSession {
149
177
  get authKey() {
150
178
  return this.spec.hyperv ? this.key.replace(/#hyperv:[^#]*$/, '#hyperv') : this.key;
151
179
  }
180
+ /**
181
+ * Where a saved password is kept: under authKey, and whenever the desktop is reached through an RD Gateway, that
182
+ * gateway too (see gatewayScope), whether the gateway takes the desktop's sign-in or an account of its own: a
183
+ * password saved for one gateway (or for a connection without one) is never sent through a different gateway named
184
+ * for the same address without asking. The certificate and the "without NLA" decision stay keyed by authKey: those
185
+ * are the desktop's, not the gateway's.
186
+ */
187
+ get credentialKey() {
188
+ return this.authKey + (0, signin_1.gatewayScope)(this.spec.gateway);
189
+ }
152
190
  /** `key`: see sessionKey(); one desktop per key. */
153
191
  constructor(container, key, spec) {
154
192
  this.key = key;
@@ -157,7 +195,8 @@ class DesktopSession {
157
195
  this.visible = false;
158
196
  this.proxy = null;
159
197
  this.ui = null;
160
- this.log = [];
198
+ /** The connection's log (Copy log): its first lines and its latest, however many the remote brings about. */
199
+ this.log = new sessionLog_1.SessionLog();
161
200
  /** What the remote display was last set to. */
162
201
  this.remoteSize = null;
163
202
  this.remote = null;
@@ -167,6 +206,20 @@ class DesktopSession {
167
206
  this.mic = null;
168
207
  /** Files through the clipboard (drop on the desktop, or copy on the remote). */
169
208
  this.files = null;
209
+ /**
210
+ * The ways the clipboard goes with this connection: as it was set up (see clipboard.ts), narrowed when the settings
211
+ * narrow while it's connected; never widened again before it reconnects.
212
+ */
213
+ this.clipboard = 'off';
214
+ /**
215
+ * The narrowest clipboard asked for this desktop by another way of opening it while it was open here (another
216
+ * profile for the same desktop, say, which brings this one forward instead): this desktop's connections in this
217
+ * pane go no further until it is closed. Its reconnects carry it over to the session made in its place (see
218
+ * reopen), since the server can bring one about whenever it likes by dropping the connection.
219
+ */
220
+ this.clipboardCap = 'both';
221
+ /** Stops the sending of this computer's clipboard on this connection. */
222
+ this.stopSending = null;
170
223
  /** The shared folders as drives on the remote (see drives.ts), when there were any at connect. */
171
224
  this.drives = null;
172
225
  /** Decodes H.264 for the graphics pipeline (IronRDP's WebCodecsH264Decoder), when this connection uses it. */
@@ -181,17 +234,33 @@ class DesktopSession {
181
234
  this.gatewayRefused = null;
182
235
  /** Set when the proxy stopped at a server without Network Level Authentication on the current connection attempt. */
183
236
  this.withoutNla = false;
237
+ /**
238
+ * Set when the proxy let the current attempt's server on without Network Level Authentication (see
239
+ * checkWithoutNla): signing in there sends it the password itself, not a proof of it.
240
+ */
241
+ this.plainSignIn = false;
242
+ /**
243
+ * "Send the password anyway" was answered just now (see refusedWithoutNla): the server that answers the next
244
+ * attempt may have the password, and the permission is remembered with its certificate (see holdWithoutNla).
245
+ */
246
+ this.sendAnyway = false;
184
247
  /** The connection-status indicator, made the first time it shows. */
185
248
  this.indicator = null;
186
249
  /** How the pictures come (for the indicator): the graphics pipeline, bitmaps, or IronRDP's choice. */
187
250
  this.graphics = '';
188
251
  /** "What does this mean?" under a message (see status()): opens help about it. */
189
252
  this.onHelp = null;
253
+ /**
254
+ * Whether the desktop's pane is the one the user is in (see RemoteDesktopService.showsInFront): only then does a
255
+ * question take the keyboard as it shows (see status()), and only then does a focus that was waiting for a moment
256
+ * (see focusSoon) go ahead. Its pane's focus hands it over later.
257
+ */
258
+ this.inFront = () => true;
190
259
  /** Keys this desktop was sent and not yet let go of (by `code`): see RemoteDesktopService.broadcastKey. */
191
260
  this.keysDown = new Map();
192
261
  this.reclaimFocus = (event) => {
193
262
  if (this.visible && !this.overlay.contains(event.target)) {
194
- setTimeout(() => this.visible && this.focusDesktop());
263
+ this.focusSoon();
195
264
  }
196
265
  };
197
266
  this.disposers = [];
@@ -263,6 +332,14 @@ class DesktopSession {
263
332
  canvas() {
264
333
  return this.host.querySelector('iron-remote-desktop')?.shadowRoot?.querySelector('canvas') ?? null;
265
334
  }
335
+ /**
336
+ * Focuses the remote desktop in a moment, if it is still in the pane the user is in then (see inFront). `visible`
337
+ * stays true in a tab in the background, and a focus that was waiting (a retry, or Tabby's re-focus of the terminal
338
+ * taken back) mustn't pull the keyboard, and this computer's clipboard with it, away from where the user went.
339
+ */
340
+ focusSoon(attempts, delay = 0) {
341
+ setTimeout(() => this.visible && this.inFront() && this.focusDesktop(attempts), delay);
342
+ }
266
343
  /**
267
344
  * Shows or hides the desktop layer. While it shows, the terminal's input textarea is disabled so
268
345
  * nothing can focus it: keystrokes can't leak into the shell underneath, and Tabby's re-focus of
@@ -275,7 +352,7 @@ class DesktopSession {
275
352
  input.disabled = visible;
276
353
  });
277
354
  if (visible) {
278
- setTimeout(() => this.visible && this.focusDesktop());
355
+ this.focusSoon();
279
356
  }
280
357
  }
281
358
  /**
@@ -290,13 +367,19 @@ class DesktopSession {
290
367
  link.onclick = () => this.onHelp?.(msg);
291
368
  this.statusEl.querySelector('.trd-status-actions').replaceChildren(...actions.map(action => {
292
369
  const button = document.createElement('button');
293
- button.className = 'btn btn-secondary';
370
+ button.className = action.default ? 'btn btn-primary trd-status-default' : 'btn btn-secondary';
294
371
  button.textContent = action.label;
295
372
  button.addEventListener('click', () => action.run());
296
373
  return button;
297
374
  }));
298
375
  this.statusEl.style.display = msg || actions.length ? '' : 'none';
299
376
  this.overlay.classList.toggle('trd-dim', actions.length > 0);
377
+ // Not where another pane is in front: the remote decides when a question comes, and the keyboard stays where the
378
+ // user went (a background tab's buttons can take it: Tabby keeps those in the page, off screen). The pane's
379
+ // focus brings it to the default answer once the user goes there.
380
+ if (this.visible && actions.some(a => a.default) && this.inFront()) {
381
+ this.focusDesktop();
382
+ }
300
383
  }
301
384
  onDispose(f) {
302
385
  this.disposers.push(f);
@@ -312,10 +395,18 @@ class DesktopSession {
312
395
  }
313
396
  return;
314
397
  }
398
+ // A question with a default answer (see StatusAction.default) keeps the focus on it, so Enter picks that.
399
+ const answer = this.statusEl.querySelector('.trd-status-default');
400
+ if (answer) {
401
+ if (document.activeElement !== answer) {
402
+ answer.focus();
403
+ }
404
+ return;
405
+ }
315
406
  const host = this.host.querySelector('iron-remote-desktop');
316
407
  this.canvas()?.focus();
317
408
  if (host && document.activeElement !== host && attempts > 1 && this.visible) {
318
- setTimeout(() => this.visible && this.focusDesktop(attempts - 1), 16);
409
+ this.focusSoon(attempts - 1, 16);
319
410
  }
320
411
  }
321
412
  dispose() {
@@ -338,6 +429,7 @@ class DesktopSession {
338
429
  this.overlay.remove();
339
430
  }
340
431
  }
432
+ exports.DesktopSession = DesktopSession;
341
433
  /**
342
434
  * A remote desktop per terminal pane (an SSH tab, or a local terminal running `ssh`), shown as a layer
343
435
  * over that pane's terminal. Toggling hides the layer and returns to the console; the RDP session keeps
@@ -362,6 +454,17 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
362
454
  this.h264Support = null;
363
455
  /** Desktops (session keys) where H.264 decoding failed: they connect without it until Tabby restarts. */
364
456
  this.h264Failed = new Set();
457
+ /** When each desktop's video decoder was last taken back by the browser (see run()). */
458
+ this.h264Reclaimed = new Map();
459
+ /**
460
+ * Where each host stands as far as the key `desk` has there is concerned (see deskGeneration): a count that goes up
461
+ * each time that key is forgotten.
462
+ */
463
+ this.deskForgotten = new Map();
464
+ /** How many times `desk` was turned off, for every host (see deskGeneration). */
465
+ this.deskTurnedOff = 0;
466
+ /** Whether the passwords 0.5.0 saved for gateways' desktops were handed to forgetOnce (see gatewayPasswords). */
467
+ this.gatewayPasswordsAsked = false;
365
468
  /**
366
469
  * What the setup last found on each SSH host (by target key): GNOME, xrdp (and its port) or Windows. Kept for as
367
470
  * long as Tabby runs; a Windows host is found out on the first connect there, which costs a failed setup.
@@ -375,19 +478,158 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
375
478
  /** VMs found on SSH hosts (see vms.ts), by target key: when, and as desktops; and what the scans learnt of the host. */
376
479
  this.vms = new Map();
377
480
  this.lastUsed = new Map();
378
- /** Automatic reconnection after a desktop dropped, per pane: attempts so far and the pending timer. */
481
+ /** Each pane's `desk` requests, one at a time (see DeskGate). */
482
+ this.deskGates = new WeakMap();
483
+ /**
484
+ * Automatic reconnection after a desktop dropped, per pane: attempts so far and the pending timer. Kept while a
485
+ * reconnected desktop is up, until it has stayed up for STABLE_MS (see reconnectedUntilNow): one that drops again
486
+ * sooner goes on where the delays were, and they run out.
487
+ */
379
488
  this.reconnects = new Map();
489
+ /**
490
+ * The desktops (session keys) the microphone was stopped for, by Stop in the header's microphone menu or the
491
+ * desktop's own menu. A server can end its connection whenever it likes, and the one made in its place would go by
492
+ * the setting again, so the stop holds for the desktop, reconnects included, however they come about, until it is
493
+ * turned back on in the desktop's menu. Kept in this window until it is closed: each Tabby window has its own.
494
+ */
495
+ this.microphoneStopped = new Set();
496
+ /** When each desktop's last note about its microphone was (see microphoneChanged). */
497
+ this.microphoneNoted = new WeakMap();
380
498
  /** Split tabs where keys typed on one desktop go to all of them (see DesktopKeyboard). */
381
499
  this.broadcast = new WeakSet();
382
500
  /** How the pane's next connection to a GNOME desktop that's open elsewhere goes, when that was chosen already. */
383
501
  this.openChoices = new Map();
384
502
  /** Each pane's name for itself on hosts, for the note a take-over leaves there (see prepareRemoteDesktop). */
385
503
  this.clients = new WeakMap();
504
+ // The settings also change without updateSettings: saved in another Tabby window, in Settings › Config file, or
505
+ // by config sync. Open desktops narrow their clipboard then too, as they do from here (see applyClipboard), and
506
+ // the microphone turned off stops at once there as well.
507
+ config.changed$.subscribe(() => this.sessions.forEach(session => {
508
+ this.applyClipboard(session);
509
+ this.applyMicrophone(session);
510
+ }));
511
+ config.changed$.subscribe(() => this.noteDesk());
512
+ config.ready$?.subscribe?.(() => this.migrateConfig());
513
+ config.changed$.subscribe(() => this.migrateConfig());
514
+ }
515
+ /**
516
+ * Brings what older versions saved up to date; saves only when something changed.
517
+ *
518
+ * - Profiles an .rdp import made before imports set an account of their own (0.3 to 0.5) take their group's, or the
519
+ * global, default saved account, and with it its saved password, to the address the file named; the same goes for
520
+ * a gateway account, to the gateway it named, and for the kind (an xrdp one would get the password without NLA,
521
+ * unasked). They get `account`, `gatewayAccount` and `kind` null, as imports now have (see importRdp), so they ask
522
+ * and are Windows desktops whatever the defaults say. They are known by the shape the import gave them, which no
523
+ * save in Tabby's profile editor has changed (it drops options equal to their defaults, an empty `via` among
524
+ * them): the desktop icon, a domain, `via: ''`, and no option the import didn't set.
525
+ * - Gateway certificates remembered under a spelling other than the gateway's one form (0.5.0 kept capitals and a
526
+ * trailing dot, see parseGateway) are consulted by nothing any more, and only fill the list: dropped. Not moved
527
+ * to the gateway's form: such a spelling may be one a file chose, its certificate trusted on first use, unasked.
528
+ * - Passwords 0.5.0 saved for desktops reached through a gateway are forgotten, once in each store (see
529
+ * gatewayPasswords).
530
+ */
531
+ migrateConfig() {
532
+ let changed = false;
533
+ const imported = new Set(['host', 'port', 'kind', 'username', 'domain', 'via', 'gateway', 'gatewayAccount']);
534
+ for (const p of this.config.store.profiles ?? []) {
535
+ const o = p?.type === targets_1.RDP_PROFILE_TYPE && p.icon === 'fas fa-desktop' ? p.options : null;
536
+ if (o && typeof o === 'object' && !('account' in o) && o.via === '' && typeof o.domain === 'string' && typeof o.host === 'string' &&
537
+ (o.gatewayAccount ?? '') === '' && (o.kind ?? 'windows') === 'windows' && Object.keys(o).every(k => imported.has(k))) {
538
+ o.account = null;
539
+ o.gatewayAccount = null;
540
+ o.kind = null;
541
+ changed = true;
542
+ }
543
+ }
544
+ const store = this.config.store.remoteDesktop;
545
+ const list = Array.isArray(store?.trustedCertificates) ? store.trustedCertificates : [];
546
+ const kept = list.filter((e) => {
547
+ const key = typeof e?.desktop === 'string' ? e.desktop : '';
548
+ if (!key.startsWith('gateway#')) {
549
+ return true;
550
+ }
551
+ // `gateway#<host>:<port>`, an IPv6 host without its brackets.
552
+ const address = key.slice('gateway#'.length);
553
+ const colon = address.lastIndexOf(':');
554
+ const host = address.slice(0, colon);
555
+ const gateway = colon > 0 ? (0, gateway_1.parseGateway)(`${host.includes(':') ? `[${host}]` : host}:${address.slice(colon + 1)}`) : null;
556
+ return !!gateway && `gateway#${gateway.host}:${gateway.port}` === key;
557
+ });
558
+ if (kept.length !== list.length) {
559
+ store.trustedCertificates = kept;
560
+ changed = true;
561
+ }
562
+ if (changed) {
563
+ this.config.save();
564
+ }
565
+ // Whatever the config says: its mark is the Vault's, and this computer's keychain keeps one of its own (see
566
+ // forgetOnce).
567
+ if (store && !this.gatewayPasswordsAsked) {
568
+ this.gatewayPasswordsAsked = true;
569
+ (0, signin_1.forgetOnce)(exports.GATEWAY_PASSWORDS_MARK, () => this.gatewayPasswords(), {
570
+ done: () => !!this.config.store.remoteDesktop?.gatewayPasswordsMigrated,
571
+ markDone: () => {
572
+ this.config.store.remoteDesktop.gatewayPasswordsMigrated = true;
573
+ this.config.save();
574
+ },
575
+ });
576
+ }
577
+ }
578
+ /**
579
+ * 0.5.0 kept the saved password of a desktop reached through an RD Gateway under the desktop's key alone, which is
580
+ * now the key of that address reached without one (passwords are kept for the way they go now, see gatewayScope):
581
+ * any desktop at that address without a gateway (a quick connect, another profile, an .rdp file naming it) would
582
+ * use it unasked. Forgotten, for each address a remote desktop profile or a desktop behind a host reaches through a
583
+ * gateway; the desktops there, with a gateway or without, ask once. Which keys those are, as the config says now:
584
+ * forgetOnce forgets them when the store of passwords is first used (a locked Vault then asks for its passphrase at
585
+ * a sign-in, as it would anyway), once in this computer's keychain, marked done there (GATEWAY_PASSWORDS_MARK), and
586
+ * once in the Vault, marked done in the config (`gatewayPasswordsMigrated`), which config sync takes to other
587
+ * computers with the Vault but not with their keychains. A part that can't be done (a Vault prompt cancelled, a
588
+ * keychain that doesn't answer) is tried again at the next use, and what it would forget isn't used meanwhile.
589
+ */
590
+ gatewayPasswords() {
591
+ const direct = new Set();
592
+ const behindHosts = new Set();
593
+ // A profile in a shape nothing reads is left out, rather than fail this: the work couldn't be done then, and no
594
+ // saved password at all would be used meanwhile (see forgetOnce).
595
+ const profiles = this.config.store.profiles;
596
+ for (const p of Array.isArray(profiles) ? profiles : []) {
597
+ if (p?.type !== targets_1.RDP_PROFILE_TYPE) {
598
+ continue;
599
+ }
600
+ // As Tabby opens it, its group's gateway included (as it is saved, should Tabby not resolve it).
601
+ let options = p.options;
602
+ try {
603
+ options = this.profiles.getConfigProxyForProfile(p).options;
604
+ }
605
+ catch { }
606
+ try {
607
+ const spec = options?.host ? (0, desktops_1.specOf)({ ...options, wake: undefined }) : null;
608
+ if (spec?.gateway && (0, gateway_1.parseGateway)(spec.gateway)) {
609
+ (options.via ? behindHosts : direct).add(spec.id);
610
+ }
611
+ }
612
+ catch { }
613
+ }
614
+ for (const entry of this.configuredDesktops()) {
615
+ const spec = entry ? (0, desktops_1.specOf)(entry) : null;
616
+ if (spec?.gateway && (0, gateway_1.parseGateway)(spec.gateway)) {
617
+ behindHosts.add(spec.id);
618
+ }
619
+ }
620
+ // Behind every host: an entry's `via` doesn't say which host's key it is (see desktopEdited).
621
+ return key => {
622
+ const { desktopId } = (0, desktops_1.keyParts)(key);
623
+ return direct.has(desktopId) && (0, signin_1.isFor)(key, desktopId, { direct: true, scope: '' }) ||
624
+ behindHosts.has(desktopId) && (0, signin_1.isFor)(key, desktopId, { scope: '' });
625
+ };
386
626
  }
387
627
  /**
388
628
  * The remote a desktop opened in the pane now is on: the pane's own, or in an SSH tab where `ssh` was typed at
389
629
  * the prompt, the machine it went to (as for a local terminal running ssh), through the tab's host. Asks when it
390
- * can't tell which. Undefined: cancelled.
630
+ * can't tell which, and whenever the ssh goes somewhere not chosen "from now on" from this host: the host is what
631
+ * says which ssh runs in its terminal, and could otherwise have a desktop of its choosing open in place of its own,
632
+ * unseen. Undefined: cancelled.
391
633
  */
392
634
  async remoteFor(pane) {
393
635
  const own = await this.targets.targetOf(pane);
@@ -396,10 +638,20 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
396
638
  return own;
397
639
  }
398
640
  let chosen = nested[0];
399
- if (nested.length > 1) {
641
+ let remember = false;
642
+ if (nested.length > 1 || !this.nestedChosen(own, chosen.cmd.destination)) {
643
+ // The tab's own connection first, so that Enter opens what the tab is connected to. A machine the host says
644
+ // ssh leads to opens this time only, unless the user says to open it at once from now on: a first choice
645
+ // is no consent to every later claim of the host's under that name.
400
646
  const picked = await this.selector.show('Which remote desktop?', [
401
- ...nested.map(n => ({ name: n.cmd.destination, description: `ssh running in a terminal on ${own.label}`, icon: 'fas fa-desktop', result: n })),
402
647
  { name: own.label, description: 'this tab\'s own SSH connection', icon: 'fas fa-desktop', result: null },
648
+ ...nested.map(n => ({ name: n.cmd.destination, description: `ssh running in a terminal on ${own.label}`, icon: 'fas fa-desktop', result: { nested: n, remember: false } })),
649
+ ...nested.map(n => ({
650
+ name: `${n.cmd.destination}, from now on`,
651
+ description: `ssh running in a terminal on ${own.label}: opens at once from now on`,
652
+ icon: 'fas fa-desktop',
653
+ result: { nested: n, remember: true },
654
+ })),
403
655
  ]).catch(() => undefined);
404
656
  if (picked === undefined) {
405
657
  return undefined;
@@ -407,9 +659,28 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
407
659
  if (!picked) {
408
660
  return own;
409
661
  }
410
- chosen = picked;
662
+ chosen = picked.nested;
663
+ remember = picked.remember;
664
+ }
665
+ const target = await this.targets.nestedTarget(pane, chosen).catch(() => null);
666
+ // Remembered once it led somewhere: one the host couldn't resolve is no choice to repeat at once.
667
+ if (target && remember) {
668
+ this.chooseNested(own, chosen.cmd.destination);
669
+ }
670
+ return target ?? own;
671
+ }
672
+ /** Whether ssh to `destination`, typed on `outer`, was chosen there for a desktop before (`remoteDesktop.nestedSSH`). */
673
+ nestedChosen(outer, destination) {
674
+ const list = this.config.store.remoteDesktop?.nestedSSH;
675
+ return (Array.isArray(list) ? list : []).some((e) => e?.via === outer.key && e?.destination === destination);
676
+ }
677
+ /** Remembers the choice: from now on, ssh to `destination` alone in a console on `outer` opens its desktop at once. */
678
+ chooseNested(outer, destination) {
679
+ if (!this.nestedChosen(outer, destination)) {
680
+ const store = this.config.store.remoteDesktop;
681
+ store.nestedSSH = [...(Array.isArray(store.nestedSSH) ? store.nestedSSH : []), { via: outer.key, destination }];
682
+ this.config.save();
411
683
  }
412
- return await this.targets.nestedTarget(pane, chosen).catch(() => null) ?? own;
413
684
  }
414
685
  has(pane) {
415
686
  this.prune();
@@ -442,7 +713,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
442
713
  async signInAgain(pane) {
443
714
  const session = this.sessions.get(pane);
444
715
  if (session && this.signsIn(session.spec)) {
445
- await (0, signin_1.forgetCredentials)(session.authKey);
716
+ await (0, signin_1.forgetCredentials)(session.credentialKey);
446
717
  // With a saved account, the form asks for its password anew (the saved one stays until then).
447
718
  this.askAgain.add(pane);
448
719
  await this.reopen(pane, session.spec);
@@ -461,7 +732,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
461
732
  ? (this.config.store.profiles ?? []).filter((p) => p?.type === targets_1.RDP_PROFILE_TYPE && p.options?.host && p.options.via === target.profileId)
462
733
  .map((p) => ({ ...this.profiles.getConfigProxyForProfile(p).options, name: p.name }))
463
734
  : [];
464
- const specs = (0, desktops_1.desktopsFor)(target, this.config.store.remoteDesktop?.desktops, this.ownDesktops.get(target.key), viaProfile);
735
+ const specs = (0, desktops_1.desktopsFor)(target, this.config.store.remoteDesktop?.desktops, this.ownDesktops.get(target.key), viaProfile, this.settings().clipboard);
465
736
  // Found VMs come last, and not where a configured desktop has the same address.
466
737
  const found = this.settings().discoverVMs ? this.vms.get(target.key)?.specs ?? [] : [];
467
738
  return [...specs, ...found.filter(f => !specs.some(s => s.id === f.id))];
@@ -486,21 +757,33 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
486
757
  this.vms.set(target.key, { at: Date.now(), specs: found.map(vms_1.vmSpec), host });
487
758
  this.changed$.next();
488
759
  }, () => {
489
- // Not asked, rather than nothing found (the connection wasn't up yet, say): the next look asks again.
490
- this.vms.set(target.key, { at: known?.at ?? 0, specs: known?.specs ?? [], host });
760
+ // Not asked, rather than nothing found (the connection wasn't up yet, say): the next look asks again. A host
761
+ // that was asked and gave no usable answer (none in time, or far too much) waits its minute like any other.
762
+ this.vms.set(target.key, { at: target.isOpen() ? Date.now() : known?.at ?? 0, specs: known?.specs ?? [], host });
491
763
  });
492
764
  this.vms.set(target.key, { at: known?.at ?? 0, specs: known?.specs ?? [], scan, host });
493
765
  return scan;
494
766
  }
767
+ /**
768
+ * Whether the pane has a VM the host found open, for saveFoundDesktop to keep. Not one found on a machine reached
769
+ * with ssh typed in a host's console: desktops kept behind that machine's name, which is the host's word, would be
770
+ * offered in tabs connected to any host that goes by it (see viaMatches).
771
+ */
772
+ canSaveFoundDesktop(pane) {
773
+ const session = this.sessions.get(pane);
774
+ return !!session?.spec.found && !!session.remote && session.remote.via === undefined;
775
+ }
495
776
  /** Keeps a found VM open in the pane among the host's desktops (`remoteDesktop.desktops`), under its name. */
496
777
  saveFoundDesktop(pane) {
497
778
  const session = this.sessions.get(pane);
498
779
  const spec = session?.spec;
499
- if (!session?.remote || !spec?.found) {
780
+ if (!session?.remote || !spec?.found || !this.canSaveFoundDesktop(pane)) {
500
781
  return;
501
782
  }
783
+ // Its kind stays the host's word, not a choice of the user's: an xrdp one still asks before signing in without
784
+ // Network Level Authentication (see xrdpUnasked), as it did when found.
502
785
  const entry = spec.hyperv ? { name: spec.name, via: session.remote.label, hyperv: spec.hyperv }
503
- : { name: spec.name, via: session.remote.label, host: spec.host, port: spec.port, kind: spec.kind, ...spec.wake ? { wake: spec.wake } : {} };
786
+ : { name: spec.name, via: session.remote.label, host: spec.host, port: spec.port, kind: spec.kind, ...spec.kind === 'xrdp' ? { kindFromHost: true } : {}, ...spec.wake ? { wake: spec.wake } : {} };
504
787
  this.config.store.remoteDesktop.desktops = [...this.configuredDesktops(), entry];
505
788
  this.config.save();
506
789
  this.changed$.next();
@@ -513,12 +796,22 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
513
796
  }
514
797
  /**
515
798
  * The desktops of the pane's SSH host (from the last lookup) and the one a plain toggle acts on: the one
516
- * the pane has open, else the host's default.
799
+ * the pane has open, else the host's default. `chosen`: whether one of the host's desktops is that one, known by
800
+ * its key, since a machine reached through the host has desktops of its own with the same ids (its own desktop's
801
+ * is `own`, as the host's is). Worked out once, for a menu to ask of each desktop it lists: a host can list
802
+ * hundreds of VMs, and the host's default is found among all its desktops.
517
803
  */
518
804
  choicesOf(pane) {
519
805
  const target = this.targets.cached(pane);
520
806
  const specs = target ? this.desktopsOf(target) : [];
521
- return { specs, current: this.desktopOf(pane) ?? (target ? this.defaultDesktop(target) : null) };
807
+ const current = this.desktopOf(pane) ?? (target ? this.defaultDesktop(target) : null);
808
+ const key = this.sessions.get(pane)?.key ?? (target && current ? (0, desktops_1.sessionKey)(target, current) : null);
809
+ return { specs, current, chosen: spec => !!target && (0, desktops_1.sessionKey)(target, spec) === key };
810
+ }
811
+ /** Whether the VMs found on the pane's host are listed only as far as MAX_VMS (see vms.ts): it may have more. */
812
+ vmsCut(pane) {
813
+ const target = this.targets.cached(pane);
814
+ return !!target && this.settings().discoverVMs && !!this.vms.get(target.key)?.host.cut;
522
815
  }
523
816
  /** "Send files…": picks files to paste there. */
524
817
  sendFiles(pane) {
@@ -532,6 +825,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
532
825
  desktopOf(pane) {
533
826
  return this.sessions.get(pane)?.spec ?? null;
534
827
  }
828
+ /** How far the clipboard of the pane's desktop was narrowed while it was open (DesktopSession.clipboardCap). */
829
+ clipboardCapOf(pane) {
830
+ return this.sessions.get(pane)?.clipboardCap ?? 'both';
831
+ }
535
832
  /** `remoteDesktop.accounts`: the saved accounts desktops can sign in with (see accounts.ts). */
536
833
  accounts() {
537
834
  return (0, accounts_1.accountsOf)(this.config.store.remoteDesktop);
@@ -542,12 +839,14 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
542
839
  * with what opens its editor.
543
840
  */
544
841
  accountUses(id) {
545
- const desktops = this.configuredDesktops().flatMap((d, i) => d.account === id || d.gatewayAccount === id ? [{ name: String(d.name ?? (0, desktops_1.desktopIdOf)(d)), desktopIndex: i }] : []);
842
+ // Names as text whatever the config holds (see entryText): the accounts list and the question before removing
843
+ // one show them all.
844
+ 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 }] : []);
546
845
  const groups = this.config.store.groups ?? [];
547
846
  const inherited = (p) => p.options?.account === undefined && groups.find(g => g.id === p.group)?.defaults?.[targets_1.RDP_PROFILE_TYPE]?.options?.account === id;
548
847
  const profiles = (this.config.store.profiles ?? [])
549
848
  .filter((p) => p?.type === targets_1.RDP_PROFILE_TYPE && (p.options?.account === id || p.options?.gatewayAccount === id || inherited(p)))
550
- .map((p) => ({ name: String(p.name ?? p.options?.host ?? ''), profileId: p.id ? String(p.id) : undefined }));
849
+ .map((p) => ({ name: (0, desktops_1.configText)(p.name) || (0, desktops_1.configText)(p.options?.host), profileId: (0, desktops_1.configText)(p.id) || undefined }));
551
850
  return [...desktops, ...profiles];
552
851
  }
553
852
  /**
@@ -631,8 +930,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
631
930
  async removeAccount(id) {
632
931
  const store = this.config.store.remoteDesktop;
633
932
  store.accounts = this.accounts().filter(a => a.id !== id);
634
- if (this.configuredDesktops().some(d => d.account === id || d.gatewayAccount === id)) {
933
+ if (this.configuredDesktops().some(d => (0, desktops_1.isDesktopEntry)(d) && (d.account === id || d.gatewayAccount === id))) {
635
934
  store.desktops = this.configuredDesktops().map(d => {
935
+ if (!(0, desktops_1.isDesktopEntry)(d)) {
936
+ return d;
937
+ }
636
938
  const rest = { ...d };
637
939
  if (rest.account === id) {
638
940
  delete rest.account;
@@ -644,12 +946,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
644
946
  return rest;
645
947
  });
646
948
  }
949
+ // Null rather than '': '' takes the group's default account, should there be one, where the profile is to ask.
647
950
  for (const profile of this.config.store.profiles ?? []) {
648
951
  if (profile?.type === targets_1.RDP_PROFILE_TYPE && profile.options?.account === id) {
649
- profile.options.account = '';
952
+ profile.options.account = null;
650
953
  }
651
954
  if (profile?.type === targets_1.RDP_PROFILE_TYPE && profile.options?.gatewayAccount === id) {
652
- profile.options.gatewayAccount = '';
955
+ profile.options.gatewayAccount = null;
653
956
  }
654
957
  }
655
958
  // A profile group's default for remote desktop profiles, too.
@@ -679,7 +982,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
679
982
  this.showConsole(pane);
680
983
  }
681
984
  // The resolved hostname, so a profile tab and a plain `ssh` to that machine both offer it.
682
- const hostname = target.key.replace(/:\d+$/, '').replace(/^[^@]*@/, '');
985
+ const hostname = (0, desktops_1.keyFields)(target.key).host;
683
986
  const entry = await (0, desktopForm_1.askDesktop)(pane.element.nativeElement, {
684
987
  title: `Add a desktop reached through ${target.label}`,
685
988
  action: 'Add and open',
@@ -687,6 +990,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
687
990
  addAccount: input => this.addAccountFromForm(input),
688
991
  entry: { via: hostname, host: '127.0.0.1', port: 3389 },
689
992
  check: e => this.addressTaken(e, -1),
993
+ clipboard: this.settings().clipboard,
690
994
  });
691
995
  if (!entry) {
692
996
  pane.frontend?.focus();
@@ -700,13 +1004,14 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
700
1004
  /** Another entry for the same host already has this address (desktops are told apart by it). */
701
1005
  addressTaken(entry, index) {
702
1006
  const id = (0, desktops_1.desktopIdOf)(entry);
703
- const via = (entry.via ?? '').trim().toLowerCase();
704
- const taken = this.configuredDesktops().some((d, i) => i !== index && (0, desktops_1.desktopIdOf)(d) === id && (d.via ?? '').trim().toLowerCase() === via);
705
- return taken ? `There is already a desktop at ${id} behind ${entry.via}.` : null;
1007
+ // As text: an edited entry keeps a `via` the form doesn't ask for, and other entries hold whatever the config does.
1008
+ const via = (0, desktops_1.configText)(entry.via).trim().toLowerCase();
1009
+ const taken = this.configuredDesktops().some((d, i) => i !== index && (0, desktops_1.isDesktopEntry)(d) && (0, desktops_1.desktopIdOf)(d) === id && (0, desktops_1.configText)(d.via).trim().toLowerCase() === via);
1010
+ return taken ? `There is already a desktop at ${id} behind ${(0, desktops_1.configText)(entry.via)}.` : null;
706
1011
  }
707
1012
  /**
708
- * "Edit a desktop": the same form, over the pane, filled in. A new address takes the desktop's saved accounts and
709
- * sharpness along; a new user name drops the saved account, which was for the old one. Open sessions keep running.
1013
+ * "Edit a desktop": the same form, over the pane, filled in. A new address takes what is kept for the desktop along
1014
+ * (see desktopEdited); a new user name drops the saved password, which was for the old one. Open sessions keep running.
710
1015
  */
711
1016
  async editDesktop(pane, index) {
712
1017
  if (!this.configuredDesktops()[index]) {
@@ -728,17 +1033,19 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
728
1033
  /** "Edit a desktop" with the form over any element (a pane, or the settings page). */
729
1034
  async editDesktopIn(host, index, askVia = false) {
730
1035
  const old = this.configuredDesktops()[index];
731
- if (!old) {
1036
+ if (!(0, desktops_1.isDesktopEntry)(old)) {
732
1037
  return;
733
1038
  }
1039
+ const { name, via } = (0, desktops_1.entryText)(old);
734
1040
  const entry = await (0, desktopForm_1.askDesktop)(host, {
735
- title: `Edit ${old.name ?? (0, desktops_1.desktopIdOf)(old)}${askVia ? '' : ` (behind ${old.via})`}`,
1041
+ title: `Edit ${name}${askVia || !via ? '' : ` (behind ${via})`}`,
736
1042
  action: 'Save',
737
1043
  accounts: this.accounts(),
738
1044
  addAccount: input => this.addAccountFromForm(input),
739
1045
  hosts: askVia ? await this.sshHosts() : undefined,
740
1046
  entry: old,
741
1047
  check: e => this.addressTaken(e, index),
1048
+ clipboard: this.settings().clipboard,
742
1049
  });
743
1050
  // Compared with the entry as it is now: the config can change while the form shows.
744
1051
  const list = this.configuredDesktops();
@@ -750,45 +1057,80 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
750
1057
  this.config.save();
751
1058
  this.changed$.next();
752
1059
  const accountChanged = (old.username ?? '') !== (entry.username ?? '') || (old.domain ?? '') !== (entry.domain ?? '') || (old.account ?? '') !== (entry.account ?? '');
753
- await this.desktopEdited((0, desktops_1.desktopIdOf)(old), (0, desktops_1.desktopIdOf)(entry), accountChanged, false);
1060
+ await this.desktopEdited((0, desktops_1.desktopIdOf)(old), (0, desktops_1.desktopIdOf)(entry), accountChanged, false, { from: old.gateway, to: entry.gateway });
754
1061
  }
755
1062
  /**
756
1063
  * A desktop was edited (the edit form, or an RDP profile's settings): what is kept per desktop is keyed by its
757
- * address, so a new address takes the saved account, its sharpness, its remembered certificate and the last-used
1064
+ * address, so a new address takes the saved password, its sharpness, its remembered certificate and the last-used
758
1065
  * choice along. (Should another machine answer at the new address, its certificate differs and the connection
759
- * stops to ask, rather than trusting it silently as a first use.) A new user name or domain drops the saved
760
- * account instead: it was for the old one. `direct`: a direct desktop (`rdp#<id>` keys), otherwise one behind SSH
761
- * hosts (`<ssh key>#<id>`).
1066
+ * stops to ask, rather than trusting it silently as a first use.) Not the permission to sign in without Network
1067
+ * Level Authentication: that was given to the server at the old address, and is asked for again. The saved password
1068
+ * is the one of the desktop's way there, `gateways.from` (see gatewayScope): a new user name, domain or account, or
1069
+ * another gateway (or none where there was one), drops it instead, since it was for the old one; a new address
1070
+ * takes it along, through the same gateway. Another desktop at the old address reached another way (a direct
1071
+ * profile next to one through a gateway) keeps its own, and what a desktop at the new address has stays its own.
1072
+ * Where a desktop at the new address is known already (a certificate is remembered for it), the password doesn't
1073
+ * move either, and the edited desktop asks: its certificate stays the one of the machine there, which then checks
1074
+ * out, and the moved password would go to that machine unasked, a desktop of another host's or profile's perhaps.
1075
+ * Behind a host, a password moves only along with the certificate remembered for its desktop, and is dropped
1076
+ * without one (see moveCredentialsFor). `direct`: a direct desktop (`rdp#<id>` keys), otherwise one behind SSH
1077
+ * hosts (`<ssh key>#<id>`): those of every host, since a desktop's `via` can name its host by an alias or a
1078
+ * profile's name that its keys don't show. Each store of passwords has its part done on its own: one that can't be
1079
+ * read now (Tabby's Vault locked and its prompt cancelled, the keychain not answering) keeps what it holds for the
1080
+ * desktop as it was unused, until it can be read while this window is open (see forgetLater). That is said, of that
1081
+ * store. After a new address the certificate stays at the old one as well as going along (below), whichever
1082
+ * passwords stayed there.
762
1083
  */
763
- async desktopEdited(fromId, toId, accountChanged, direct) {
764
- if (direct) {
765
- const [from, to] = [`${desktops_1.DIRECT_KEY}#${fromId}`, `${desktops_1.DIRECT_KEY}#${toId}`];
766
- const saved = !accountChanged && from !== to ? await (0, signin_1.loadCredentials)(from) : null;
767
- if (saved) {
768
- await (0, signin_1.saveCredentials)(to, saved).catch(() => null);
769
- }
770
- if (accountChanged || from !== to) {
771
- await (0, signin_1.forgetCredentials)(from);
772
- }
773
- }
774
- else if (accountChanged) {
775
- await (0, signin_1.forgetCredentialsFor)(fromId);
1084
+ async desktopEdited(fromId, toId, accountChanged, direct, gateways) {
1085
+ const scope = (0, signin_1.gatewayScope)(gateways.from);
1086
+ let waiting = [];
1087
+ if (accountChanged || scope !== (0, signin_1.gatewayScope)(gateways.to)) {
1088
+ waiting = await (0, signin_1.forgetCredentialsFor)(fromId, { direct, scope });
776
1089
  }
777
1090
  else if (fromId !== toId) {
778
- await (0, signin_1.moveCredentialsFor)(fromId, toId);
1091
+ // As they are when each password is looked at, not as they were before the stores were listed (a Vault's
1092
+ // passphrase prompt takes as long as the user does, and a connection meanwhile may remember the certificate
1093
+ // of the new address), and before the certificates move (below).
1094
+ waiting = await (0, signin_1.moveCredentialsFor)(fromId, toId, { direct, scope }, key => this.trustedCertificates().some(e => e.desktop === key));
1095
+ }
1096
+ if (waiting.length) {
1097
+ // Not said to be there: a store that can't be read doesn't tell what it holds.
1098
+ this.notifications.info(`If a password was saved for this desktop before the change, ${this.passwordWaits(waiting)}`);
779
1099
  }
780
1100
  if (fromId === toId) {
781
1101
  return;
782
1102
  }
783
- const moved = (key) => direct ? key === `${desktops_1.DIRECT_KEY}#${fromId}` : key.endsWith(`#${fromId}`) && !key.startsWith(`${desktops_1.DIRECT_KEY}#`);
1103
+ // Anchored on the key's parts, not an `endsWith('#'+fromId)`: a gateway pin (`gateway#…`) or a desktop whose
1104
+ // id is a suffix of this one's isn't moved by mistake.
1105
+ const moved = (key) => {
1106
+ const { target, desktopId: id } = (0, desktops_1.keyParts)(key);
1107
+ return id === fromId && (direct ? target === desktops_1.DIRECT_KEY : target !== desktops_1.DIRECT_KEY && target !== 'gateway');
1108
+ };
784
1109
  const store = this.config.store.remoteDesktop;
785
- for (const list of ['desktopSharpness', 'trustedCertificates', 'withoutNla']) {
1110
+ // The certificates go along and stay at the old address too. What is kept there still goes only to the machine
1111
+ // it was saved for, and another machine answering there meets the certificate question rather than being
1112
+ // trusted as a first use: the password of another desktop at that address, reached another way (a certificate
1113
+ // belongs to the address, a password to the way there: see credentialKey), or one that a store which couldn't
1114
+ // be read still has, found there again should this window close before it goes.
1115
+ for (const list of ['desktopSharpness', 'trustedCertificates']) {
786
1116
  if (Array.isArray(store[list])) {
787
- store[list] = store[list].map((e) => typeof e?.desktop === 'string' && moved(e.desktop)
788
- ? { ...e, desktop: `${e.desktop.slice(0, -fromId.length)}${toId}` }
789
- : e);
1117
+ // What a desktop already has at the new address (one behind the same host, say) stays its own: the moved
1118
+ // entry is dropped rather than put next to it, where either could be the one found.
1119
+ const isMoved = (e) => typeof e?.desktop === 'string' && moved(e.desktop);
1120
+ const taken = new Set(store[list].filter((e) => !isMoved(e)).map((e) => e?.desktop));
1121
+ const kept = list === 'trustedCertificates';
1122
+ store[list] = store[list].flatMap((e) => {
1123
+ if (!isMoved(e)) {
1124
+ return [e];
1125
+ }
1126
+ const desktop = `${(0, desktops_1.keyParts)(e.desktop).target}#${toId}`;
1127
+ return [...kept ? [e] : [], ...taken.has(desktop) ? [] : [{ ...e, desktop }]];
1128
+ });
790
1129
  }
791
1130
  }
1131
+ if (Array.isArray(store.withoutNla)) {
1132
+ store.withoutNla = store.withoutNla.filter((e) => !(typeof e?.desktop === 'string' && moved(e.desktop)));
1133
+ }
792
1134
  this.config.save();
793
1135
  for (const [key, id] of this.lastUsed) {
794
1136
  if (id === fromId && (key === desktops_1.DIRECT_KEY) === direct) {
@@ -809,7 +1151,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
809
1151
  input.click();
810
1152
  });
811
1153
  if (file) {
812
- await this.importRdp(new Uint8Array(await file.arrayBuffer()), file.name);
1154
+ // A reading or parsing failure is shown, not dropped as an unhandled rejection: this is the user's action.
1155
+ try {
1156
+ await this.importRdp(new Uint8Array(await file.arrayBuffer()), file.name);
1157
+ }
1158
+ catch (e) {
1159
+ this.notifications.error(`${file.name}: couldn't be imported (${e?.message ?? e})`);
1160
+ }
813
1161
  }
814
1162
  }
815
1163
  /**
@@ -818,43 +1166,112 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
818
1166
  * puts them.
819
1167
  */
820
1168
  async remoteDesktopGroup() {
821
- const groups = await this.profiles.getProfileGroups({ includeNonUserGroup: false });
822
- const existing = groups.find(g => g.name === 'Remote desktops');
823
- if (existing?.id) {
824
- return existing.id;
1169
+ const existing = await this.existingRemoteDesktopGroup();
1170
+ if (existing) {
1171
+ return existing;
825
1172
  }
826
1173
  const group = { name: 'Remote desktops', profiles: [] };
827
1174
  await this.profiles.newProfileGroup(group, { genId: true });
828
1175
  return group.id;
829
1176
  }
1177
+ /** The "Remote desktops" profile group's id, when there is one yet (see remoteDesktopGroup). */
1178
+ async existingRemoteDesktopGroup() {
1179
+ const groups = await this.profiles.getProfileGroups({ includeNonUserGroup: false });
1180
+ return groups.find(g => g.name === 'Remote desktops')?.id || undefined;
1181
+ }
830
1182
  /**
831
1183
  * Adds a "Remote desktop (RDP)" profile from a .rdp file's contents (address, user name, domain; see rdpFile.ts)
832
- * and opens it; or opens the profile that already has that address and user. Returns the profile, or null.
1184
+ * and opens it; or opens the profile that already has that address, user and domain. Returns the profile, or null.
833
1185
  */
834
- async importRdp(data, fileName) {
1186
+ 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'), '�');
835
1189
  const parsed = (0, rdpFile_1.parseRdpFile)(data);
836
1190
  if (!parsed) {
837
1191
  this.notifications.error(`${fileName}: no address ("full address") in it`);
838
1192
  return null;
839
1193
  }
840
- const existing = (this.config.store.profiles ?? []).find((p) => p?.type === targets_1.RDP_PROFILE_TYPE && !p.options?.via &&
841
- p.options?.host === parsed.host && (p.options.port || 3389) === parsed.port && (p.options.username ?? '') === (parsed.username ?? '') &&
842
- (p.options.gateway ?? '') === (parsed.gateway ?? ''));
1194
+ // The confirmation below is the only gate on an imported file, so the file can't be allowed to spoof it: an
1195
+ // address, gateway, user name or domain with a character that could reorder or hide part of it (see
1196
+ // UNSHOWABLE) is refused, and an international name is shown and stored as the punycode it actually resolves
1197
+ // to, so a look-alike can't pose as a trusted name. But for the zero-width joiners between letters that Persian
1198
+ // and Indic scripts write words with, in a user name or a domain (see withoutWordJoiners; elsewhere they don't
1199
+ // show): an account's name decides nothing of where the sign-in goes, and such names are a person's own.
1200
+ for (const [what, value] of [['address', parsed.host], ['RD Gateway', parsed.gateway], ['user name', parsed.username && (0, unshowable_1.withoutWordJoiners)(parsed.username)],
1201
+ ['domain', parsed.domain && (0, unshowable_1.withoutWordJoiners)(parsed.domain)]]) {
1202
+ if (value && unshowable_1.UNSHOWABLE.test(value)) {
1203
+ this.notifications.error(`${fileName}: its ${what} has characters that can't be shown safely, so it wasn't imported.`);
1204
+ return null;
1205
+ }
1206
+ }
1207
+ const international = (text) => /[^\x00-\x7f]/.test(text ?? '');
1208
+ const spoofable = international(parsed.host) || international(parsed.gateway);
1209
+ const host = /^[\x20-\x7e]*$/.test(parsed.host) || (0, net_1.isIP)(parsed.host) ? parsed.host : (0, url_1.domainToASCII)(parsed.host) || null;
1210
+ if (!host) {
1211
+ this.notifications.error(`${fileName}: its address isn't one that can be reached.`);
1212
+ return null;
1213
+ }
1214
+ parsed.host = host;
1215
+ if (parsed.gateway) {
1216
+ // As the gateway setting has it (see parseGateway): its host as it resolves, its port apart from that.
1217
+ const gateway = (0, gateway_1.parseGateway)(parsed.gateway);
1218
+ if (!gateway) {
1219
+ this.notifications.error(`${fileName}: its RD Gateway isn't an address that can be reached.`);
1220
+ return null;
1221
+ }
1222
+ parsed.gateway = (0, gateway_1.formatGateway)(gateway);
1223
+ }
1224
+ // A profile that already leads where the file does, as it resolves (its group's defaults included): opened
1225
+ // rather than added again. It is the user's own: where it leads, and as whom, are as they set it. As whom
1226
+ // includes the domain the question names, which Windows takes for the same in any case.
1227
+ const sameGateway = (a, b) => {
1228
+ const [x, y] = [(0, gateway_1.parseGateway)(a), (0, gateway_1.parseGateway)(b)];
1229
+ return x && y ? x.host === y.host && x.port === y.port : !x && !y;
1230
+ };
1231
+ const sameDomain = (a, b) => (0, desktops_1.configText)(a).toLowerCase() === (b ?? '').toLowerCase();
1232
+ const existing = (this.config.store.profiles ?? []).find((p) => {
1233
+ const o = p?.type === targets_1.RDP_PROFILE_TYPE ? this.profiles.getConfigProxyForProfile(p).options : null;
1234
+ return o && !o.via && o.host === parsed.host && (o.port || 3389) === parsed.port && (o.username ?? '') === (parsed.username ?? '') &&
1235
+ sameDomain(o.domain, parsed.domain) && sameGateway(o.gateway, parsed.gateway);
1236
+ });
843
1237
  if (existing) {
1238
+ if (parsed.clipboard && !await this.clipboardOffForFile(existing, fileName)) {
1239
+ return null;
1240
+ }
844
1241
  this.notifications.info(`Opening "${existing.name}", which is already a profile for ${fileName}`);
845
1242
  await this.profiles.openNewTabForProfile(existing);
846
1243
  return existing;
847
1244
  }
848
1245
  // A file is someone else's word for where to connect: where it leads is said before anything goes there. The
849
- // account typed into the sign-in form goes to that address, and to the gateway before it.
1246
+ // account typed into the sign-in form goes to that address, and to the gateway before it, as a proof of the
1247
+ // password the gateway can keep, so the gateway's part is spelled out, as is the domain the account would be
1248
+ // taken from. A file can turn the clipboard off for its desktop (see rdpFile.ts), never on. Otherwise the
1249
+ // profile gets no clipboard option, so that it follows the defaults of its type and of its group (Tabby's
1250
+ // profile settings), else the setting: the question names what that comes to.
850
1251
  const base = fileName.replace(/^.*[\\/]/, '');
1252
+ const fileClipboard = parsed.clipboard ? { clipboard: parsed.clipboard } : {};
1253
+ const group = await this.existingRemoteDesktopGroup();
1254
+ const clipboard = (0, clipboard_1.ownClipboard)(this.profiles.getConfigProxyForProfile({ type: targets_1.RDP_PROFILE_TYPE, name: base, group, options: { ...fileClipboard } }).options.clipboard) ??
1255
+ this.settings().clipboard;
1256
+ // The microphone is the setting's alone: on, every desktop gets it whenever it asks for it (an honest server
1257
+ // asks while an app there records), whatever the file.
1258
+ const what = [...clipboard === 'both' ? ['your clipboard'] : [], 'the folders you share',
1259
+ ...this.settings().microphone ? ['your microphone when it asks for it'] : []];
1260
+ const listed = what.length > 1 ? `${what.slice(0, -1).join(', ')} and ${what[what.length - 1]}` : what[0];
1261
+ const gets = clipboard === 'fromRemote' ? `${listed}${what.length > 1 ? ',' : ''} and can put things on your clipboard`
1262
+ : clipboard === 'off' && parsed.clipboard ? `${listed} (the file turns the clipboard off for it)` : listed;
1263
+ const as = parsed.username ? `, as ${(0, accounts_1.signInName)({ username: parsed.username, domain: parsed.domain })}`
1264
+ : parsed.domain ? `, with an account of the domain ${parsed.domain}` : '';
851
1265
  const { response } = await this.platform.showMessageBox({
852
1266
  type: 'warning',
853
1267
  message: `Add a remote desktop from ${base}?`,
854
1268
  detail: `It connects to ${(0, desktopForm_1.formatAddress)(parsed.host, parsed.port)}${parsed.gateway ? `, through the gateway ${parsed.gateway}` : ''}` +
855
- `${parsed.username ? `, as ${parsed.username}` : ''}. What you sign in with there goes to that address` +
856
- `${parsed.gateway ? ' and that gateway' : ''}, and once signed in, the desktop gets the clipboard and the folders you share. ` +
857
- 'Add it only if you know where it leads, or trust who gave you the file.',
1269
+ `${as}. What you sign in with there goes to that address${parsed.gateway ? ' and that gateway' : ''}, and once signed in, ` +
1270
+ `the desktop gets ${gets}.` +
1271
+ (parsed.gateway ? ' The gateway is signed in to first, before the desktop is reached, and is given a proof of your password ' +
1272
+ 'that whoever runs it could try to crack offline.' : '') +
1273
+ (spoofable ? ' A name in the file uses international characters, shown here as the punycode it resolves to.' : '') +
1274
+ ' Add it only if you know where it leads, or trust who gave you the file.',
858
1275
  buttons: ['Add and connect', 'Add only', 'Cancel'],
859
1276
  defaultId: 0,
860
1277
  cancelId: 2,
@@ -867,7 +1284,20 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
867
1284
  name: base.replace(/\.rdp$/i, '') || parsed.host,
868
1285
  icon: 'fas fa-desktop',
869
1286
  group: await this.remoteDesktopGroup(),
870
- options: { host: parsed.host, port: parsed.port, kind: 'windows', username: parsed.username ?? '', domain: parsed.domain ?? '', via: '', gateway: parsed.gateway ?? '', gatewayAccount: '' },
1287
+ // `account: null` on purpose: the plugin puts imported profiles in the "Remote desktops" group, and without an
1288
+ // account of its own a profile takes that group's (or the global) default saved account, so a file could send
1289
+ // a saved, possibly privileged, password to the address it names with no sign-in prompt. Null rather than '':
1290
+ // Tabby's profile editor drops a value equal to the default when it saves ('' while there is no default
1291
+ // account), and one set later would then be taken; null is never a default, and is no account (see
1292
+ // migrateConfig). The editor still offers "Ask for the account" or a saved one. The same for the gateway's
1293
+ // account (a default one would send its proof to the gateway the file named) and the kind (a default of xrdp
1294
+ // would have the password go without NLA, unasked): null is the desktop's own sign-in, and Windows. The
1295
+ // clipboard is the one default left to inherit (see above): the user's own word for the group, and named in
1296
+ // the question.
1297
+ options: {
1298
+ host: parsed.host, port: parsed.port, kind: null, username: parsed.username ?? '', domain: parsed.domain ?? '', account: null, via: '',
1299
+ gateway: parsed.gateway ?? '', gatewayAccount: null, ...fileClipboard,
1300
+ },
871
1301
  };
872
1302
  await this.profiles.newProfile(profile);
873
1303
  // The file's display scale, as this desktop's own sharpness (the key a direct desktop's settings are kept by).
@@ -878,26 +1308,61 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
878
1308
  }
879
1309
  this.config.save();
880
1310
  this.notifications.notice(`Added the remote desktop profile "${profile.name}" (Settings › Profiles & connections)`);
881
- if (parsed.ignored.length) {
882
- // What the file asked for beyond that, so nobody wonders why the desktop doesn't behave as in mstsc.
883
- this.notifications.info(`${base} also asks for ${parsed.ignored.join('; ')}. Not applied.`);
1311
+ // What the file asked for beyond that, so nobody wonders why the desktop doesn't behave as in mstsc. The microphone
1312
+ // isn't among it while the setting is on: the desktop gets it then (the question said so). A note can quote the
1313
+ // file (a gateway it names but doesn't use), shown as the file name is.
1314
+ const ignored = parsed.ignored.filter(note => note !== rdpFile_1.MICROPHONE_NOTE || !this.settings().microphone);
1315
+ if (ignored.length) {
1316
+ const notes = ignored.join('; ').replace(new RegExp(unshowable_1.UNSHOWABLE.source, 'gu'), '�');
1317
+ this.notifications.info(`${base} also asks for ${notes}. Not applied.`);
884
1318
  }
885
1319
  if (response === 0) {
886
1320
  await this.profiles.openNewTabForProfile(profile);
887
1321
  }
888
1322
  return profile;
889
1323
  }
1324
+ /**
1325
+ * An .rdp file that turns the clipboard off leads to a profile there already is: opened as it is, that profile
1326
+ * would share the clipboard all the same. Unless it is off there too, the user is asked whether to turn it off in
1327
+ * that profile, or to open it as it is; nothing goes in silence. False when cancelled.
1328
+ */
1329
+ async clipboardOffForFile(profile, fileName) {
1330
+ const now = (0, clipboard_1.ownClipboard)(this.profiles.getConfigProxyForProfile(profile).options?.clipboard) ?? this.settings().clipboard;
1331
+ if (now === 'off') {
1332
+ return true;
1333
+ }
1334
+ const { response } = await this.platform.showMessageBox({
1335
+ type: 'warning',
1336
+ message: `Turn the clipboard off for "${profile.name}"?`,
1337
+ detail: `${fileName.replace(/^.*[\\/]/, '')} turns the clipboard off for its desktop, and "${profile.name}", already a ` +
1338
+ `profile for that desktop, ${now === 'both' ? 'shares it both ways' : 'lets that desktop put things on your clipboard'}. ` +
1339
+ 'Turned off there, it is off from now on; if the desktop is open, what you copy after that stops going ' +
1340
+ 'to it (what it was reading already may still arrive), and the clipboard is off in full once the desktop reconnects.',
1341
+ buttons: ['Turn it off and open', 'Open as it is', 'Cancel'],
1342
+ defaultId: 0,
1343
+ cancelId: 2,
1344
+ });
1345
+ if (response === 0) {
1346
+ // As Tabby's profile editor saves a profile: new options in place of the old. Opening it then narrows its
1347
+ // desktop if it is open already (see showDesktop).
1348
+ profile.options = { ...profile.options, clipboard: 'off' };
1349
+ this.config.save();
1350
+ }
1351
+ return response === 0 || response === 1;
1352
+ }
890
1353
  /** Asks, then removes a configured desktop (see removeDesktop). The menus and the settings page use this. */
891
1354
  async confirmRemoveDesktop(index) {
892
1355
  const d = this.configuredDesktops()[index];
893
- if (!d) {
1356
+ if (!(0, desktops_1.isDesktopEntry)(d)) {
894
1357
  return false;
895
1358
  }
896
- const address = `${d.host ?? '127.0.0.1'}:${d.port ?? 3389}`;
1359
+ // A Hyper-V VM's sign-in and certificate are its host's (see DesktopSession.authKey), which its other VMs use too.
1360
+ const { name, address, via, hyperv } = (0, desktops_1.entryText)(d);
897
1361
  const { response } = await this.platform.showMessageBox({
898
1362
  type: 'warning',
899
- message: `Remove the desktop "${d.name ?? address}"?`,
900
- detail: `${address} behind ${d.via}. Its saved password and remembered certificate are forgotten too. ` +
1363
+ message: `Remove the desktop "${name}"?`,
1364
+ detail: (hyperv ? `A Hyper-V VM${via ? ` on ${via}` : ''}. The sign-in and certificate kept for its host stay, for its other VMs. `
1365
+ : `${address}${via ? ` behind ${via}` : ''}. Its saved password and remembered certificate are forgotten too. `) +
901
1366
  'To open it instead, use its host\'s SSH tab: right-click › Open.',
902
1367
  buttons: ['Remove', 'Keep'],
903
1368
  defaultId: 1,
@@ -906,22 +1371,57 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
906
1371
  if (response !== 0) {
907
1372
  return false;
908
1373
  }
909
- this.removeDesktop(index);
1374
+ // Not waited for: the stores are asked meanwhile, which a Vault's passphrase prompt can hold up.
1375
+ this.removeDesktop(index).catch(() => null);
910
1376
  return true;
911
1377
  }
912
- /** Removes a configured desktop, its saved accounts and its remembered certificate. Open sessions to it keep running. */
913
- removeDesktop(index) {
1378
+ /**
1379
+ * Removes a configured desktop, its saved passwords and its remembered certificate. Open sessions to it keep
1380
+ * running. The desktop is gone from the list at once; what is kept for it goes as forgetDesktop says, which can
1381
+ * take as long as a Vault's passphrase prompt does: a caller needn't wait for the result.
1382
+ */
1383
+ async removeDesktop(index) {
914
1384
  const list = this.configuredDesktops();
915
1385
  const [removed] = list.splice(index, 1);
916
- if (!removed) {
1386
+ if (!(0, desktops_1.isDesktopEntry)(removed)) {
917
1387
  return;
918
1388
  }
919
- const id = `${removed.host ?? '127.0.0.1'}:${removed.port ?? 3389}`;
1389
+ // Its desktop's id, which what is kept for it is under: a Hyper-V VM's included.
1390
+ const id = (0, desktops_1.desktopIdOf)(removed);
920
1391
  this.config.store.remoteDesktop.desktops = list;
921
- this.forgetCertificatesFor(id);
922
1392
  this.config.save();
923
1393
  this.changed$.next();
924
- (0, signin_1.forgetCredentialsFor)(id);
1394
+ await this.forgetDesktop(id, false, `"${(0, desktops_1.entryText)(removed).name}"`);
1395
+ }
1396
+ /**
1397
+ * Forgets what is kept for a desktop that is gone (removed from the list, or a direct profile deleted or sent
1398
+ * through an SSH host): its saved passwords from each store that can be read, and then its remembered certificate,
1399
+ * that of every desktop at that address behind any SSH host or, with `direct`, the direct one's. Where a store
1400
+ * can't be read just now (Tabby's Vault locked and its prompt cancelled, the keychain not answering) its password
1401
+ * stays, unused in this window (see forgetCredentialsFor), and so do the certificates: they are what keeps that
1402
+ * password from going to a machine that isn't the one it was saved for, where a desktop behind a host trusts the
1403
+ * first certificate it meets unasked. The user is told, and can forget them in Settings › Remote Desktop ›
1404
+ * Certificates. `what` names the desktop for that.
1405
+ */
1406
+ async forgetDesktop(id, direct, what) {
1407
+ const waiting = await (0, signin_1.forgetCredentialsFor)(id, direct ? { direct: true } : {});
1408
+ if (waiting.length) {
1409
+ this.notifications.info(`If a password was saved for ${what}, ${this.passwordWaits(waiting)} Its remembered certificate stays, ` +
1410
+ 'so that the password can only go to that machine: Settings › Remote Desktop › Certificates can forget it.');
1411
+ return;
1412
+ }
1413
+ this.forgetCertificatesFor(id, direct);
1414
+ this.config.save();
1415
+ this.changed$.next();
1416
+ }
1417
+ /**
1418
+ * What becomes of a desktop's saved password that couldn't be removed from `waiting`, the stores that can't be read
1419
+ * just now (see forgetCredentialsFor), as the end of a sentence.
1420
+ */
1421
+ passwordWaits(waiting) {
1422
+ const where = waiting.map(store => store === 'vault' ? 'Tabby\'s Vault' : 'the system keychain').join(' or ');
1423
+ const when = waiting.length > 1 ? 'each can be read' : waiting[0] === 'vault' ? 'the Vault is unlocked' : 'the keychain answers';
1424
+ return `it couldn't be removed from ${where} just now. It won't be used in this window, and is removed once ${when}, while the window stays open.`;
925
1425
  }
926
1426
  /**
927
1427
  * Shows a desktop over the pane: `desktopId` (see desktopsOf), or the one it has open, or the default. A pane
@@ -929,23 +1429,33 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
929
1429
  */
930
1430
  async showDesktop(pane, desktopId, options = {}) {
931
1431
  this.prune();
1432
+ // A desktop asked for by id is one of the pane's own host's (the menus list those), not one of wherever ssh
1433
+ // typed in its console leads: an id from that list isn't to be looked up among the desktops of a machine the
1434
+ // host names. Nor is a desktop open in the pane taken for it by its id alone: a machine's own desktop there has
1435
+ // the id of the host's own.
1436
+ const requested = desktopId ? options.target ?? await this.targets.targetOf(pane) : undefined;
932
1437
  let session = this.sessions.get(pane);
933
- if (session?.state === 'ended' || session && desktopId && session.spec.id !== desktopId) {
1438
+ // One that ended (the server can end it whenever it likes) is shown again as a new connection, which keeps to the
1439
+ // clipboard it was narrowed to (see clipboardCap), as a reconnect does: below, when it is the same desktop.
1440
+ const ended = session?.state === 'ended' ? session : undefined;
1441
+ if (session?.state === 'ended' || session && desktopId && (session.spec.id !== desktopId || requested && session.key !== (0, desktops_1.sessionKey)(requested, { id: desktopId }))) {
934
1442
  this.disconnect(pane);
935
1443
  session = undefined;
936
1444
  }
937
1445
  if (!session) {
938
- const target = options.target ?? await this.remoteFor(pane);
1446
+ const target = desktopId ? requested : options.target ?? await this.remoteFor(pane);
939
1447
  if (target === undefined) {
940
1448
  return;
941
1449
  }
942
1450
  if (!target) {
943
- this.notifications.error('No SSH connection found in this terminal');
1451
+ // A remote desktop tab whose profile was changed to go through an SSH profile: that one opens over its tab.
1452
+ this.notifications.error((0, targets_1.isRDPTab)(pane) ? `${pane.profile?.name ?? 'This profile'} no longer connects directly: open it from the profile list`
1453
+ : 'No SSH connection found in this terminal');
944
1454
  return;
945
1455
  }
946
1456
  if (this.sessions.has(pane)) {
947
- // Opened meanwhile (e.g. a double click); just show it.
948
- return this.showDesktop(pane, desktopId);
1457
+ // Opened meanwhile (e.g. a double click); just show it, if it is the one asked for on the same remote.
1458
+ return this.showDesktop(pane, desktopId, { ...options, target: requested ?? options.target });
949
1459
  }
950
1460
  const spec = desktopId ? this.desktopsOf(target).find(s => s.id === desktopId) : this.defaultDesktop(target);
951
1461
  if (!spec) {
@@ -962,14 +1472,31 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
962
1472
  // Next to it, moving the focus there would go unnoticed.
963
1473
  this.help.note(pane.element.nativeElement, `${target.label}'s desktop is already open in the other pane.`, 'nested-ssh');
964
1474
  }
1475
+ // Asked for with a narrower clipboard than the open one was connected with (another profile for the same
1476
+ // desktop, say, or an .rdp file's): that one narrows to it, rather than the request getting more.
1477
+ const open = this.sessions.get(existing);
1478
+ if (open && this.applyClipboard(open, (0, clipboard_1.narrowest)(spec.clipboard ?? this.settings().clipboard, options.clipboardCap ?? 'both'))) {
1479
+ open.files?.toast(`Clipboard sharing with this desktop is now ${clipboard_1.CLIPBOARD_LABELS[open.clipboard].toLowerCase()}, as ` +
1480
+ `${spec.name} has it, until it is closed. The change applies in full once the desktop reconnects.`);
1481
+ }
965
1482
  this.selectPane(existing);
966
1483
  return this.showDesktop(existing);
967
1484
  }
968
1485
  const created = new DesktopSession(pane.element.nativeElement, key, spec);
969
1486
  created.onHelp = message => this.help.explain(message);
970
- if (options.viewOnly) {
1487
+ // The remote it is opened on, from the start: reopening one whose connection ended before it got as far as
1488
+ // a sign-in (cancelled, or a setup that failed) goes there again, not to the tab's own host.
1489
+ created.remote = target;
1490
+ created.inFront = () => this.showsInFront(created);
1491
+ // View only stays on for the same desktop shown again after its server ended it, as on a reconnect: the
1492
+ // keys typed there would go to it otherwise.
1493
+ if (options.viewOnly || ended?.key === key && ended.viewOnly) {
971
1494
  created.setViewOnly(true);
972
1495
  }
1496
+ const cap = (0, clipboard_1.narrowest)(options.clipboardCap ?? 'both', ended?.key === key ? ended.clipboardCap : 'both');
1497
+ if (cap !== 'both') {
1498
+ created.clipboardCap = cap;
1499
+ }
973
1500
  session = created;
974
1501
  this.sessions.set(pane, created);
975
1502
  // Released with the session: a pane outlives the desktops opened and closed in it.
@@ -978,7 +1505,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
978
1505
  // Tabby focuses the terminal when the pane gets focus (tab switch, split focus); take it back.
979
1506
  const focused = pane.focused$.subscribe(() => {
980
1507
  if (created.visible) {
981
- setTimeout(() => created.visible && created.focusDesktop());
1508
+ setTimeout(() => created.visible && this.showsInFront(created) && created.focusDesktop());
982
1509
  this.label(pane, created);
983
1510
  }
984
1511
  });
@@ -997,37 +1524,234 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
997
1524
  const shown = session;
998
1525
  setTimeout(() => this.followPane(pane, shown));
999
1526
  }
1527
+ /** Whether `key` is desk's key of a host whose setup gave it to this plugin (see openConsole). */
1528
+ knowsDeskKey(key) {
1529
+ const digest = (0, deskScript_1.deskKeyDigest)(key);
1530
+ return !!digest && this.deskKeys().some(e => e.sha256 === digest);
1531
+ }
1000
1532
  /**
1001
1533
  * `desk`: show this machine's desktop and bring the console along: a terminal on the desktop,
1002
- * attached to the console's tmux session (or opened in its folder when it isn't in tmux).
1534
+ * attached to the console's shared session (or opened in its folder when it isn't in one).
1003
1535
  */
1004
1536
  async openConsole(pane, request) {
1537
+ // Anything shown in a terminal can carry what `desk` prints: a file, a log, another machine's output. A request
1538
+ // counts only with the key of a host whose setup gave it to this plugin, which only that user can read there;
1539
+ // others are ignored before anything runs, here or on a host, and without a word in Tabby's window.
1540
+ if (!this.knowsDeskKey(request.key)) {
1541
+ console.warn(`desk: ignored a request ${request.key ? 'with a key no host gave this plugin' : 'without desk\'s key'}`);
1542
+ return;
1543
+ }
1544
+ let gate = this.deskGates.get(pane);
1545
+ if (!gate) {
1546
+ gate = new deskScript_1.DeskGate();
1547
+ this.deskGates.set(pane, gate);
1548
+ }
1549
+ await gate.run(async () => {
1550
+ if (!this.settings().desk) {
1551
+ // A hook left from when it was on; it goes away the next time a desktop opens there. Said when `desk` is
1552
+ // typed, in the pane in front, not whenever a host likes (see below).
1553
+ if (this.inFront(pane)) {
1554
+ this.notifications.info('`desk` is off: turn it on in the menu under Remote Desktop › Settings');
1555
+ }
1556
+ else {
1557
+ console.warn('desk: ignored a request while desk is off, from a pane that isn\'t in front');
1558
+ }
1559
+ return;
1560
+ }
1561
+ // A host can always send its own key, and a recording of `desk`'s output holds it: such a request mustn't show
1562
+ // a desktop in a pane the user isn't in, where it would take the keyboard (and this computer's clipboard)
1563
+ // from the pane they are typing in. `desk` is typed in the pane in front.
1564
+ if (!this.inFront(pane)) {
1565
+ this.help.note(pane.element.nativeElement, 'desk asked for this machine\'s desktop while this pane wasn\'t the one in front, so it wasn\'t shown. Run desk again here to show it.');
1566
+ return;
1567
+ }
1568
+ await this.openConsoleFor(pane, request, (0, deskScript_1.deskKeyDigest)(request.key));
1569
+ });
1570
+ }
1571
+ /**
1572
+ * Whether the pane is the one the user is in: the focused pane of the active tab. A desktop that takes the focus
1573
+ * anywhere else takes the keyboard from that pane, even in a tab that isn't showing: Tabby keeps those in the page,
1574
+ * off screen. (Whether Tabby's window is in front doesn't matter here: what is decided is which pane has the
1575
+ * keyboard once the user is back.)
1576
+ */
1577
+ inFront(pane) {
1578
+ return (0, targets_1.desktopPaneOf)(this.app.activeTab) === pane;
1579
+ }
1580
+ /** Whether `session` is the desktop of the pane the user is in (see inFront). */
1581
+ showsInFront(session) {
1582
+ const pane = (0, targets_1.desktopPaneOf)(this.app.activeTab);
1583
+ return !!pane && this.sessions.get(pane) === session;
1584
+ }
1585
+ /**
1586
+ * Gives a desktop that just connected the keyboard, if it shows in the pane the user is in. Not when another pane is
1587
+ * in front by now: the connection can take as long as the remote likes, and the keyboard (and this computer's
1588
+ * clipboard, which follows it) stays where the user went. The pane takes it back when it gets the focus again.
1589
+ */
1590
+ focusIfInFront(pane, session) {
1591
+ if (session.visible && this.inFront(pane)) {
1592
+ session.focusDesktop();
1593
+ }
1594
+ }
1595
+ /**
1596
+ * Whether another pane of the pane's split is maximized over it (Tabby's pane-maximize): it is all but hidden then,
1597
+ * nearly transparent behind that pane, so it counts as not showing (see takesAll and inView).
1598
+ */
1599
+ behindMaximized(pane) {
1600
+ const maximized = this.splitOf(pane)?.getMaximizedTab?.();
1601
+ return !!maximized && maximized !== pane;
1602
+ }
1603
+ async openConsoleFor(pane, request, digest) {
1005
1604
  const target = await this.targets.targetOf(pane);
1006
1605
  if (!target) {
1007
1606
  return;
1008
1607
  }
1009
1608
  // `desk` may run on another machine than the pane is connected to: `ssh` typed in its console, with the script
1010
1609
  // there through a shared home folder. That machine's desktop isn't this connection's to open.
1610
+ let there = target;
1011
1611
  if (request.machine) {
1012
1612
  const machineOf = async (t) => (await t.exec('sh -s', deskScript_1.MACHINE_ID_COMMAND).catch(() => '')).trim();
1013
1613
  const machine = await machineOf(target);
1014
- if (machine && machine !== request.machine) {
1614
+ if (!machine) {
1615
+ // Without an answer it could be either machine.
1616
+ this.notifications.error(`desk: couldn't ask ${target.label} which machine it is`);
1617
+ return;
1618
+ }
1619
+ if (machine !== request.machine) {
1015
1620
  // Typed `ssh` at the prompt: the machine it went to, when that is where desk ran.
1621
+ there = null;
1016
1622
  for (const nested of (0, ssh_1.isSSHTab)(pane) ? await this.targets.nestedSSH(pane).catch(() => []) : []) {
1017
- const there = await this.targets.nestedTarget(pane, nested).catch(() => null);
1018
- if (there && await machineOf(there) === request.machine) {
1019
- return this.openConsoleOn(pane, there, request);
1623
+ const candidate = await this.targets.nestedTarget(pane, nested).catch(() => null);
1624
+ if (candidate && await machineOf(candidate) === request.machine) {
1625
+ there = candidate;
1626
+ break;
1020
1627
  }
1021
1628
  }
1022
- const there = request.hostname || 'another machine';
1023
- this.help.note(pane.element.nativeElement, `desk ran on ${there}, but this tab is connected to ${target.label}. ` +
1024
- `Open ${there} in its own tab to use desk there.`, 'nested-ssh');
1025
- return;
1629
+ if (!there) {
1630
+ const elsewhere = request.hostname || 'another machine';
1631
+ this.help.note(pane.element.nativeElement, `desk ran on ${elsewhere}, but this tab is connected to ${target.label}. ` +
1632
+ `Open ${elsewhere} in its own tab to use desk there.`, 'nested-ssh');
1633
+ return;
1634
+ }
1635
+ }
1636
+ }
1637
+ // The key must be that machine's own: the one known may be another host's (a shared home folder gives several
1638
+ // the same one), and one set up from another computer is learned by opening the desktop from this one.
1639
+ const known = this.deskKeyOf(there.key);
1640
+ if (known !== digest) {
1641
+ this.help.note(pane.element.nativeElement, known
1642
+ ? `desk on ${there.label} sent a key that isn't the one this Tabby has for it (it was set up again since, or the key is another machine's): open its desktop once, then run desk again.`
1643
+ : `This Tabby doesn't know desk on ${there.label} yet: open its desktop once, then run desk again.`);
1644
+ return;
1645
+ }
1646
+ return this.openConsoleOn(pane, there, request);
1647
+ }
1648
+ /** What the setups of GNOME hosts said about `desk` (`remoteDesktop.deskKeys`): its key's digest, by account. */
1649
+ deskKeys() {
1650
+ const list = this.config.store.remoteDesktop?.deskKeys;
1651
+ return (Array.isArray(list) ? list : []).filter((e) => typeof e?.desktop === 'string' && typeof e?.sha256 === 'string');
1652
+ }
1653
+ deskKeyOf(key) {
1654
+ return this.deskKeys().find(e => e.desktop === key)?.sha256 ?? '';
1655
+ }
1656
+ /**
1657
+ * Notes `desk` going from on to off, wherever the setting was changed (here, another window, the config file,
1658
+ * config sync): see deskGeneration. `before`: what it was just before a change made here, where that is known.
1659
+ */
1660
+ noteDesk(before = this.deskWasOn) {
1661
+ const on = this.settings().desk;
1662
+ if (before === true && !on) {
1663
+ this.deskTurnedOff++;
1664
+ }
1665
+ this.deskWasOn = on;
1666
+ }
1667
+ /**
1668
+ * A number that changes whenever what a setup of the host's that began earlier learned about `desk` there is no
1669
+ * longer to be kept: desk was turned off since (for every host), or the host's key was forgotten (by a setup run
1670
+ * with desk off, say). A setup asked to set desk up takes it as it starts, and gives a key only if it is still the
1671
+ * same as it ends: one that began with desk on, which was turned off meanwhile and on again, or that another setup
1672
+ * dropped the host's key under, would otherwise put its key back after the host was dropped from desk.
1673
+ */
1674
+ deskGeneration(key) {
1675
+ return this.deskTurnedOff + (this.deskForgotten.get(key) ?? 0);
1676
+ }
1677
+ /**
1678
+ * Keeps what a host's setup said about `desk`: its key's digest while desk is set up there (so that desk works after
1679
+ * Tabby restarts too), nothing otherwise. Only a setup asked to set desk up (`requested`), with desk still on as it
1680
+ * ends, and not turned off, or the host's key forgotten, since it began (`generation`, see deskGeneration), gives a
1681
+ * key: a host whose setup ran with desk off has none (that setup removed it), so one it reports anyway is only a
1682
+ * way in. Any other setup forgets the host's key: desk off, or the host's own desktop no longer GNOME's. One that
1683
+ * began before desk was turned off and on again, or before the host's key was forgotten, changes nothing.
1684
+ */
1685
+ rememberDeskKey(key, endpoint, requested, generation) {
1686
+ if (!this.settings().desk) {
1687
+ this.forgetDeskKey(key);
1688
+ return;
1689
+ }
1690
+ // Begun before desk was turned off (and on again), or before the host's key was forgotten (by a setup of its
1691
+ // own with desk off, say): what it learned isn't kept, and what came since stays as it is, a newer setup's key
1692
+ // or none, rather than being undone by this one.
1693
+ if (generation !== this.deskGeneration(key)) {
1694
+ return;
1695
+ }
1696
+ const sha256 = requested && endpoint.kind === 'gnome' ? (0, deskScript_1.deskKeyDigest)(endpoint.deskKey) : '';
1697
+ if (!sha256) {
1698
+ this.forgetDeskKey(key);
1699
+ return;
1700
+ }
1701
+ if (this.deskKeyOf(key) === sha256) {
1702
+ return;
1703
+ }
1704
+ const store = this.config.store.remoteDesktop;
1705
+ const others = (Array.isArray(store.deskKeys) ? store.deskKeys : []).filter((e) => e?.desktop !== key);
1706
+ store.deskKeys = [...others, { desktop: key, sha256 }];
1707
+ this.config.save();
1708
+ }
1709
+ /** Forgets a host's desk key (see rememberDeskKey); saves only when there was one. */
1710
+ forgetDeskKey(key) {
1711
+ this.deskForgotten.set(key, (this.deskForgotten.get(key) ?? 0) + 1);
1712
+ const store = this.config.store.remoteDesktop;
1713
+ const list = Array.isArray(store.deskKeys) ? store.deskKeys : [];
1714
+ const others = list.filter((e) => e?.desktop !== key);
1715
+ if (others.length !== list.length) {
1716
+ store.deskKeys = others;
1717
+ this.config.save();
1718
+ }
1719
+ }
1720
+ /**
1721
+ * The host's setup (see prepareRemoteDesktop), with `desk` as it is set as it starts, and what it says about desk
1722
+ * kept. A setup with desk off removes desk there, which is how a host is dropped (opening its desktop with desk
1723
+ * off), so its key is forgotten before the host is asked anything: a host that answers with an error, with nothing
1724
+ * the setup can use, or not at all keeps none. So is the key of one whose setup fails while desk is off. One that
1725
+ * began before desk was turned off and on again keeps nothing it learned, and leaves what came since as it is (see
1726
+ * deskGeneration).
1727
+ */
1728
+ async setUp(pane, target, takeOver = false) {
1729
+ this.noteDesk();
1730
+ const desk = this.settings().desk;
1731
+ if (!desk) {
1732
+ this.forgetDeskKey(target.key);
1733
+ }
1734
+ // After that, which is this setup's own: what changes the host's key's standing from here on is another's.
1735
+ const generation = this.deskGeneration(target.key);
1736
+ let endpoint;
1737
+ try {
1738
+ endpoint = await (0, remoteSetup_1.prepareRemoteDesktop)(target, desk, this.config.store.remoteDesktop?.sessionBackend, this.clientOf(pane), takeOver);
1739
+ }
1740
+ catch (e) {
1741
+ if (!this.settings().desk) {
1742
+ this.forgetDeskKey(target.key);
1026
1743
  }
1744
+ throw e;
1027
1745
  }
1028
- return this.openConsoleOn(pane, target, request);
1746
+ this.rememberDeskKey(target.key, endpoint, desk, generation);
1747
+ return endpoint;
1029
1748
  }
1030
1749
  async openConsoleOn(pane, target, request) {
1750
+ // Asked again: the host decides how long its answers above take, and could wait until the user is elsewhere.
1751
+ if (!this.inFront(pane)) {
1752
+ this.help.note(pane.element.nativeElement, 'desk\'s desktop wasn\'t shown: another pane was in front by then. Run desk again here to show it.');
1753
+ return;
1754
+ }
1031
1755
  await this.showDesktop(pane, desktops_1.OWN_DESKTOP, { target });
1032
1756
  // Wait for the desktop (possibly another pane's, for the same account) to be connected.
1033
1757
  let session;
@@ -1049,8 +1773,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1049
1773
  }
1050
1774
  try {
1051
1775
  const out = await target.exec('sh -s', (0, deskScript_1.consoleScript)(request));
1052
- const err = /^RD_ERR (.*)$/m.exec(out);
1053
- session.log.push(err ? `desk: ${err[1]}` : `desk: ${/^RD_OK (.*)$/m.exec(out)?.[1] ?? 'no result'}`);
1776
+ // The host's words, as much of them as a notice shows: the host decides how long its lines are.
1777
+ const err = /^RD_ERR (.{0,1000})/m.exec(out);
1778
+ session.log.push(err ? `desk: ${err[1]}` : `desk: ${/^RD_OK (.{0,1000})/m.exec(out)?.[1] ?? 'no result'}`);
1054
1779
  if (err) {
1055
1780
  this.notifications.error(`desk: ${err[1]}`);
1056
1781
  }
@@ -1088,9 +1813,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1088
1813
  }
1089
1814
  }
1090
1815
  }
1816
+ /** The top-level tab holding a pane: the pane itself, or the split it is in. */
1817
+ topTabOf(pane) {
1818
+ return this.app.tabs.find(t => t === pane || (t instanceof tabby_core_1.SplitTabComponent && t.getAllTabs().includes(pane)));
1819
+ }
1091
1820
  /** Brings a pane forward: its top-level tab, and the pane itself within a split. */
1092
1821
  selectPane(pane) {
1093
- const top = this.app.tabs.find(t => t === pane || (t instanceof tabby_core_1.SplitTabComponent && t.getAllTabs().includes(pane)));
1822
+ const top = this.topTabOf(pane);
1094
1823
  if (top) {
1095
1824
  this.app.selectTab(top);
1096
1825
  }
@@ -1128,7 +1857,30 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1128
1857
  clearTimeout(this.reconnects.get(pane)?.timer);
1129
1858
  this.reconnects.delete(pane);
1130
1859
  }
1131
- /** Connects this pane's desktop again, keeping it shown or hidden as it was. */
1860
+ /**
1861
+ * The pane's desktop, reconnected, was up from `connectedAt` until now. Back for good once it stayed up STABLE_MS:
1862
+ * a later drop starts the delays over. One that ended sooner (a server that ends every connection at once, say)
1863
+ * goes on backing off, from when it dropped.
1864
+ */
1865
+ reconnectedUntilNow(pane, connectedAt) {
1866
+ const reconnecting = this.reconnects.get(pane);
1867
+ if (reconnecting && Date.now() - connectedAt >= RemoteDesktopService_1.STABLE_MS) {
1868
+ this.reconnects.delete(pane);
1869
+ }
1870
+ else if (reconnecting) {
1871
+ reconnecting.since = Date.now();
1872
+ }
1873
+ }
1874
+ /** The browser took the desktop's video decoder back (`key`: its session's): whether it did so before, not long ago. */
1875
+ decoderReclaimedAgain(key) {
1876
+ const before = this.h264Reclaimed.get(key);
1877
+ this.h264Reclaimed.set(key, Date.now());
1878
+ return before !== undefined && Date.now() - before <= RemoteDesktopService_1.RECLAIMED_AGAIN_MS;
1879
+ }
1880
+ /**
1881
+ * Connects this pane's desktop again, keeping it shown or hidden as it was, view only or not, and its clipboard no
1882
+ * wider than a narrower way of opening it asked for (DesktopSession.clipboardCap).
1883
+ */
1132
1884
  async reopen(pane, spec, automatic = false) {
1133
1885
  if (!automatic) {
1134
1886
  this.cancelReconnect(pane);
@@ -1136,8 +1888,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1136
1888
  const session = this.sessions.get(pane);
1137
1889
  const background = !!session && !session.visible;
1138
1890
  this.drop(pane, false);
1139
- // The same remote as before, even if the `ssh` it was found through has ended since.
1140
- await this.zone.run(() => this.showDesktop(pane, spec.id, { background, viewOnly: session?.viewOnly, target: session?.remote ?? undefined }));
1891
+ // The same remote as before, even if the `ssh` it was found through has ended since. A remote desktop tab's one
1892
+ // desktop is made afresh instead, from its profile as saved now (see RemoteTargets.targetOf), whose address may
1893
+ // have changed meanwhile.
1894
+ const tab = (0, targets_1.isRDPTab)(pane);
1895
+ await this.zone.run(() => this.showDesktop(pane, tab ? undefined : spec.id, {
1896
+ background, viewOnly: session?.viewOnly, target: tab ? undefined : session?.remote ?? undefined, clipboardCap: session?.clipboardCap,
1897
+ }));
1141
1898
  }
1142
1899
  /** After a desktop ended: reconnect by itself if it dropped, otherwise offer to. */
1143
1900
  afterEnd(pane, target, spec, session, outcome) {
@@ -1156,6 +1913,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1156
1913
  : `Couldn't reconnect: ${outcome.error ?? outcome.reason ?? 'unknown error'}.`);
1157
1914
  }
1158
1915
  else if (outcome.connected) {
1916
+ this.cancelReconnect(pane);
1159
1917
  session.status(`Remote desktop session ended: ${outcome.reason ?? 'unknown reason'}`, [again('Reconnect')]);
1160
1918
  }
1161
1919
  else {
@@ -1209,10 +1967,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1209
1967
  zoom: store.zoom === 'actual' ? 'actual' : 'fit',
1210
1968
  connectionStatus: store.connectionStatus === true,
1211
1969
  microphone: store.microphone === true,
1970
+ clipboard: (0, clipboard_1.clipboardSetting)(store.clipboard),
1212
1971
  osd: (0, osd_1.osdSettings)(store.osd),
1213
1972
  discoverVMs: store.discoverVMs !== false,
1214
1973
  checkUpdates: store.checkUpdates !== false,
1215
- shutDownIdle: [5, 15, 60].includes(Number(store.shutDownIdle)) ? Number(store.shutDownIdle) : 0,
1974
+ shutDownIdle: [5, 15, 60].includes((0, osd_1.configNumber)(store.shutDownIdle)) ? (0, osd_1.configNumber)(store.shutDownIdle) : 0,
1216
1975
  };
1217
1976
  }
1218
1977
  /** The folders shared with remote desktops as drives (`remoteDesktop.sharedFolders`), tidied. */
@@ -1251,31 +2010,87 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1251
2010
  const value = (Array.isArray(list) ? list : []).find((e) => e?.desktop === key)?.sharpness;
1252
2011
  return value === 'retina' || value === 'standard' ? value : null;
1253
2012
  }
1254
- /** The settings for a desktop: the defaults, with its own sharpness if it has one. */
2013
+ /**
2014
+ * The settings for a desktop: the defaults, with its own sharpness and clipboard sharing where it has them, the
2015
+ * clipboard no wider than a narrower way of opening it asked for while it was open (DesktopSession.clipboardCap).
2016
+ */
1255
2017
  settingsFor(session) {
1256
2018
  const settings = this.settings();
1257
- return { ...settings, sharpness: this.sharpnessFor(session.key) ?? settings.sharpness };
2019
+ return {
2020
+ ...settings,
2021
+ sharpness: this.sharpnessFor(session.key) ?? settings.sharpness,
2022
+ clipboard: (0, clipboard_1.narrowest)(session.spec.clipboard ?? settings.clipboard, session.clipboardCap),
2023
+ };
2024
+ }
2025
+ /**
2026
+ * The ways the clipboard goes with the session's desktop now: what its connection lets through, and no more than
2027
+ * the settings say now (also when they were changed in the config file rather than here).
2028
+ */
2029
+ clipboardWaysOf(session) {
2030
+ return (0, clipboard_1.clipboardWays)(session.clipboard, this.settingsFor(session).clipboard);
2031
+ }
2032
+ /**
2033
+ * Narrows what the session's connection lets through when the settings narrow it while connected, or `also` does (a
2034
+ * narrower desktop opened onto this one: see showDesktop): this computer's clipboard stops going there at once (the
2035
+ * component's reading stopped), the files offered there are taken back, and what the plugin sends and offers
2036
+ * itself stops too. The rest of the narrowing, like widening, waits for the next connection, which sets the
2037
+ * clipboard up anew. True when it narrowed.
2038
+ */
2039
+ applyClipboard(session, also) {
2040
+ if (also) {
2041
+ // Kept for this desktop's connections here, a retry while connecting and its reconnects included: see
2042
+ // settingsFor and reopen.
2043
+ session.clipboardCap = (0, clipboard_1.narrowest)(session.clipboardCap, also);
2044
+ }
2045
+ const narrowed = (0, clipboard_1.narrowest)(session.clipboard, this.settingsFor(session).clipboard);
2046
+ if (narrowed === session.clipboard) {
2047
+ return false;
2048
+ }
2049
+ // Narrowed first: taking the offered files back ends a paste of them, whose hook would start the reading
2050
+ // again if the connection still let this computer's clipboard go (see setUpClipboard).
2051
+ session.clipboard = narrowed;
2052
+ if (!(0, clipboard_1.clipboardWays)(narrowed).toRemote) {
2053
+ session.stopSending?.();
2054
+ session.files?.revoke();
2055
+ }
2056
+ session.log.push(`clipboard: ${narrowed === 'off' ? 'off' : 'only from the remote desktop'} from now on ` +
2057
+ '(in full once it reconnects)');
2058
+ return true;
1258
2059
  }
1259
2060
  /** Changes a setting, saves it, and applies it to open desktops. */
1260
2061
  updateSettings(change) {
1261
2062
  const store = this.config.store.remoteDesktop;
1262
2063
  const { osd, ...rest } = change;
2064
+ const desk = this.settings().desk;
1263
2065
  Object.assign(store, rest);
2066
+ this.noteDesk(desk);
1264
2067
  if (osd) {
1265
2068
  // A nested object in Tabby's config takes its fields one by one (the object itself isn't replaced).
1266
2069
  Object.assign(store.osd, osd);
1267
2070
  }
1268
2071
  if (change.h264) {
1269
2072
  this.h264Failed.clear(); // turned on again: give it another try everywhere
2073
+ this.h264Reclaimed.clear();
1270
2074
  }
1271
2075
  this.config.save();
1272
2076
  for (const [pane, session] of this.sessions) {
2077
+ this.applyMicrophone(session);
1273
2078
  this.applyZoom(session);
1274
2079
  this.applyKeyboardMode(session);
2080
+ this.applyClipboard(session);
1275
2081
  this.syncIndicator(session);
1276
2082
  this.followPane(pane, session);
1277
2083
  }
1278
2084
  }
2085
+ /**
2086
+ * The microphone setting turned off stops the session's microphone at once, also on a connection still being set
2087
+ * up (run() reads the setting again where it makes the microphone). Turned on, it takes the next connection.
2088
+ */
2089
+ applyMicrophone(session) {
2090
+ if (!this.settings().microphone) {
2091
+ session.mic?.turnOff();
2092
+ }
2093
+ }
1279
2094
  /** IronRDP's keyboard mode for the session: characters (Unicode) or key positions (scancodes), per the setting. */
1280
2095
  applyKeyboardMode(session) {
1281
2096
  try {
@@ -1346,10 +2161,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1346
2161
  }
1347
2162
  if (settings.resize === 'reconnect') {
1348
2163
  session.log.push(`resize: reconnecting at ${wanted.width}x${wanted.height}`);
1349
- const viewOnly = session.viewOnly;
2164
+ const { viewOnly, clipboardCap } = session;
2165
+ // The same remote and desktop as before, rather than looking (and maybe asking) again which one the pane
2166
+ // is on, or taking the host's last used, which may be another pane's. A remote desktop tab's one desktop is
2167
+ // made afresh, from its profile as saved now, as reopen does.
2168
+ const tab = (0, targets_1.isRDPTab)(pane);
2169
+ const target = tab ? undefined : session.remote ?? undefined;
1350
2170
  this.zone.run(() => {
1351
2171
  this.disconnect(pane);
1352
- this.showDesktop(pane, undefined, { viewOnly });
2172
+ this.showDesktop(pane, tab ? undefined : session.spec.id, { viewOnly, target, clipboardCap });
1353
2173
  });
1354
2174
  return;
1355
2175
  }
@@ -1420,8 +2240,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1420
2240
  if ((0, targets_1.desktopPaneOf)(this.app.activeTab) !== pane) {
1421
2241
  this.selectPane(pane);
1422
2242
  }
1423
- // After a tab switch, the pane shows on Angular's next change detection.
1424
- for (let i = 0; i < 30; i++) {
2243
+ // After a tab switch, the pane shows on Angular's next change detection. Not once the user went elsewhere
2244
+ // meanwhile: the keyboard stays where they went.
2245
+ for (let i = 0; i < 30 && this.showsInFront(session); i++) {
1425
2246
  session.focusDesktop(1);
1426
2247
  const focused = document.activeElement;
1427
2248
  if (focused?.tagName === 'IRON-REMOTE-DESKTOP' && session.overlay.contains(focused)) {
@@ -1484,20 +2305,27 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1484
2305
  * that connection's own (behind the host, or where ssh was typed on to).
1485
2306
  */
1486
2307
  label(pane, session, always = false) {
1487
- const target = session.remote;
1488
- const osd = this.settings().osd;
1489
- if (session.state !== 'connected' || !session.visible || !target || osd.show === 'off' && !always) {
1490
- return;
2308
+ // The overlay is cosmetic: whatever goes wrong with it, reading its settings included, mustn't fail the connection
2309
+ // it names (see run()).
2310
+ try {
2311
+ const target = session.remote;
2312
+ const osd = (0, osd_1.osdSettings)(this.config.store.remoteDesktop?.osd);
2313
+ if (session.state !== 'connected' || !session.visible || !target || osd.show === 'off' && !always) {
2314
+ return;
2315
+ }
2316
+ const inSplit = pane.parent instanceof tabby_core_1.SplitTabComponent && pane.parent.getAllTabs().length > 1;
2317
+ const own = session.spec.id === desktops_1.OWN_DESKTOP;
2318
+ if (osd.show === 'auto' && !always && !inSplit && own && !target.via) {
2319
+ return;
2320
+ }
2321
+ // A desktop named after its host already says where it is ("buildhost desktop (xrdp)").
2322
+ const through = own ? target.via : target.direct || session.spec.name.startsWith(target.label) ? null : target.label;
2323
+ const size = session.remoteSize ? `${session.remoteSize.width}×${session.remoteSize.height}` : '';
2324
+ session.flashLabel(own ? target.label : session.spec.name, [through ? `via ${through}` : '', size].filter(Boolean).join(' · '), osd);
1491
2325
  }
1492
- const inSplit = pane.parent instanceof tabby_core_1.SplitTabComponent && pane.parent.getAllTabs().length > 1;
1493
- const own = session.spec.id === desktops_1.OWN_DESKTOP;
1494
- if (osd.show === 'auto' && !always && !inSplit && own && !target.via) {
1495
- return;
2326
+ catch (e) {
2327
+ session.log.push(`overlay: ${e?.message ?? e}`);
1496
2328
  }
1497
- // A desktop named after its host already says where it is ("buildhost desktop (xrdp)").
1498
- const through = own ? target.via : target.direct || session.spec.name.startsWith(target.label) ? null : target.label;
1499
- const size = session.remoteSize ? `${session.remoteSize.width}×${session.remoteSize.height}` : '';
1500
- session.flashLabel(own ? target.label : session.spec.name, [through ? `via ${through}` : '', size].filter(Boolean).join(' · '), osd);
1501
2329
  }
1502
2330
  /** Shows the on-screen display on every desktop showing now, whatever `show` says (to try the settings). */
1503
2331
  showOsdEverywhere() {
@@ -1510,10 +2338,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1510
2338
  }
1511
2339
  return shown;
1512
2340
  }
1513
- /** The one-time tip (see RemoteDesktopHelp.tipOnce), once a desktop is connected and showing. */
2341
+ /**
2342
+ * The one-time tip (see RemoteDesktopHelp.tipOnce), once a desktop is connected and showing. Closing it (Got it, or
2343
+ * by itself after a while) gives the desktop the keyboard back only if its pane is the one in front by then: the
2344
+ * user may have gone to another pane, in another tab too, which would lose the keyboard (and this computer's
2345
+ * clipboard) to it. The pane takes it back when it gets the focus again.
2346
+ */
1514
2347
  tip(pane, session) {
1515
2348
  if (session.state === 'connected' && session.visible) {
1516
- this.help.tipOnce(session.overlay, this.hasConsole(pane), () => session.visible && session.focusDesktop());
2349
+ this.help.tipOnce(session.overlay, this.hasConsole(pane), () => session.visible && this.showsInFront(session) && session.focusDesktop());
1517
2350
  }
1518
2351
  }
1519
2352
  /** The open desktops, for the settings page (and bug reports): where they are, and their logs. */
@@ -1527,14 +2360,93 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1527
2360
  log: [...session.log],
1528
2361
  }));
1529
2362
  }
1530
- /** The certificates remembered for desktops (`remoteDesktop.trustedCertificates`). */
2363
+ /**
2364
+ * The desktops sent the microphone right now (the remote has it open), for the indicator in Tabby's header
2365
+ * (header.ts), and whether each is in view, where the dot on it says so too.
2366
+ */
2367
+ microphoneUsers() {
2368
+ this.prune();
2369
+ return [...this.sessions].filter(([, session]) => session.mic?.capturing)
2370
+ .map(([pane, session]) => ({ pane, name: session.spec.name, inView: this.inView(pane, session) }));
2371
+ }
2372
+ /** Stops sending the microphone to the pane's desktop at once, and on its later connections (see microphoneStopped). */
2373
+ stopMicrophone(pane) {
2374
+ const session = this.sessions.get(pane);
2375
+ if (!session) {
2376
+ return;
2377
+ }
2378
+ this.microphoneStopped.add(session.key);
2379
+ session.log.push('microphone: stopped for this desktop, its reconnects included');
2380
+ session.mic?.turnOff();
2381
+ this.changed$.next();
2382
+ }
2383
+ /** Whether the microphone was stopped for the pane's desktop (see stopMicrophone). */
2384
+ microphoneStoppedFor(pane) {
2385
+ const session = this.sessions.get(pane);
2386
+ return !!session && this.microphoneStopped.has(session.key);
2387
+ }
2388
+ /**
2389
+ * Sends the microphone to the pane's desktop again, after a stop: when it asks for it, at once if it has it open,
2390
+ * as long as the setting is on.
2391
+ */
2392
+ resumeMicrophone(pane) {
2393
+ const session = this.sessions.get(pane);
2394
+ if (!session || !this.microphoneStopped.delete(session.key)) {
2395
+ return;
2396
+ }
2397
+ session.log.push('microphone: sent to this desktop again');
2398
+ if (this.settings().microphone) {
2399
+ session.mic?.turnOn();
2400
+ }
2401
+ this.changed$.next();
2402
+ }
2403
+ /** Brings the pane's desktop on screen: its tab, the pane within a split, and the desktop over the console. */
2404
+ async bringForward(pane) {
2405
+ if (this.sessions.has(pane)) {
2406
+ this.selectPane(pane);
2407
+ await this.showDesktop(pane);
2408
+ }
2409
+ }
2410
+ /**
2411
+ * The microphone of the pane's desktop started or stopped. The dot on the desktop shows only while the desktop
2412
+ * does; the microphone in Tabby's header (header.ts) shows whatever is in front, and over the window when there is
2413
+ * no header (full screen). The remote decides when it takes the microphone, so a capture that starts while its
2414
+ * desktop isn't in view (the console in front, its tab in the background, the window hidden) is also said in a note,
2415
+ * at most once a minute each time the desktop is opened.
2416
+ */
2417
+ microphoneChanged(pane, session, mic) {
2418
+ session.overlay.classList.toggle('trd-mic-on', mic.capturing);
2419
+ this.changed$.next();
2420
+ if (mic.capturing && !this.inView(pane, session) && Date.now() - (this.microphoneNoted.get(session) ?? 0) > 60000) {
2421
+ this.microphoneNoted.set(session, Date.now());
2422
+ session.log.push('microphone: capturing while the desktop isn\'t in view');
2423
+ // What the plugin knows is that the desktop asked for the microphone and gets it, not what it does with it.
2424
+ this.zone.run(() => this.notifications.info(`${session.spec.name} is receiving your microphone, and that desktop isn't showing. ` +
2425
+ 'A red microphone in Tabby\'s header (in full screen, at the top of the window) shows while it does: click it to show the ' +
2426
+ 'desktop, or to stop sending the microphone there.'));
2427
+ }
2428
+ }
2429
+ /**
2430
+ * Whether the pane's desktop is in view: shown over its console, not behind a pane maximized over it (see
2431
+ * behindMaximized), in the active tab, in a window that isn't hidden.
2432
+ */
2433
+ inView(pane, session) {
2434
+ return session.visible && !this.behindMaximized(pane) && this.topTabOf(pane) === this.app.activeTab && document.visibilityState !== 'hidden';
2435
+ }
2436
+ /**
2437
+ * The certificates remembered for desktops and gateways (`remoteDesktop.trustedCertificates`), with how each was
2438
+ * trusted where that is known (see RememberedCertificate).
2439
+ */
1531
2440
  trustedCertificates() {
1532
2441
  const list = this.config.store.remoteDesktop?.trustedCertificates;
1533
2442
  return (Array.isArray(list) ? list : [])
1534
2443
  .filter((e) => typeof e?.desktop === 'string' && typeof e?.sha256 === 'string')
1535
- .map((e) => ({ desktop: e.desktop, sha256: (0, rdcleanpath_1.normalizeFingerprint)(e.sha256) || e.sha256 }));
2444
+ .map((e) => ({ desktop: e.desktop, sha256: (0, rdcleanpath_1.normalizeFingerprint)(e.sha256) || e.sha256, ...typeof e.authority === 'boolean' ? { authority: e.authority } : {} }));
1536
2445
  }
1537
- /** Forgets one remembered certificate (by session key); the next connection remembers the one it meets. */
2446
+ /**
2447
+ * Forgets one remembered certificate (by session key). The next connection asks about the one it meets (a desktop
2448
+ * connected to directly, a gateway), unless an authority vouches for it; one behind an SSH host remembers it.
2449
+ */
1538
2450
  forgetCertificate(key) {
1539
2451
  const store = this.config.store.remoteDesktop;
1540
2452
  store.trustedCertificates = (Array.isArray(store.trustedCertificates) ? store.trustedCertificates : []).filter((e) => e?.desktop !== key);
@@ -1543,9 +2455,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1543
2455
  this.config.save();
1544
2456
  this.changed$.next();
1545
2457
  }
1546
- /** Whether the desktop was allowed to sign in without Network Level Authentication (see refusedWithoutNla). */
2458
+ /**
2459
+ * Whether the desktop was allowed to sign in without Network Level Authentication (see refusedWithoutNla) with the
2460
+ * certificate remembered for it now.
2461
+ */
1547
2462
  signsInWithoutNla(key) {
1548
- return this.allowedWithoutNla(key);
2463
+ const allowed = this.withoutNlaCertificate(key);
2464
+ return !!allowed && allowed === this.trustedCertificates().find(e => e.desktop === key)?.sha256;
1549
2465
  }
1550
2466
  /** The split tab holding the pane, if it has more than one pane. */
1551
2467
  splitOf(pane) {
@@ -1558,10 +2474,17 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1558
2474
  return split ? split.getAllTabs().filter(p => p !== pane).map(p => ({ pane: p, session: this.sessions.get(p) }))
1559
2475
  .filter(({ session }) => session?.state === 'connected' && !session.viewOnly && session.ui) : [];
1560
2476
  }
2477
+ /**
2478
+ * Whether the pane's desktop gets what is pasted or typed into all desktops of its tab: connected, taking input, and
2479
+ * showing. One whose console is in front doesn't: nothing on screen would say that it gets them (its label is on its
2480
+ * hidden layer), and the pane shows a terminal. Nor does one behind a pane maximized over it (see behindMaximized).
2481
+ */
2482
+ takesAll(pane, session) {
2483
+ return !!session && session.state === 'connected' && !session.viewOnly && !!session.ui && session.visible && !this.behindMaximized(pane);
2484
+ }
1561
2485
  /** How many desktops in the pane's tab would get a paste or typing (itself included), when that's more than one. */
1562
2486
  desktopsInTab(pane) {
1563
- const own = this.sessions.get(pane);
1564
- return this.othersInTab(pane).length + (own?.state === 'connected' && !own.viewOnly ? 1 : 0);
2487
+ return this.othersInTab(pane).filter(other => this.takesAll(other.pane, other.session)).length + (this.takesAll(pane, this.sessions.get(pane)) ? 1 : 0);
1565
2488
  }
1566
2489
  isBroadcast(pane) {
1567
2490
  const split = this.splitOf(pane);
@@ -1594,7 +2517,12 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1594
2517
  if (!this.isBroadcast(pane)) {
1595
2518
  return;
1596
2519
  }
1597
- for (const { session } of this.othersInTab(pane)) {
2520
+ for (const { pane: other, session } of this.othersInTab(pane)) {
2521
+ // Only to the desktops showing (see takesAll). A key let go goes wherever it went down, so that none stays
2522
+ // held on a desktop hidden since.
2523
+ if (!this.takesAll(other, session) && !(type === 'keyup' && init.code && session.keysDown.has(init.code))) {
2524
+ continue;
2525
+ }
1598
2526
  try {
1599
2527
  session.ui.sendKeyboardEvent(new KeyboardEvent(type, { ...init, cancelable: true }));
1600
2528
  session.noteKey(type, init);
@@ -1607,17 +2535,41 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1607
2535
  /**
1608
2536
  * Pastes on every connected desktop in the pane's tab: files copied here are offered to each first (see
1609
2537
  * FileTransfer.pasteClipboardFiles); text and pictures are sent to each, since only the desktop with the focus
1610
- * follows this computer's clipboard on its own. Then Ctrl+V on each, in whatever window is active there.
2538
+ * follows this computer's clipboard on its own. Then Ctrl+V on each, in whatever window is active there. Only the
2539
+ * desktops showing get it (see takesAll). Those that don't take this computer's clipboard are left out, Ctrl+V
2540
+ * included (it would paste something else there), and the pane says which.
1611
2541
  */
1612
2542
  pasteToAll(pane) {
1613
- const targets = [...this.sessions.get(pane) ? [{ pane, session: this.sessions.get(pane) }] : [], ...this.othersInTab(pane)]
1614
- .filter(({ session }) => session.state === 'connected' && !session.viewOnly && session.ui);
1615
- for (const { session } of targets) {
2543
+ const all = [...this.sessions.get(pane) ? [{ pane, session: this.sessions.get(pane) }] : [], ...this.othersInTab(pane)]
2544
+ .filter(({ pane: into, session }) => this.takesAll(into, session));
2545
+ const { targets, left, note } = (0, clipboard_1.splitPaste)(all, ({ session }) => this.clipboardWaysOf(session), ({ session }) => session.spec.name);
2546
+ if (note) {
2547
+ left.forEach(({ session }) => session.log.push('paste to all desktops: left out: this computer\'s clipboard doesn\'t go to this desktop'));
2548
+ const own = this.sessions.get(pane);
2549
+ if (own?.visible && own.files) {
2550
+ own.files.toast(note);
2551
+ }
2552
+ else {
2553
+ this.notifications.info(note);
2554
+ }
2555
+ }
2556
+ for (const { pane: into, session } of targets) {
1616
2557
  // Each pastes once it has taken in the new clipboard, however long that takes it.
1617
2558
  const offered = session.files?.pasteClipboardFiles() ?? false;
2559
+ // Taken with nothing it could send (see FileTransfer.pasteClipboardFiles, which logs why): the remote
2560
+ // wasn't asked, and didn't refuse anything.
2561
+ const nothing = offered && session.files.nothingOffered;
1618
2562
  (offered ? session.files.offerAnswered() : this.sendClipboard(session)).then(ok => {
1619
2563
  if (!ok) {
1620
- session.log.push(`paste to all desktops: the remote refused the ${offered ? 'files' : 'clipboard'}`);
2564
+ session.log.push(nothing ? 'paste to all desktops: no Ctrl+V: none of the files copied here could be sent'
2565
+ : `paste to all desktops: the remote refused the ${offered ? 'files' : 'clipboard'}`);
2566
+ return;
2567
+ }
2568
+ // That can take seconds. Ctrl+V goes only if the desktop still takes a paste from here: not once the
2569
+ // clipboard narrowed for it, or it went view only, out of sight or away meanwhile (`visible` stays true
2570
+ // in a tab in the background, which isn't in view).
2571
+ if (this.sessions.get(into) !== session || !this.takesAll(into, session) || !this.inView(into, session) || !this.clipboardWaysOf(session).toRemote) {
2572
+ session.log.push('paste to all desktops: no Ctrl+V: the desktop stopped taking a paste from here meanwhile');
1621
2573
  return;
1622
2574
  }
1623
2575
  try {
@@ -1637,6 +2589,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1637
2589
  * send, or no answer came: a paste then gets what that desktop has.
1638
2590
  */
1639
2591
  async sendClipboard(session) {
2592
+ // Not where it doesn't go (pasteToAll leaves those desktops out before this).
2593
+ if (!this.clipboardWaysOf(session).toRemote) {
2594
+ return false;
2595
+ }
1640
2596
  const answered = session.files?.clipboardAnswered() ?? new Promise(resolve => setTimeout(() => resolve(true), 300));
1641
2597
  try {
1642
2598
  await session.ui.sendClipboardData();
@@ -1691,9 +2647,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1691
2647
  // A Windows host is known from an earlier connect: no setup to run there (it would only fail again).
1692
2648
  if (spec.id === desktops_1.OWN_DESKTOP && !retryError && this.ownDesktops.get(target.key)?.kind !== 'windows') {
1693
2649
  session.status(`Preparing the remote desktop on ${target.label}…`);
1694
- const endpoint = await (0, remoteSetup_1.prepareRemoteDesktop)(target, this.settings().desk, this.config.store.remoteDesktop?.sessionBackend, this.clientOf(pane));
2650
+ const endpoint = await this.setUp(pane, target);
1695
2651
  this.ownDesktops.set(target.key, { kind: endpoint.kind, xrdpPort: endpoint.xrdpPort, xrdpUser: endpoint.xrdpUser });
1696
- // Only the setup knows which desktop the host has; the session follows it (graphics, sign-in, resizing).
2652
+ endpoint.notes?.forEach(note => session.log.push(`setup: ${note}`));
2653
+ // Only the setup knows which desktop the host has; the session follows it (graphics, sign-in, resizing). It is
2654
+ // the host's word about itself, which spares no question (see DesktopSpec.kindTrusted).
1697
2655
  spec.kind = endpoint.kind;
1698
2656
  if (endpoint.kind === 'windows') {
1699
2657
  session.log.push('setup: the SSH host runs Windows; its own desktop is its RDP server');
@@ -1714,7 +2672,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1714
2672
  }
1715
2673
  if (choice === 'take') {
1716
2674
  session.status(`Taking over the desktop on ${target.label}…`);
1717
- gnome = await (0, remoteSetup_1.prepareRemoteDesktop)(target, this.settings().desk, this.config.store.remoteDesktop?.sessionBackend, this.clientOf(pane), true);
2675
+ gnome = await this.setUp(pane, target, true);
1718
2676
  session.log.push(`setup: took the desktop over (${endpoint.clients} other connection(s))`);
1719
2677
  }
1720
2678
  else {
@@ -1739,7 +2697,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1739
2697
  hyperv = state.enhanced ? 'enhanced' : 'basic';
1740
2698
  session.log.push(`hyper-v: ${hyperv === 'enhanced' ? 'an enhanced session' : 'the basic console (the guest takes no enhanced session now)'}`);
1741
2699
  // The SSH user is who to try first: it got to list the VMs.
1742
- spec.username ??= target.key.replace(/@[^@]*$/, '') || undefined;
2700
+ spec.username ??= (0, desktops_1.keyUser)(target.key) || undefined;
1743
2701
  }
1744
2702
  // A saved account (Settings): its user name, and its password from the keychain. One that has none yet, or that
1745
2703
  // this desktop just refused, is asked for here and saved for every desktop that uses the account.
@@ -1749,7 +2707,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1749
2707
  }
1750
2708
  const again = this.askAgain.delete(pane);
1751
2709
  // A desktop whose account is gone asks, rather than falling back to a login of its own saved earlier.
1752
- let saved = retryError || again || spec.account && !account ? null : await (0, signin_1.loadCredentials)(account ? (0, accounts_1.accountKey)(account.id) : session.authKey);
2710
+ let saved = retryError || again || spec.account && !account ? null : await (0, signin_1.loadCredentials)(account ? (0, accounts_1.accountKey)(account.id) : session.credentialKey);
1753
2711
  if (saved && account && saved.username !== (0, accounts_1.signInName)(account)) {
1754
2712
  // Kept for an earlier user name of the account (its change didn't reach the store): not this one's.
1755
2713
  session.log.push(`sign-in: the account's saved password is for ${saved.username}, not ${(0, accounts_1.signInName)(account)}; asking`);
@@ -1762,23 +2720,28 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1762
2720
  return { host: spec.host, port: spec.port, credentials: account ? { username: (0, accounts_1.signInName)(account), password: saved.password } : saved, remember: false, domain: account ? account.domain ?? '' : undefined, hyperv };
1763
2721
  }
1764
2722
  session.status('');
2723
+ const mark = (0, signin_1.forgetMark)();
2724
+ // The host's own desktops (its own, or its xrdp besides GNOME) and direct ones need no "via". A machine reached
2725
+ // with ssh typed in a host's console says which host: that host is what leads there.
2726
+ const hostOwn = spec.id === desktops_1.OWN_DESKTOP || spec.kind === 'xrdp' && spec.host === '127.0.0.1';
2727
+ const via = !target.via ? target.label : hostOwn ? target.via : `${target.label}, through ${target.via}`;
1765
2728
  const asked = (0, signin_1.askCredentials)(session.overlay, {
1766
- // The host's own desktops (its own, or its xrdp besides GNOME) and direct ones need no "via".
1767
- title: spec.hyperv ? `Sign in to ${target.label}, to open ${spec.name}`
1768
- : spec.id === desktops_1.OWN_DESKTOP || target.direct || spec.kind === 'xrdp' && spec.host === '127.0.0.1' ? `Sign in to ${spec.name}` : `Sign in to ${spec.name} (via ${target.label})`,
2729
+ title: spec.hyperv ? `Sign in to ${target.label}${target.via ? ` (via ${target.via})` : ''}, to open ${spec.name}`
2730
+ : hostOwn && !target.via || target.direct ? `Sign in to ${spec.name}` : `Sign in to ${spec.name} (via ${via})`,
1769
2731
  username: account ? (0, accounts_1.signInName)(account) : spec.username,
1770
2732
  error: retryError,
1771
2733
  canRemember: true,
1772
2734
  account: account?.name,
1773
2735
  }, session.disposed);
1774
- if (session.visible) {
1775
- setTimeout(() => session.visible && session.focusDesktop());
2736
+ // Only in the pane in front, as when connected (see run()): the form gets the keyboard when its pane does.
2737
+ if (session.visible && this.inFront(pane)) {
2738
+ setTimeout(() => session.visible && this.showsInFront(session) && session.focusDesktop());
1776
2739
  }
1777
2740
  const entered = await asked;
1778
2741
  return entered && {
1779
2742
  host: spec.host, port: spec.port, credentials: entered, remember: entered.remember, domain: account ? account.domain ?? '' : undefined,
1780
2743
  saveKey: account && (0, accounts_1.accountKey)(account.id), saveLabel: account && `saved account ${account.name}`, saveFor: account && (0, accounts_1.signInName)(account),
1781
- hyperv,
2744
+ hyperv, mark,
1782
2745
  };
1783
2746
  }
1784
2747
  async connect(pane, target, spec, session) {
@@ -1842,7 +2805,24 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1842
2805
  asked.gatewayAccount = signIn;
1843
2806
  }
1844
2807
  // The proxy outlives an attempt: it reads the sign-in of the attempt it serves.
1845
- session.proxy ??= await (0, rdcleanpath_1.startRDCleanPathProxy)(() => gateway ? this.throughGateway(target, spec, session, gateway, endpoint) : target.openTcp(asked.host, asked.port), fingerprint => this.checkCertificate(session, fingerprint), m => session.log.push(m), { autologon: spec.kind === 'xrdp', withoutNla: () => this.checkWithoutNla(session) });
2808
+ if (!session.proxy) {
2809
+ const proxy = await (0, rdcleanpath_1.startRDCleanPathProxy)((_destination, signal) => gateway ? this.throughGateway(target, spec, session, gateway, endpoint, signal) : target.openTcp(asked.host, asked.port), (fingerprint, valid, reason, details) => this.checkCertificate(session, fingerprint, valid, reason, details), m => session.log.push(m), {
2810
+ autologon: spec.kind === 'xrdp',
2811
+ withoutNla: () => this.checkWithoutNla(session),
2812
+ // Let through before TLS: the permission is held to the certificate the server then shows.
2813
+ withoutNlaCertificate: fingerprint => this.holdWithoutNla(session, fingerprint),
2814
+ // A desktop connected to directly is checked against its name and this computer's
2815
+ // certificate authorities (see checkTrusted). One behind an SSH host goes by that host's
2816
+ // names, which its certificate can't be checked against: trusted on first use.
2817
+ serverName: target.direct ? serverNameOf(asked.host) : undefined,
2818
+ });
2819
+ // The desktop was closed while the proxy started: closing it found no proxy, and nothing else would.
2820
+ if (!alive()) {
2821
+ proxy.close();
2822
+ return;
2823
+ }
2824
+ session.proxy = proxy;
2825
+ }
1846
2826
  const outcome = await this.run(pane, target, spec, session, rdp, asked);
1847
2827
  if (outcome.certificate && alive()) {
1848
2828
  // A changed certificate, now trusted: the same account again, without asking for it again.
@@ -1867,7 +2847,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1867
2847
  if (outcome.signInFailed && this.signsIn(spec) && attempt < 5 && alive()) {
1868
2848
  // The desktop's own saved account goes. A shared one stays until a new password is entered: this
1869
2849
  // desktop refusing it doesn't make it wrong for the others.
1870
- await (0, signin_1.forgetCredentials)(session.authKey);
2850
+ await (0, signin_1.forgetCredentials)(session.credentialKey);
1871
2851
  retryError = outcome.signInFailed;
1872
2852
  endpoint = null;
1873
2853
  continue;
@@ -1929,8 +2909,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1929
2909
  canRemember: true,
1930
2910
  account: account.name,
1931
2911
  }, session.disposed);
1932
- if (session.visible) {
1933
- setTimeout(() => session.visible && session.focusDesktop());
2912
+ // Only in the pane in front (see endpointFor).
2913
+ if (session.visible && this.showsInFront(session)) {
2914
+ setTimeout(() => session.visible && this.showsInFront(session) && session.focusDesktop());
1934
2915
  }
1935
2916
  const entered = await asked;
1936
2917
  return entered && { ...signIn, credentials: entered, remember: entered.remember };
@@ -1939,13 +2920,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1939
2920
  * The proxy's stream to a desktop behind an RD Gateway (see gateway.ts): to the gateway as the SSH host (or this
1940
2921
  * computer) reaches it, signed in with the gateway's own account or else the desktop's, then on to the desktop.
1941
2922
  */
1942
- async throughGateway(target, spec, session, gateway, endpoint) {
2923
+ async throughGateway(target, spec, session, gateway, endpoint, signal) {
1943
2924
  session.gatewayRefused = null;
1944
2925
  const own = endpoint.gatewayAccount;
1945
2926
  const credentials = own?.credentials ?? endpoint.credentials;
1946
2927
  session.log.push(`gateway: ${gateway.host}:${gateway.port}, signing in with ${own ? 'its saved account' : 'the desktop\'s sign-in'}`);
1947
2928
  try {
1948
- 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) => this.checkGatewayCertificate(session, gateway, fingerprint, valid), { log: m => session.log.push(m), clientName: os.hostname() });
2929
+ 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 });
1949
2930
  // Entered a moment ago and taken by the gateway: kept, while the account is still what it was signed in as.
1950
2931
  if (own?.remember) {
1951
2932
  own.remember = false;
@@ -1964,70 +2945,161 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1964
2945
  }
1965
2946
  }
1966
2947
  /**
1967
- * A gateway's certificate, before the sign-in goes to it. One that is valid for the gateway's name by this
1968
- * computer's certificate authorities is accepted (a gateway on the internet has one); any other is trusted on
1969
- * first use, like a desktop's. Either way it is remembered, by the gateway's address: after a valid one, an invalid
1970
- * one is a change to decide on, not a first use.
2948
+ * A gateway's certificate, before the sign-in goes to it. The sign-in reaches the gateway as a proof of the
2949
+ * password, one that can be cracked offline, so a gateway is not trusted silently the way a desktop behind a host
2950
+ * is: it is checked as a desktop connected to directly is (see checkTrusted), by the gateway's address, whether the
2951
+ * gateway is reached from here or from an SSH host.
1971
2952
  */
1972
- checkGatewayCertificate(session, gateway, raw, valid) {
1973
- const fingerprint = (0, rdcleanpath_1.normalizeFingerprint)(raw);
1974
- const address = `${gateway.host}:${gateway.port}`;
1975
- const key = `gateway#${address}`;
2953
+ checkGatewayCertificate(session, gateway, raw, valid, reason = '', details) {
2954
+ const key = `gateway#${gateway.host}:${gateway.port}`;
2955
+ this.checkTrusted(session, key, (0, rdcleanpath_1.normalizeFingerprint)(raw), valid, reason, gateway.host, { address: (0, desktopForm_1.formatAddress)(gateway.host, gateway.port), key }, details);
2956
+ }
2957
+ /**
2958
+ * The certificate of a server whose name an authority can vouch for: a desktop connected to directly, an RD
2959
+ * Gateway. One valid for that name by this computer's certificate authorities connects without asking, and is
2960
+ * remembered as such. Any other is asked about the first time, with why it isn't valid and its fingerprint, before
2961
+ * anything goes to the server, and is remembered once trusted (refusedCertificate). Once remembered, the same
2962
+ * certificate connects, unless an authority's word is all that remembered it and no longer holds (it has expired,
2963
+ * or the authority is gone from this computer's store): nobody confirmed it, so it is asked about as a first use. A
2964
+ * different one: where an authority vouched for the one before, another it vouches for replaces it (a renewal,
2965
+ * logged); one the user trusted stops the connection whatever replaces it, as does any replacement that isn't
2966
+ * valid. Throws, with the problem on the session, to stop and ask. What a certificate says of itself (`details`)
2967
+ * goes to the log whenever it isn't the one remembered, and with the problem to the question.
2968
+ */
2969
+ checkTrusted(session, key, fingerprint, valid, reason, name, gateway, details) {
2970
+ const what = gateway ? 'gateway certificate' : 'certificate';
1976
2971
  const known = this.trustedCertificate(key);
1977
- if (valid || !known) {
1978
- if (known !== fingerprint) {
1979
- this.trustCertificate(key, fingerprint);
1980
- }
1981
- session.log.push(`gateway certificate: SHA-256 ${fingerprint}, ${valid ? `valid for ${gateway.host}` : 'remembered (first connection)'}`);
2972
+ // Remembered before the two were told apart: a gateway's on an authority's word whatever came before, a
2973
+ // desktop's held to the one remembered. Each keeps the rule it was checked by.
2974
+ const byAuthority = known?.authority ?? key.startsWith('gateway#');
2975
+ // Remembered on an authority's word, which no longer holds: the same certificate, but the user never confirmed
2976
+ // it. One the user trusted (`authority` false) and one from before the two were told apart connect on their
2977
+ // fingerprint, as they were remembered.
2978
+ const lapsed = known?.sha256 === fingerprint && known.authority === true && !valid;
2979
+ if (known && fingerprint === known.sha256 && !lapsed) {
2980
+ if (valid && known.authority === undefined) {
2981
+ // One remembered before, unasked, that an authority vouches for: from here on, a renewal by one goes
2982
+ // through too, as it would for this certificate met for the first time now. One the user trusted stays
2983
+ // the user's: a different one is asked about, whatever vouches for it.
2984
+ this.trustCertificate(key, fingerprint, true);
2985
+ }
2986
+ session.log.push(`${what}: SHA-256 ${fingerprint}, as remembered${valid ? `, valid for ${name}` : ''}`);
1982
2987
  return;
1983
2988
  }
1984
- if (fingerprint !== known) {
1985
- session.certificateProblem = { expected: known, actual: fingerprint, pinned: false, gateway: { address, key } };
1986
- throw new Error(`the gateway's certificate SHA-256 ${fingerprint} is not the one remembered (${known})`);
2989
+ if (lapsed) {
2990
+ session.log.push(`${what}: SHA-256 ${fingerprint}, as remembered, but no longer valid for ${name}: ${reason || 'unchecked'}`);
2991
+ }
2992
+ if (details) {
2993
+ session.log.push(`${what}: ${(0, authorities_1.describeCertificate)(details, !(0, authorities_1.vouched)(valid, details))}`);
2994
+ }
2995
+ if (valid && (!known || byAuthority)) {
2996
+ this.trustCertificate(key, fingerprint, true);
2997
+ session.log.push(`${what}: SHA-256 ${fingerprint}, valid for ${name}${known ? `, in place of ${known.sha256} (renewed)` : ', remembered'}`);
2998
+ return;
1987
2999
  }
1988
- session.log.push(`gateway certificate: SHA-256 ${fingerprint}, as remembered`);
3000
+ // `expected` empty marks a first use rather than a change (see refusedCertificate), also for a remembered
3001
+ // certificate that no longer holds.
3002
+ const replaced = lapsed ? null : known;
3003
+ const replaces = !replaced || byAuthority ? {} : { replaces: replaced.authority === false ? 'yours' : 'first use' };
3004
+ session.certificateProblem = {
3005
+ expected: replaced?.sha256 ?? '', actual: fingerprint, pinned: false, valid, name, reason, ...replaces, ...gateway ? { gateway } : {}, ...details ? { details } : {},
3006
+ };
3007
+ const whose = gateway ? 'the gateway\'s certificate' : 'certificate';
3008
+ throw new Error(replaced
3009
+ ? `${whose} SHA-256 ${fingerprint} is not the one remembered (${replaced.sha256})`
3010
+ : `${whose} SHA-256 ${fingerprint} can't be verified (${reason || 'unchecked'}), and ${lapsed ? 'was remembered on an authority\'s word, which no longer holds' : 'hasn\'t been seen before'}`);
1989
3011
  }
1990
- /** The desktops allowed to sign in without Network Level Authentication (`remoteDesktop.withoutNla`), by session key. */
1991
- allowedWithoutNla(key) {
3012
+ /**
3013
+ * The certificate a desktop was allowed to sign in without Network Level Authentication with, or null
3014
+ * (`remoteDesktop.withoutNla`, by session key: see holdWithoutNla). An entry without one isn't a permission: such a
3015
+ * desktop asks again. Only development builds wrote those; 0.5.0 never saved the permission (`withoutNla` was
3016
+ * missing from its defaults, so Tabby didn't write it).
3017
+ */
3018
+ withoutNlaCertificate(key) {
1992
3019
  const list = this.config.store.remoteDesktop?.withoutNla;
1993
- return (Array.isArray(list) ? list : []).some((e) => e?.desktop === key);
3020
+ const value = (Array.isArray(list) ? list : []).find((e) => e?.desktop === key)?.sha256;
3021
+ return typeof value === 'string' ? (0, rdcleanpath_1.normalizeFingerprint)(value) || null : null;
3022
+ }
3023
+ /** Whether a desktop was allowed to sign in without Network Level Authentication (see withoutNlaCertificate). */
3024
+ allowedWithoutNla(key) {
3025
+ return !!this.withoutNlaCertificate(key);
1994
3026
  }
1995
3027
  /**
1996
3028
  * The proxy found a server that doesn't use Network Level Authentication: the password would go to it as it is,
1997
- * before the server has shown anything but a certificate. That is how xrdp signs in, so an xrdp desktop goes on.
1998
- * A Windows machine or GNOME Remote Desktop always uses it unless it was turned off, and something posing as one
1999
- * (the address in a file someone sent) doesn't: those stop here, until the desktop is allowed (refusedWithoutNla).
3029
+ * before the server has shown anything but a certificate. That is how xrdp signs in, so a desktop the user made an
3030
+ * xrdp one goes on (see xrdpUnasked). A Windows machine or GNOME Remote Desktop always uses it unless it was turned
3031
+ * off, and something posing as one (the address in a file someone sent), or as an xrdp desktop a host offers or
3032
+ * says it has itself, doesn't: those stop here, until the desktop is allowed (refusedWithoutNla). One that was goes
3033
+ * on to TLS, where the permission is held to the certificate it was given with (holdWithoutNla).
2000
3034
  */
2001
3035
  checkWithoutNla(session) {
2002
- if (session.spec.kind === 'xrdp') {
3036
+ if ((0, desktops_1.xrdpUnasked)(session.spec)) {
3037
+ session.plainSignIn = true;
2003
3038
  return;
2004
3039
  }
2005
- if (this.allowedWithoutNla(session.authKey)) {
3040
+ if (session.sendAnyway || this.allowedWithoutNla(session.authKey)) {
2006
3041
  session.log.push('sign-in: without Network Level Authentication, as allowed for this desktop');
3042
+ session.plainSignIn = true;
2007
3043
  return;
2008
3044
  }
2009
3045
  session.withoutNla = true;
2010
3046
  throw new Error('the server doesn\'t use Network Level Authentication');
2011
3047
  }
3048
+ /**
3049
+ * After the certificate check, for a server that doesn't use Network Level Authentication (checkWithoutNla let it on
3050
+ * to TLS; the proxy's withoutNlaCertificate): the permission to send it the password is for the server with one
3051
+ * certificate. Given just now, it is remembered with this one; one remembered holds only for the certificate it was
3052
+ * given with. Anything else (a permission taken back meanwhile, by another connection of the desktop that trusted a
3053
+ * new certificate) stops here, before the client hears back and so before the password goes, and asks again.
3054
+ */
3055
+ holdWithoutNla(session, fingerprint) {
3056
+ if ((0, desktops_1.xrdpUnasked)(session.spec)) {
3057
+ return;
3058
+ }
3059
+ if (session.sendAnyway) {
3060
+ session.sendAnyway = false;
3061
+ const store = this.config.store.remoteDesktop;
3062
+ store.withoutNla = [...(Array.isArray(store.withoutNla) ? store.withoutNla : []).filter((e) => e?.desktop !== session.authKey), { desktop: session.authKey, sha256: fingerprint }];
3063
+ this.config.save();
3064
+ session.log.push(`sign-in: allowed without Network Level Authentication for certificate SHA-256 ${fingerprint}`);
3065
+ return;
3066
+ }
3067
+ if (this.withoutNlaCertificate(session.authKey) === fingerprint) {
3068
+ return;
3069
+ }
3070
+ session.withoutNla = true;
3071
+ throw new Error('the server doesn\'t use Network Level Authentication, and wasn\'t allowed to sign in without it with this certificate');
3072
+ }
2012
3073
  /**
2013
3074
  * After the proxy stopped at a server without Network Level Authentication; nothing but the connection request
2014
- * was sent to it. Says what going on would mean and asks; automatic reconnecting stops here. Resolves true to
2015
- * connect again (the desktop is then allowed, and remembered as such).
3075
+ * (without the user name: see withoutUserCookie) was sent to it, or, where it stopped after the certificate check
3076
+ * (see holdWithoutNla), the TLS handshake. Says what going on would mean and asks; automatic reconnecting stops
3077
+ * here. Resolves true to connect again: the server that answers then may have the password, and the permission is
3078
+ * remembered with its certificate (see holdWithoutNla).
2016
3079
  */
2017
3080
  async refusedWithoutNla(pane, target, spec, session) {
2018
3081
  this.cancelReconnect(pane);
2019
3082
  const alive = () => this.sessions.get(pane) === session;
2020
3083
  session.log.push('sign-in: the server doesn\'t use Network Level Authentication; asking before the password goes to it');
2021
- if (!session.visible) {
2022
- this.notifications.notice(`${spec.name}${target.direct ? '' : ` (via ${target.label})`} would get your password as it is. Open that desktop to decide.`);
2023
- }
3084
+ // A note that stays a while (notice() is gone in a second): the question waits unseen until the user goes
3085
+ // there.
3086
+ if (!this.inView(pane, session)) {
3087
+ this.notifications.info(`${spec.name}${target.direct ? '' : ` (via ${target.label})`} would get your password as it is. Open that desktop to decide.`);
3088
+ }
3089
+ // An xrdp desktop asks only where that it is xrdp is a host's word (see xrdpUnasked): a VM the host found, the
3090
+ // host's own desktop, a machine reached through it. Only a desktop the user configured has a kind to set: a VM
3091
+ // saved from the host's list (kindTrusted false, see xrdpFromHost) is told how.
3092
+ const why = spec.kind === 'xrdp'
3093
+ ? `xrdp signs in this way by design. That ${spec.name} is xrdp is what ${target.via ?? target.label} reported, not a ` +
3094
+ `setting of yours: send the password if you know it is${spec.kindTrusted === false ? ', or pick its kind in its edit form, which makes it one' : ''}.`
3095
+ : 'A Windows machine asks for Network Level Authentication unless that was turned off on it. A server posing as one doesn\'t' +
3096
+ (spec.kindTrusted && spec.id !== desktops_1.OWN_DESKTOP ? `, and neither does xrdp: if ${spec.name} is an xrdp server, set its kind to xrdp instead.` : '.');
2024
3097
  const allowed = await new Promise(resolve => {
2025
3098
  session.status(`${spec.name} doesn't use Network Level Authentication, so your password would be sent to it as it is: ` +
2026
- 'encrypted on the way, but readable to whatever answered there, before it has proved anything. Nothing has been sent.\n\n' +
2027
- 'A Windows machine asks for Network Level Authentication unless that was turned off on it. A server posing as one doesn\'t, ' +
2028
- `and neither does xrdp: if ${spec.name} is an xrdp server, set its kind to xrdp instead.`, [
3099
+ `encrypted on the way, but readable to whatever answered there, before it has proved anything. Your password hasn't been sent.\n\n${why}`, [
2029
3100
  { label: 'Send the password anyway', run: () => resolve(true) },
2030
- { label: 'Cancel', run: () => resolve(false) },
3101
+ // What Enter picks, as for the certificate questions: sending the password is the risk.
3102
+ { label: 'Cancel', run: () => resolve(false), default: true },
2031
3103
  ], true);
2032
3104
  session.disposed.then(() => resolve(false));
2033
3105
  });
@@ -2040,19 +3112,21 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2040
3112
  session.status('Not connected: the password was not sent.', [{ label: 'Try again', run: () => this.reopen(pane, spec) }], true);
2041
3113
  return false;
2042
3114
  }
2043
- const store = this.config.store.remoteDesktop;
2044
- store.withoutNla = [...(Array.isArray(store.withoutNla) ? store.withoutNla : []).filter((e) => e?.desktop !== session.authKey), { desktop: session.authKey }];
2045
- this.config.save();
3115
+ // Not remembered yet: the permission goes with a certificate, which the next attempt's server shows.
3116
+ session.sendAnyway = true;
2046
3117
  return true;
2047
3118
  }
2048
3119
  /**
2049
3120
  * The proxy's check of the server's certificate, before any credentials go out. The host's own desktop must show
2050
- * the certificate its setup reported. Others (behind a host, a Windows host's own, a remote desktop tab's) are
2051
- * trusted on first use: remembered silently the first time (most connections already run inside SSH, and a prompt
3121
+ * the certificate its setup reported. A desktop connected to directly (a remote desktop tab's: a profile, quick
3122
+ * connect, an imported file, a restored tab) is checked against its name and this computer's certificate
3123
+ * authorities, and asked about the first time when they don't vouch for it (see checkTrusted): nothing else stands
3124
+ * between it and the network on the way. The others (behind a host, a Windows host's own) are trusted on first
3125
+ * use: remembered silently the first time (the way there runs inside SSH, which vouches for the host, and a prompt
2052
3126
  * on every new desktop would only teach clicking it away), and refused when it changes, until the new one is
2053
3127
  * trusted (see refusedCertificate). Remembered by session key, like saved accounts.
2054
3128
  */
2055
- checkCertificate(session, fingerprint) {
3129
+ checkCertificate(session, fingerprint, valid = false, reason = '', details) {
2056
3130
  session.certificateProblem = null;
2057
3131
  // The host's own GNOME desktop; its own xrdp makes its certificate itself, so that one is trusted on first use.
2058
3132
  if (session.spec.id === desktops_1.OWN_DESKTOP && session.spec.kind === 'gnome') {
@@ -2064,14 +3138,21 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2064
3138
  session.log.push(`certificate: SHA-256 ${fingerprint}, as set up`);
2065
3139
  return;
2066
3140
  }
2067
- const known = this.trustedCertificate(session.authKey);
3141
+ if ((0, desktops_1.keyParts)(session.authKey).target === desktops_1.DIRECT_KEY) {
3142
+ this.checkTrusted(session, session.authKey, fingerprint, valid, reason, serverNameOf(session.spec.host), undefined, details);
3143
+ return;
3144
+ }
3145
+ const known = this.trustedCertificate(session.authKey)?.sha256;
3146
+ if (details && fingerprint !== known) {
3147
+ session.log.push(`certificate: ${(0, authorities_1.describeCertificate)(details, !(0, authorities_1.vouched)(valid, details))}`);
3148
+ }
2068
3149
  if (!known) {
2069
3150
  this.trustCertificate(session.authKey, fingerprint);
2070
3151
  session.log.push(`certificate: SHA-256 ${fingerprint}, remembered (first connection)`);
2071
3152
  return;
2072
3153
  }
2073
3154
  if (fingerprint !== known) {
2074
- session.certificateProblem = { expected: known, actual: fingerprint, pinned: false };
3155
+ session.certificateProblem = { expected: known, actual: fingerprint, pinned: false, ...details ? { details } : {} };
2075
3156
  throw new Error(`certificate SHA-256 ${fingerprint} is not the one remembered (${known})`);
2076
3157
  }
2077
3158
  session.log.push(`certificate: SHA-256 ${fingerprint}, as remembered`);
@@ -2120,9 +3201,12 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2120
3201
  return choice ?? stopped(`Not opened: the desktop on ${target.label} is open somewhere else (another Tabby window or computer).`);
2121
3202
  }
2122
3203
  /**
2123
- * After the proxy refused a certificate; nothing was sent to that server. The host's own desktop: an error, since
2124
- * the plugin made that certificate itself. Others: both fingerprints, to trust the new certificate or not. Either
2125
- * way, automatic reconnecting stops here. Resolves true to connect again (the new certificate is then trusted).
3204
+ * After the proxy refused a certificate; nothing of the sign-in was sent to that server (only the connection request
3205
+ * and the TLS handshake). The host's own desktop: an error, since the plugin made that certificate itself. Others: a
3206
+ * first one (see checkTrusted) with why it can't be verified (see whyNotValid), its fingerprint and what it says of
3207
+ * itself (marked as its own word unless an authority vouched for it, see vouched), a changed one with both
3208
+ * fingerprints, to trust it or not; Cancel is what Enter picks. Either way, automatic reconnecting stops here.
3209
+ * Resolves true to connect again (the certificate is then trusted).
2126
3210
  */
2127
3211
  async refusedCertificate(pane, target, spec, session, endpoint, problem) {
2128
3212
  this.cancelReconnect(pane);
@@ -2132,24 +3216,73 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2132
3216
  this.changed$.next();
2133
3217
  session.status(message, [{ label: 'Try again', run: () => this.reopen(pane, spec) }], true);
2134
3218
  };
2135
- const fingerprints = `${problem.pinned ? 'Expected' : 'Remembered'} (SHA-256):\n${showFingerprint(problem.expected)}\n\nNow:\n${showFingerprint(problem.actual)}`;
2136
3219
  if (problem.pinned) {
3220
+ const fingerprints = `Expected (SHA-256):\n${showFingerprint(problem.expected)}\n\nNow:\n${showFingerprint(problem.actual)}`;
2137
3221
  stop(`The remote desktop on ${target.label} answered with a certificate other than the one set up for it. ` +
2138
3222
  `The connection was stopped before signing in.\n\n${fingerprints}\n\n` +
2139
3223
  `Something other than GNOME Remote Desktop may be listening on port ${endpoint.port}.`);
2140
3224
  return false;
2141
3225
  }
2142
- // The desktop's, or its gateway's.
3226
+ // The desktop's, or its gateway's. With nothing remembered (no expected fingerprint), a first use: a certificate
3227
+ // no authority vouches for, where the sign-in would go, confirmed rather than trusted silently (see checkTrusted).
2143
3228
  const whose = problem.gateway ? `${spec.name}'s gateway ${problem.gateway.address}` : spec.name;
2144
- if (!session.visible) {
2145
- this.notifications.notice(`The certificate of ${whose}${target.direct ? '' : ` (via ${target.label})`} has changed. Open that desktop to decide.`);
2146
- }
3229
+ const it = problem.gateway ? 'the gateway' : spec.name;
3230
+ const via = target.direct ? '' : ` (via ${target.label})`;
3231
+ // Named as the certificate was checked for them, as the .rdp import shows names: the desktop's as it resolves
3232
+ // (an international name as its punycode, see serverNameOf), its gateway in its one form (see parseGateway).
3233
+ const address = problem.gateway?.address ?? (0, desktopForm_1.formatAddress)(problem.name ?? spec.host, spec.port);
3234
+ const throughGateway = !problem.gateway && spec.gateway ? (0, gateway_1.parseGateway)(spec.gateway) : null;
3235
+ const firstUse = !problem.expected;
3236
+ // What the certificate says of itself, under its fingerprint (shown as the import shows a file's): an issuer
3237
+ // one wouldn't expect there (an authority that inspects the network's TLS) tells against it. Unless an
3238
+ // authority vouched for it (`valid`), or its chain leads to one this computer trusts (another machine's
3239
+ // certificate, which the names in it give away), all of it is the certificate's own word, which a certificate
3240
+ // anyone made can match, an organisation's own issuer included: the line says so, where the user decides.
3241
+ const about = problem.details ? `\n${(0, authorities_1.describeCertificate)(problem.details, !(0, authorities_1.vouched)(problem.valid, problem.details))}` : '';
3242
+ const fingerprints = firstUse
3243
+ ? `SHA-256:\n${showFingerprint(problem.actual)}${about}`
3244
+ : `Remembered (SHA-256):\n${showFingerprint(problem.expected)}\n\nNow:\n${showFingerprint(problem.actual)}${about}`;
3245
+ // Also for a desktop showing in a tab that isn't in front: the question doesn't take the keyboard there (see
3246
+ // DesktopSession.status), and would wait unseen. A note that stays a while (notice() is gone in a second).
3247
+ if (!this.inView(pane, session)) {
3248
+ this.notifications.info(firstUse
3249
+ ? `${whose}${via} has a certificate this computer can't verify. Open that desktop to decide.`
3250
+ : `The certificate of ${whose}${via} has changed. Open that desktop to decide.`);
3251
+ }
3252
+ // Signing in without Network Level Authentication was allowed for the server with the old certificate, and goes
3253
+ // with it, as when that is forgotten (see forgetCertificate); so does an answer given just now, before this
3254
+ // certificate showed. A gateway's certificate has nothing to do with it.
3255
+ const plain = !problem.gateway && (session.sendAnyway || this.allowedWithoutNla(session.authKey));
3256
+ // Where an authority's word counts: why the certificate isn't valid, or, for one that wasn't remembered on an
3257
+ // authority's word replaced by one an authority vouches for, why that is asked about all the same.
3258
+ const why = problem.valid === false ? (0, authorities_1.whyNotValid)(problem.reason ?? '', problem.name ?? address, problem.details) : '';
3259
+ const verdict = problem.valid === undefined ? ''
3260
+ : problem.valid ? `The new one is valid for ${problem.name} by this computer's certificate authorities, but the one it replaces ` +
3261
+ `${problem.replaces === 'yours' ? 'is one you trusted yourself' : 'was remembered the first time it was seen, without that check'}. `
3262
+ : `The new one can't be verified: ${why}. `;
3263
+ // What signing in gives the server: a proof of the password, with Network Level Authentication; without it (the
3264
+ // proxy let this attempt's server on without, see checkWithoutNla), the password itself. A gateway gets a proof.
3265
+ const gives = !problem.gateway && session.plainSignIn
3266
+ ? `This server doesn't use Network Level Authentication: signing in sends it your password itself, readable to a server posing as ${it}.`
3267
+ : `Signing in gives ${it} a proof of your password, which a server posing as ${it} could try to crack offline.`;
2147
3268
  const trusted = await new Promise(resolve => {
2148
- session.status(`The certificate of ${whose} has changed since it was last used. Nothing has been sent to it.\n\n` +
2149
- `${fingerprints}\n\nReinstalling the machine or renewing its certificate changes it. If neither happened, ` +
2150
- `something else may be answering at ${problem.gateway?.address ?? `${spec.host}:${spec.port}`}.`, [
2151
- { label: 'Trust the new certificate', run: () => resolve(true) },
2152
- { label: 'Cancel', run: () => resolve(false) },
3269
+ // Nothing of the sign-in: the connection request and the TLS handshake have gone to the server by now.
3270
+ session.status(firstUse
3271
+ ? `${whose}${problem.gateway ? '' : ` (${address}${throughGateway ? `, through the gateway ${(0, gateway_1.formatGateway)(throughGateway)}` : ''})`} ` +
3272
+ `has a certificate this computer can't verify: ${why}. Nothing of your sign-in has been sent to it yet` +
3273
+ // The gateway on the way has had the sign-in already, as its own certificate allowed.
3274
+ `${throughGateway ? '; only the gateway has had your sign-in' : ''}. ${gives}\n\n${fingerprints}\n\n` +
3275
+ `Check this fingerprint with whoever runs ${it}, some other way than this connection, before you trust it. ` +
3276
+ 'Once trusted, it is remembered, and a different certificate there stops the connection again.'
3277
+ : `The certificate of ${whose} has changed since it was last used. Nothing of your sign-in has been sent to it.\n\n` +
3278
+ `${fingerprints}\n\n${verdict}Reinstalling the machine or renewing its certificate changes it. If neither happened, ` +
3279
+ `something else may be answering at ${address}.` +
3280
+ (plain ? '\n\nIt was allowed to sign in without Network Level Authentication, which goes with the old certificate: ' +
3281
+ 'if the server still doesn\'t use it, you\'ll be asked again.'
3282
+ // An xrdp desktop of the user's, which signs in that way unasked (see xrdpUnasked).
3283
+ : !problem.gateway && session.plainSignIn ? `\n\n${gives}` : ''), [
3284
+ { label: firstUse ? 'Trust the certificate' : 'Trust the new certificate', run: () => resolve(true) },
3285
+ { label: 'Cancel', run: () => resolve(false), default: true },
2153
3286
  ], true);
2154
3287
  session.disposed.then(() => resolve(false));
2155
3288
  });
@@ -2157,23 +3290,36 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2157
3290
  return false;
2158
3291
  }
2159
3292
  if (!trusted) {
2160
- stop('Not connected: the new certificate was not trusted.');
3293
+ stop(firstUse ? 'Not connected: the certificate was not trusted.' : 'Not connected: the new certificate was not trusted.');
2161
3294
  return false;
2162
3295
  }
2163
- this.trustCertificate(problem.gateway?.key ?? session.authKey, problem.actual);
2164
- session.log.push(`${problem.gateway ? 'gateway ' : ''}certificate: SHA-256 ${problem.actual}, trusted instead of ${problem.expected}`);
3296
+ // Trusting a certificate never allows signing in without NLA. One allowed for a desktop whose first certificate
3297
+ // is trusted now was allowed before any certificate of it was seen (that stop comes first, before TLS), and stays.
3298
+ if (!problem.gateway && !firstUse) {
3299
+ const store = this.config.store.remoteDesktop;
3300
+ store.withoutNla = (Array.isArray(store.withoutNla) ? store.withoutNla : []).filter((e) => e?.desktop !== session.authKey);
3301
+ session.sendAnyway = false;
3302
+ if (plain) {
3303
+ session.log.push('sign-in: no longer allowed without Network Level Authentication: that was for the old certificate');
3304
+ }
3305
+ }
3306
+ // Where an authority's word counts, remembered as one the user trusted, or (a replacement it vouches for) as its.
3307
+ this.trustCertificate(problem.gateway?.key ?? session.authKey, problem.actual, problem.valid);
3308
+ session.log.push(`${problem.gateway ? 'gateway ' : ''}certificate: SHA-256 ${problem.actual}, ${firstUse ? 'trusted (first connection)' : `trusted instead of ${problem.expected}`}`);
2165
3309
  return true;
2166
3310
  }
2167
- /** The certificate remembered for a desktop behind a host (`remoteDesktop.trustedCertificates`), if any. */
3311
+ /** The certificate remembered for a desktop or a gateway (`remoteDesktop.trustedCertificates`), if any. */
2168
3312
  trustedCertificate(key) {
2169
3313
  const list = this.config.store.remoteDesktop?.trustedCertificates;
2170
- const value = (Array.isArray(list) ? list : []).find((e) => e?.desktop === key)?.sha256;
2171
- return typeof value === 'string' ? (0, rdcleanpath_1.normalizeFingerprint)(value) || null : null;
3314
+ const entry = (Array.isArray(list) ? list : []).find((e) => e?.desktop === key);
3315
+ const sha256 = typeof entry?.sha256 === 'string' ? (0, rdcleanpath_1.normalizeFingerprint)(entry.sha256) : '';
3316
+ return sha256 ? { sha256, ...typeof entry.authority === 'boolean' ? { authority: entry.authority } : {} } : null;
2172
3317
  }
2173
- trustCertificate(key, fingerprint) {
3318
+ /** Remembers a server's certificate; `authority` as RememberedCertificate has it (none: trusted on first use). */
3319
+ trustCertificate(key, fingerprint, authority) {
2174
3320
  const store = this.config.store.remoteDesktop;
2175
3321
  const others = (Array.isArray(store.trustedCertificates) ? store.trustedCertificates : []).filter((e) => e?.desktop !== key);
2176
- store.trustedCertificates = [...others, { desktop: key, sha256: fingerprint }];
3322
+ store.trustedCertificates = [...others, { desktop: key, sha256: fingerprint, ...authority === undefined ? {} : { authority } }];
2177
3323
  this.config.save();
2178
3324
  }
2179
3325
  /**
@@ -2183,9 +3329,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2183
3329
  forgetCertificatesFor(desktopId, direct = false) {
2184
3330
  const store = this.config.store.remoteDesktop;
2185
3331
  const list = Array.isArray(store.trustedCertificates) ? store.trustedCertificates : [];
2186
- const forget = (key) => direct ? key === `${desktops_1.DIRECT_KEY}#${desktopId}` : key.endsWith(`#${desktopId}`) && !key.startsWith(`${desktops_1.DIRECT_KEY}#`);
2187
- store.trustedCertificates = list.filter((e) => !forget(String(e?.desktop ?? '')));
2188
- store.withoutNla = (Array.isArray(store.withoutNla) ? store.withoutNla : []).filter((e) => !forget(String(e?.desktop ?? '')));
3332
+ // Anchored on the key's parts (see keyParts): a gateway pin or a desktop whose id is a suffix of this one's
3333
+ // keeps its certificate.
3334
+ const forget = (key) => {
3335
+ const { target, desktopId: id } = (0, desktops_1.keyParts)(key);
3336
+ return id === desktopId && (direct ? target === desktops_1.DIRECT_KEY : target !== desktops_1.DIRECT_KEY && target !== 'gateway');
3337
+ };
3338
+ // Keys as text only (String() of an object from the config can throw), as desktopEdited reads them.
3339
+ store.trustedCertificates = list.filter((e) => !(typeof e?.desktop === 'string' && forget(e.desktop)));
3340
+ store.withoutNla = (Array.isArray(store.withoutNla) ? store.withoutNla : []).filter((e) => !(typeof e?.desktop === 'string' && forget(e.desktop)));
2189
3341
  }
2190
3342
  /**
2191
3343
  * For a desktop with `wake`: when its RDP server doesn't answer (probed from the SSH host), starts it and waits
@@ -2244,6 +3396,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2244
3396
  session.certificateProblem = null;
2245
3397
  session.gatewayRefused = null;
2246
3398
  session.withoutNla = false;
3399
+ session.plainSignIn = false;
2247
3400
  const el = document.createElement('iron-remote-desktop');
2248
3401
  try {
2249
3402
  el.setAttribute('scale', this.screenScale() === 3 ? 'real' : 'fit');
@@ -2251,11 +3404,22 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2251
3404
  el.module = rdp.Backend;
2252
3405
  const ready = new Promise(resolve => el.addEventListener('ready', (e) => resolve(e.detail.irgUserInteraction), { once: true }));
2253
3406
  session.host.replaceChildren(el);
2254
- session.ui = await ready;
2255
- // Files through the clipboard: registered before connecting, which sets up its channel messages.
3407
+ // The element only gets ready in the page: one whose desktop was closed meanwhile (the layer gone) never does.
3408
+ // 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)]);
3410
+ if (!session.ui || !alive()) {
3411
+ return { connected: false };
3412
+ }
3413
+ // Files through the clipboard: registered before connecting, which sets up its channel messages. The
3414
+ // clipboard goes only the ways the settings let it, as they are now, for this connection (see clipboard.ts).
2256
3415
  session.files?.dispose();
2257
- session.files = new fileTransfer_1.FileTransfer(rdp, session.overlay, m => session.log.push(m));
2258
- session.ui.enableFileTransfer(session.files.provider);
3416
+ // What it reads for the remote waits for room in the connection (see ServerFlow).
3417
+ session.files = new fileTransfer_1.FileTransfer(rdp, session.overlay, m => session.log.push(m), () => this.clipboardWaysOf(session), session.proxy ?? undefined);
3418
+ const clipboard = (0, clipboard_1.setUpClipboard)(session.ui, session.files.provider, this.settingsFor(session).clipboard, () => this.clipboardWaysOf(session).toRemote);
3419
+ session.clipboard = clipboard.mode;
3420
+ session.stopSending = clipboard.stopSending;
3421
+ session.log.push(`clipboard: ${clipboard.mode === 'both' ? 'both ways'
3422
+ : clipboard.mode === 'fromRemote' ? 'only from the remote desktop (this computer\'s is neither read nor sent)' : 'off (no clipboard channel)'}`);
2259
3423
  // The component outlines itself while it has the keyboard; the desktop should look like the
2260
3424
  // terminal it replaces, edge to edge. Its styles live in a shadow root, so adopt a sheet there.
2261
3425
  // Not a <style> element: the component only forwards keys while its own container is the shadow
@@ -2298,7 +3462,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2298
3462
  }
2299
3463
  session.h264?.close();
2300
3464
  session.h264 = null;
2301
- if (spec.kind !== 'xrdp' && !basic && typeof rdp.graphicsPipeline === 'function' && await this.useH264(rdp, session, settings)) {
3465
+ const h264 = spec.kind !== 'xrdp' && !basic && typeof rdp.graphicsPipeline === 'function' && await this.useH264(rdp, session, settings);
3466
+ // Asking the browser about H.264 can take a moment, the first time: a desktop closed meanwhile gets no decoder,
3467
+ // sound, microphone or drives, which nothing would close.
3468
+ if (!alive()) {
3469
+ return { connected: false };
3470
+ }
3471
+ if (h264) {
2302
3472
  session.h264 = new rdp.WebCodecsH264Decoder({ onFailure: (reason) => session.log.push(`h264: failed: ${reason}`) });
2303
3473
  config.withExtension(rdp.h264Decoder(session.h264));
2304
3474
  }
@@ -2321,9 +3491,16 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2321
3491
  }
2322
3492
  session.mic?.close();
2323
3493
  session.mic = null;
2324
- if (settings.microphone && !basic && typeof rdp.audioInput === 'function') {
3494
+ // The setting as it is now: `settings` was read before the wait for H.264 above, and turned off meanwhile it
3495
+ // found no microphone here to stop (see applyMicrophone).
3496
+ if (this.settings().microphone && !basic && typeof rdp.audioInput === 'function') {
2325
3497
  const ui = session.ui;
2326
- const mic = new microphone_1.Microphone(pcm => ui.invokeExtension(rdp.audioInputData(pcm)), () => session.overlay.classList.toggle('trd-mic-on', mic.capturing), m => session.log.push(m), m => this.zone.run(() => this.notifications.error(m)));
3498
+ const mic = new microphone_1.Microphone(pcm => ui.invokeExtension(rdp.audioInputData(pcm)), () => this.microphoneChanged(pane, session, mic), m => session.log.push(m), m => this.zone.run(() => this.notifications.error(m)));
3499
+ // Stopped for this desktop, also on this connection, which its server may have brought about by ending
3500
+ // the last one. The remote can still open the microphone, and gets nothing until it is turned back on.
3501
+ if (this.microphoneStopped.has(session.key)) {
3502
+ mic.turnOff();
3503
+ }
2327
3504
  session.mic = mic;
2328
3505
  config.withExtension(rdp.audioInput(mic.callback));
2329
3506
  }
@@ -2333,7 +3510,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2333
3510
  session.drives = null;
2334
3511
  const folders = this.sharedFolders();
2335
3512
  if (folders.length && spec.kind !== 'gnome' && !basic && typeof rdp.driveRedirection === 'function') {
2336
- session.drives = new drives_1.SharedDrives(folders, m => session.log.push(m));
3513
+ session.drives = new drives_1.SharedDrives(folders, m => session.log.push(m), session.proxy ?? undefined);
2337
3514
  config.withExtension(rdp.driveRedirection(session.drives));
2338
3515
  session.log.push(`drives: ${folders.map(f => `${f.name}${f.readOnly ? ' (read-only)' : ''}`).join(', ')}`);
2339
3516
  }
@@ -2358,7 +3535,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2358
3535
  }
2359
3536
  // IronErrorKind: 1 WrongPassword, 2 LogonFailure.
2360
3537
  const kind = typeof e?.kind === 'function' ? e.kind() : undefined;
2361
- const detail = typeof e?.backtrace === 'function' ? e.backtrace() : (e?.message ?? String(e));
3538
+ // Not the proxy's token, which is in the address IronRDP names when it can't connect there.
3539
+ const detail = (0, rdcleanpath_1.withoutToken)(typeof e?.backtrace === 'function' ? e.backtrace() : (e?.message ?? String(e)), session.proxy);
2362
3540
  if (kind === 1 || kind === 2) {
2363
3541
  session.log.push(`sign-in failed: ${detail.split('\n')[0]}`);
2364
3542
  return { connected: false, signInFailed: kind === 1 ? 'Wrong user name or password.' : 'The sign-in was refused.' };
@@ -2384,19 +3562,31 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2384
3562
  this.label(pane, session);
2385
3563
  this.syncIndicator(session);
2386
3564
  this.changed$.next();
2387
- this.reconnects.delete(pane); // connected again: a later drop starts over
2388
3565
  // Under a saved account only while that account is still there with the same sign-in name: the prompt may
2389
3566
  // have been open while it was removed or changed in Settings.
2390
3567
  const accountNow = endpoint.saveKey ? this.accounts().find(a => (0, accounts_1.accountKey)(a.id) === endpoint.saveKey) : undefined;
2391
3568
  if (endpoint.remember && endpoint.saveKey && (!accountNow || (0, accounts_1.signInName)(accountNow) !== endpoint.saveFor)) {
2392
3569
  session.log.push(`sign-in: not saved: the account ${accountNow ? 'changed' : 'was removed'} meanwhile`);
2393
3570
  }
2394
- else if (endpoint.remember) {
2395
- (0, signin_1.saveCredentials)(endpoint.saveKey ?? session.authKey, credentials, endpoint.saveLabel).then(() => session.log.push(`sign-in: saved ${endpoint.saveKey ? 'as the account\'s password ' : ''}in ${(0, signin_1.storeName)()}`), e => session.log.push(`sign-in: ${(0, signin_1.storeName)()}: ${e?.message ?? e}`));
3571
+ else if (endpoint.remember && !endpoint.saveKey && endpoint.mark !== undefined && (0, signin_1.forgottenSince)(session.credentialKey, endpoint.mark)) {
3572
+ // Edited (its user name, gateway or address) or removed while the server delayed the sign-in: what was
3573
+ // forgotten then stays forgotten, and the desktop asks the next time.
3574
+ session.log.push('sign-in: not saved: the desktop was changed or removed meanwhile');
2396
3575
  }
2397
- if (session.visible) {
2398
- session.focusDesktop();
3576
+ else if (endpoint.remember) {
3577
+ const key = endpoint.saveKey ?? session.credentialKey;
3578
+ const writing = (0, signin_1.forgetMark)();
3579
+ (0, signin_1.saveCredentials)(key, credentials, endpoint.saveLabel).then(() => {
3580
+ // A forget that came while it was being written (an edit, a removal) found nothing to forget
3581
+ // yet: it wins, and the password goes again now that it is there.
3582
+ if (!endpoint.saveKey && (0, signin_1.forgottenSince)(key, writing)) {
3583
+ session.log.push('sign-in: saved, then forgotten again: the desktop was changed or removed meanwhile');
3584
+ return (0, signin_1.forgetCredentials)(key);
3585
+ }
3586
+ session.log.push(`sign-in: saved ${endpoint.saveKey ? 'as the account\'s password ' : ''}in ${(0, signin_1.storeName)()}`);
3587
+ }, e => session.log.push(`sign-in: ${(0, signin_1.storeName)()}: ${e?.message ?? e}`));
2399
3588
  }
3589
+ this.focusIfInFront(pane, session);
2400
3590
  const connectedAt = Date.now();
2401
3591
  let end;
2402
3592
  try {
@@ -2406,13 +3596,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2406
3596
  if (session.h264?.failed) {
2407
3597
  // Reconnects (automatically, like any dropped connection) without H.264, unless the browser only
2408
3598
  // took an idle decoder back (a hidden window): then with it, as a new stream starts with a key frame.
2409
- if (session.h264.reclaimed) {
3599
+ // Taken back again soon after, it goes like any other failure: the connection would otherwise start
3600
+ // over and over for as long as the window stays hidden, or the browser keeps taking decoders back.
3601
+ if (session.h264.reclaimed && !this.decoderReclaimedAgain(session.key)) {
2410
3602
  return { connected: true, error: 'the video decoder was reclaimed' };
2411
3603
  }
2412
3604
  this.h264Failed.add(session.key);
2413
3605
  return { connected: true, error: `H.264 decoding failed (${session.h264.failed}); continuing without it` };
2414
3606
  }
2415
- const error = typeof e?.backtrace === 'function' ? e.backtrace().split('\n')[0] : (e?.message ?? String(e));
3607
+ const error = (0, rdcleanpath_1.withoutToken)(typeof e?.backtrace === 'function' ? e.backtrace().split('\n')[0] : (e?.message ?? String(e)), session.proxy);
2416
3608
  // A Hyper-V host signs anyone in that it knows, and ends the connection at once when that account may not
2417
3609
  // open the VM's console: a refused sign-in in effect, so the form comes back, saying so.
2418
3610
  if (spec.hyperv && Date.now() - connectedAt < 5000 && /disconnect provider ultimatum/i.test(error)) {
@@ -2425,6 +3617,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2425
3617
  // The server can't close the microphone, or its files, once the connection is gone.
2426
3618
  session.mic?.close();
2427
3619
  session.drives?.dispose();
3620
+ if (alive()) {
3621
+ this.reconnectedUntilNow(pane, connectedAt);
3622
+ }
2428
3623
  }
2429
3624
  return { connected: true, reason: end?.reason?.() };
2430
3625
  }
@@ -2435,6 +3630,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2435
3630
  };
2436
3631
  exports.RemoteDesktopService = RemoteDesktopService;
2437
3632
  RemoteDesktopService.RECONNECT_DELAYS = [1, 2, 4, 8, 15, 30];
3633
+ /** How long a connection has to stay up to count as back: a drop after that starts the delays over. */
3634
+ RemoteDesktopService.STABLE_MS = 30000;
3635
+ /** A video decoder the browser takes back twice within this turns H.264 off for the desktop (see run()). */
3636
+ RemoteDesktopService.RECLAIMED_AGAIN_MS = 10 * 60000;
2438
3637
  exports.RemoteDesktopService = RemoteDesktopService = RemoteDesktopService_1 = __decorate([
2439
3638
  (0, core_1.Injectable)({ providedIn: 'root' }),
2440
3639
  __metadata("design:paramtypes", [tabby_core_1.AppService,
@@ -2488,4 +3687,3 @@ function keepWindowDraggable() {
2488
3687
  }
2489
3688
  }).observe(document.body, { childList: true });
2490
3689
  }
2491
- //# sourceMappingURL=desktop.service.js.map