tabby-rdp 0.4.1 → 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 (76) hide show
  1. package/CHANGELOG.md +580 -0
  2. package/README.md +252 -34
  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 +1763 -204
  13. package/dist/desktopForm.js +72 -13
  14. package/dist/desktops.js +243 -19
  15. package/dist/drives.js +525 -0
  16. package/dist/fileTransfer.js +1064 -72
  17. package/dist/gateway.js +651 -0
  18. package/dist/header.js +76 -5
  19. package/dist/help.js +34 -13
  20. package/dist/hyperv.js +129 -0
  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 +225 -0
  25. package/dist/osd.js +56 -15
  26. package/dist/probe.js +15 -3
  27. package/dist/rdcleanpath.js +468 -52
  28. package/dist/rdpFile.js +53 -4
  29. package/dist/rdpProfile.js +75 -22
  30. package/dist/remoteSetup.js +103 -23
  31. package/dist/sessionLog.js +53 -0
  32. package/dist/settingsPage.js +117 -27
  33. package/dist/signin.js +429 -54
  34. package/dist/ssh.js +99 -18
  35. package/dist/sshChannelStream.js +50 -6
  36. package/dist/targets.js +185 -39
  37. package/dist/ui.js +98 -20
  38. package/dist/unshowable.js +41 -0
  39. package/dist/updates.js +37 -2
  40. package/dist/vms.js +36 -5
  41. package/dist/wake.js +31 -12
  42. package/package.json +4 -3
  43. package/remote/trd-pty.py +74 -13
  44. package/vendor/SHA256SUMS +26 -0
  45. package/vendor/iron-remote-desktop-rdp.js +418 -390
  46. package/vendor/ironrdp_web_bg.wasm +0 -0
  47. package/dist/accountForm.js.map +0 -1
  48. package/dist/accounts.js.map +0 -1
  49. package/dist/audio.js.map +0 -1
  50. package/dist/connectionStatus.js.map +0 -1
  51. package/dist/desk.js.map +0 -1
  52. package/dist/deskScript.js.map +0 -1
  53. package/dist/desktop.service.js.map +0 -1
  54. package/dist/desktopForm.js.map +0 -1
  55. package/dist/desktops.js.map +0 -1
  56. package/dist/fileTransfer.js.map +0 -1
  57. package/dist/header.js.map +0 -1
  58. package/dist/help.js.map +0 -1
  59. package/dist/index.js.map +0 -1
  60. package/dist/keyboard.js.map +0 -1
  61. package/dist/microphone.js.map +0 -1
  62. package/dist/osd.js.map +0 -1
  63. package/dist/probe.js.map +0 -1
  64. package/dist/rdcleanpath.js.map +0 -1
  65. package/dist/rdpFile.js.map +0 -1
  66. package/dist/rdpProfile.js.map +0 -1
  67. package/dist/remoteSetup.js.map +0 -1
  68. package/dist/settingsPage.js.map +0 -1
  69. package/dist/signin.js.map +0 -1
  70. package/dist/ssh.js.map +0 -1
  71. package/dist/sshChannelStream.js.map +0 -1
  72. package/dist/targets.js.map +0 -1
  73. package/dist/ui.js.map +0 -1
  74. package/dist/updates.js.map +0 -1
  75. package/dist/vms.js.map +0 -1
  76. 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,12 +18,15 @@ 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");
29
+ const drives_1 = require("./drives");
27
30
  const desktopForm_1 = require("./desktopForm");
28
31
  const accounts_1 = require("./accounts");
29
32
  const accountForm_1 = require("./accountForm");
@@ -37,7 +40,18 @@ const rdcleanpath_1 = require("./rdcleanpath");
37
40
  const signin_1 = require("./signin");
38
41
  const ssh_1 = require("./ssh");
39
42
  const wake_1 = require("./wake");
43
+ const hyperv_1 = require("./hyperv");
44
+ const gateway_1 = require("./gateway");
45
+ const authorities_1 = require("./authorities");
40
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';
41
55
  /** How long a desktop that was started (`wake`) gets to answer. */
42
56
  const WAKE_TIMEOUT_MS = 3 * 60 * 1000;
43
57
  // The vendored IronRDP bundles are ES modules. tsc turns import() into require(), and Node's import() is refused in
@@ -76,6 +90,7 @@ const STYLE = `
76
90
  .trd-status-help { pointer-events: auto; color: #8ab4f8; font-size: 12px; cursor: pointer; }
77
91
  .trd-status-help:hover { text-decoration: underline; }
78
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; }
79
94
  .trd-view-only { display: none; position: absolute; inset: 0; }
80
95
  .trd-overlay.trd-view-only-on .trd-view-only { display: block; }
81
96
  .trd-overlay.trd-broadcast-on::before { content: 'Typing into all desktops in this tab'; position: absolute; top: 8px; left: 50%;
@@ -83,11 +98,26 @@ const STYLE = `
83
98
  font-size: 11px; pointer-events: none; white-space: nowrap; }
84
99
  .trd-view-only::after { content: 'View only'; position: absolute; top: 8px; left: 8px; padding: 2px 8px; border-radius: 4px;
85
100
  background: rgba(30, 30, 30, 0.85); color: #ddd; font-size: 11px; pointer-events: none; }
86
- .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;
87
102
  align-items: center; justify-content: center; background: rgba(220, 38, 38, 0.85); color: #fff; font-size: 11px;
88
103
  pointer-events: none; }
89
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; }
90
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
+ }
91
121
  /** A SHA-256 fingerprint on two lines of 16 bytes, so it fits a narrow pane. */
92
122
  const showFingerprint = (fingerprint) => fingerprint ? `${fingerprint.slice(0, 47)}\n${fingerprint.slice(48)}` : '(none)';
93
123
  /**
@@ -138,7 +168,25 @@ function remoteSizeFor(rect, settings) {
138
168
  scale: Math.round(dpr * 100),
139
169
  };
140
170
  }
171
+ /** Exported for the unit tests, which use its keys (credentialKey) without a page to put a layer in. */
141
172
  class DesktopSession {
173
+ /**
174
+ * What a sign-in and a certificate are remembered under: the desktop; for a Hyper-V VM, its host, whose account
175
+ * and certificate they are, the same for all of its VMs.
176
+ */
177
+ get authKey() {
178
+ return this.spec.hyperv ? this.key.replace(/#hyperv:[^#]*$/, '#hyperv') : this.key;
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
+ }
142
190
  /** `key`: see sessionKey(); one desktop per key. */
143
191
  constructor(container, key, spec) {
144
192
  this.key = key;
@@ -147,7 +195,8 @@ class DesktopSession {
147
195
  this.visible = false;
148
196
  this.proxy = null;
149
197
  this.ui = null;
150
- 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();
151
200
  /** What the remote display was last set to. */
152
201
  this.remoteSize = null;
153
202
  this.remote = null;
@@ -157,6 +206,22 @@ class DesktopSession {
157
206
  this.mic = null;
158
207
  /** Files through the clipboard (drop on the desktop, or copy on the remote). */
159
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;
223
+ /** The shared folders as drives on the remote (see drives.ts), when there were any at connect. */
224
+ this.drives = null;
160
225
  /** Decodes H.264 for the graphics pipeline (IronRDP's WebCodecsH264Decoder), when this connection uses it. */
161
226
  this.h264 = null;
162
227
  /** No keyboard or mouse input goes to the remote (keys: see keyboard.ts; the mouse: a layer over the picture). */
@@ -165,15 +230,37 @@ class DesktopSession {
165
230
  this.pinnedCertificate = null;
166
231
  /** Set when the proxy refused the server's certificate on the current connection attempt. */
167
232
  this.certificateProblem = null;
233
+ /** Set when the desktop's RD Gateway refused the sign-in on the current connection attempt: what it said. */
234
+ this.gatewayRefused = null;
235
+ /** Set when the proxy stopped at a server without Network Level Authentication on the current connection attempt. */
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;
168
247
  /** The connection-status indicator, made the first time it shows. */
169
248
  this.indicator = null;
170
249
  /** How the pictures come (for the indicator): the graphics pipeline, bitmaps, or IronRDP's choice. */
171
250
  this.graphics = '';
172
251
  /** "What does this mean?" under a message (see status()): opens help about it. */
173
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;
259
+ /** Keys this desktop was sent and not yet let go of (by `code`): see RemoteDesktopService.broadcastKey. */
260
+ this.keysDown = new Map();
174
261
  this.reclaimFocus = (event) => {
175
262
  if (this.visible && !this.overlay.contains(event.target)) {
176
- setTimeout(() => this.visible && this.focusDesktop());
263
+ this.focusSoon();
177
264
  }
178
265
  };
179
266
  this.disposers = [];
@@ -204,6 +291,30 @@ class DesktopSession {
204
291
  this.host.querySelector('iron-remote-desktop')?.shadowRoot?.querySelector('.screen-wrapper')?.scrollBy(wheel.deltaX, wheel.deltaY);
205
292
  }, { passive: true });
206
293
  }
294
+ /** Notes a key sent to this desktop as held, or let go. */
295
+ noteKey(type, init) {
296
+ if (type === 'keydown' && init.code) {
297
+ this.keysDown.set(init.code, init);
298
+ }
299
+ else if (type === 'keyup' && init.code) {
300
+ this.keysDown.delete(init.code);
301
+ }
302
+ }
303
+ /**
304
+ * Lets go of every key still held on this desktop. For when their keyups won't reach it: view only coming on, or
305
+ * typing into all going off, by a shortcut whose modifiers are down at that moment. They would stay down there.
306
+ */
307
+ releaseKeys() {
308
+ for (const [code, init] of [...this.keysDown]) {
309
+ this.keysDown.delete(code);
310
+ try {
311
+ this.ui?.sendKeyboardEvent(new KeyboardEvent('keyup', { key: init.key, code, location: init.location, cancelable: true }));
312
+ }
313
+ catch (e) {
314
+ this.log.push(`keys: ${code} not released: ${e?.message ?? e}`);
315
+ }
316
+ }
317
+ }
207
318
  setViewOnly(viewOnly) {
208
319
  this.viewOnly = viewOnly;
209
320
  this.overlay.classList.toggle('trd-view-only-on', viewOnly);
@@ -221,6 +332,14 @@ class DesktopSession {
221
332
  canvas() {
222
333
  return this.host.querySelector('iron-remote-desktop')?.shadowRoot?.querySelector('canvas') ?? null;
223
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
+ }
224
343
  /**
225
344
  * Shows or hides the desktop layer. While it shows, the terminal's input textarea is disabled so
226
345
  * nothing can focus it: keystrokes can't leak into the shell underneath, and Tabby's re-focus of
@@ -233,7 +352,7 @@ class DesktopSession {
233
352
  input.disabled = visible;
234
353
  });
235
354
  if (visible) {
236
- setTimeout(() => this.visible && this.focusDesktop());
355
+ this.focusSoon();
237
356
  }
238
357
  }
239
358
  /**
@@ -248,13 +367,19 @@ class DesktopSession {
248
367
  link.onclick = () => this.onHelp?.(msg);
249
368
  this.statusEl.querySelector('.trd-status-actions').replaceChildren(...actions.map(action => {
250
369
  const button = document.createElement('button');
251
- button.className = 'btn btn-secondary';
370
+ button.className = action.default ? 'btn btn-primary trd-status-default' : 'btn btn-secondary';
252
371
  button.textContent = action.label;
253
372
  button.addEventListener('click', () => action.run());
254
373
  return button;
255
374
  }));
256
375
  this.statusEl.style.display = msg || actions.length ? '' : 'none';
257
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
+ }
258
383
  }
259
384
  onDispose(f) {
260
385
  this.disposers.push(f);
@@ -270,10 +395,18 @@ class DesktopSession {
270
395
  }
271
396
  return;
272
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
+ }
273
406
  const host = this.host.querySelector('iron-remote-desktop');
274
407
  this.canvas()?.focus();
275
408
  if (host && document.activeElement !== host && attempts > 1 && this.visible) {
276
- setTimeout(() => this.visible && this.focusDesktop(attempts - 1), 16);
409
+ this.focusSoon(attempts - 1, 16);
277
410
  }
278
411
  }
279
412
  dispose() {
@@ -291,10 +424,12 @@ class DesktopSession {
291
424
  this.h264?.close();
292
425
  this.mic?.close();
293
426
  this.files?.dispose();
427
+ this.drives?.dispose();
294
428
  this.proxy?.close();
295
429
  this.overlay.remove();
296
430
  }
297
431
  }
432
+ exports.DesktopSession = DesktopSession;
298
433
  /**
299
434
  * A remote desktop per terminal pane (an SSH tab, or a local terminal running `ssh`), shown as a layer
300
435
  * over that pane's terminal. Toggling hides the layer and returns to the console; the RDP session keeps
@@ -319,6 +454,17 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
319
454
  this.h264Support = null;
320
455
  /** Desktops (session keys) where H.264 decoding failed: they connect without it until Tabby restarts. */
321
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;
322
468
  /**
323
469
  * What the setup last found on each SSH host (by target key): GNOME, xrdp (and its port) or Windows. Kept for as
324
470
  * long as Tabby runs; a Windows host is found out on the first connect there, which costs a failed setup.
@@ -329,22 +475,161 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
329
475
  * with a wrong password it shows its own login window rather than refusing, so the form never comes back.
330
476
  */
331
477
  this.askAgain = new WeakSet();
332
- /** VMs found on SSH hosts (see vms.ts), by target key: when, and as desktops. */
478
+ /** VMs found on SSH hosts (see vms.ts), by target key: when, and as desktops; and what the scans learnt of the host. */
333
479
  this.vms = new Map();
334
480
  this.lastUsed = new Map();
335
- /** 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
+ */
336
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();
337
498
  /** Split tabs where keys typed on one desktop go to all of them (see DesktopKeyboard). */
338
499
  this.broadcast = new WeakSet();
339
500
  /** How the pane's next connection to a GNOME desktop that's open elsewhere goes, when that was chosen already. */
340
501
  this.openChoices = new Map();
341
502
  /** Each pane's name for itself on hosts, for the note a take-over leaves there (see prepareRemoteDesktop). */
342
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
+ };
343
626
  }
344
627
  /**
345
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
346
629
  * the prompt, the machine it went to (as for a local terminal running ssh), through the tab's host. Asks when it
347
- * 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.
348
633
  */
349
634
  async remoteFor(pane) {
350
635
  const own = await this.targets.targetOf(pane);
@@ -353,10 +638,20 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
353
638
  return own;
354
639
  }
355
640
  let chosen = nested[0];
356
- 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.
357
646
  const picked = await this.selector.show('Which remote desktop?', [
358
- ...nested.map(n => ({ name: n.cmd.destination, description: `ssh running in a terminal on ${own.label}`, icon: 'fas fa-desktop', result: n })),
359
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
+ })),
360
655
  ]).catch(() => undefined);
361
656
  if (picked === undefined) {
362
657
  return undefined;
@@ -364,9 +659,28 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
364
659
  if (!picked) {
365
660
  return own;
366
661
  }
367
- 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();
368
683
  }
369
- return await this.targets.nestedTarget(pane, chosen).catch(() => null) ?? own;
370
684
  }
371
685
  has(pane) {
372
686
  this.prune();
@@ -399,7 +713,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
399
713
  async signInAgain(pane) {
400
714
  const session = this.sessions.get(pane);
401
715
  if (session && this.signsIn(session.spec)) {
402
- await (0, signin_1.forgetCredentials)(session.key);
716
+ await (0, signin_1.forgetCredentials)(session.credentialKey);
403
717
  // With a saved account, the form asks for its password anew (the saved one stays until then).
404
718
  this.askAgain.add(pane);
405
719
  await this.reopen(pane, session.spec);
@@ -418,7 +732,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
418
732
  ? (this.config.store.profiles ?? []).filter((p) => p?.type === targets_1.RDP_PROFILE_TYPE && p.options?.host && p.options.via === target.profileId)
419
733
  .map((p) => ({ ...this.profiles.getConfigProxyForProfile(p).options, name: p.name }))
420
734
  : [];
421
- 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);
422
736
  // Found VMs come last, and not where a configured desktop has the same address.
423
737
  const found = this.settings().discoverVMs ? this.vms.get(target.key)?.specs ?? [] : [];
424
738
  return [...specs, ...found.filter(f => !specs.some(s => s.id === f.id))];
@@ -438,23 +752,38 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
438
752
  if (known && Date.now() - known.at < 60000) {
439
753
  return Promise.resolve();
440
754
  }
441
- const scan = (0, vms_1.scanVMs)(target).then(found => {
442
- this.vms.set(target.key, { at: Date.now(), specs: found.map(vms_1.vmSpec) });
755
+ const host = known?.host ?? { windows: this.ownDesktops.get(target.key)?.kind === 'windows' || undefined };
756
+ const scan = (0, vms_1.scanVMs)(target, host).then(found => {
757
+ this.vms.set(target.key, { at: Date.now(), specs: found.map(vms_1.vmSpec), host });
443
758
  this.changed$.next();
444
759
  }, () => {
445
- this.vms.set(target.key, { at: Date.now(), specs: known?.specs ?? [] });
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 });
446
763
  });
447
- this.vms.set(target.key, { at: known?.at ?? 0, specs: known?.specs ?? [], scan });
764
+ this.vms.set(target.key, { at: known?.at ?? 0, specs: known?.specs ?? [], scan, host });
448
765
  return scan;
449
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
+ }
450
776
  /** Keeps a found VM open in the pane among the host's desktops (`remoteDesktop.desktops`), under its name. */
451
777
  saveFoundDesktop(pane) {
452
778
  const session = this.sessions.get(pane);
453
779
  const spec = session?.spec;
454
- if (!session?.remote || !spec?.found) {
780
+ if (!session?.remote || !spec?.found || !this.canSaveFoundDesktop(pane)) {
455
781
  return;
456
782
  }
457
- const entry = { name: spec.name, via: session.remote.label, host: spec.host, port: spec.port, kind: spec.kind, ...spec.wake ? { wake: spec.wake } : {} };
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.
785
+ const entry = spec.hyperv ? { name: spec.name, via: session.remote.label, hyperv: spec.hyperv }
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 } : {} };
458
787
  this.config.store.remoteDesktop.desktops = [...this.configuredDesktops(), entry];
459
788
  this.config.save();
460
789
  this.changed$.next();
@@ -467,12 +796,22 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
467
796
  }
468
797
  /**
469
798
  * The desktops of the pane's SSH host (from the last lookup) and the one a plain toggle acts on: the one
470
- * 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.
471
803
  */
472
804
  choicesOf(pane) {
473
805
  const target = this.targets.cached(pane);
474
806
  const specs = target ? this.desktopsOf(target) : [];
475
- 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;
476
815
  }
477
816
  /** "Send files…": picks files to paste there. */
478
817
  sendFiles(pane) {
@@ -486,21 +825,28 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
486
825
  desktopOf(pane) {
487
826
  return this.sessions.get(pane)?.spec ?? null;
488
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
+ }
489
832
  /** `remoteDesktop.accounts`: the saved accounts desktops can sign in with (see accounts.ts). */
490
833
  accounts() {
491
834
  return (0, accounts_1.accountsOf)(this.config.store.remoteDesktop);
492
835
  }
493
836
  /**
494
- * The desktops that sign in with a saved account: configured ones behind SSH hosts, and RDP profiles (their own
495
- * choice, or their profile group's default when they make none). Each comes with what opens its editor.
837
+ * The desktops that sign in with a saved account, or sign in to their RD Gateway with it: configured ones behind
838
+ * SSH hosts, and RDP profiles (their own choice, or their profile group's default when they make none). Each comes
839
+ * with what opens its editor.
496
840
  */
497
841
  accountUses(id) {
498
- const desktops = this.configuredDesktops().flatMap((d, i) => d.account === 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 }] : []);
499
845
  const groups = this.config.store.groups ?? [];
500
846
  const inherited = (p) => p.options?.account === undefined && groups.find(g => g.id === p.group)?.defaults?.[targets_1.RDP_PROFILE_TYPE]?.options?.account === id;
501
847
  const profiles = (this.config.store.profiles ?? [])
502
- .filter((p) => p?.type === targets_1.RDP_PROFILE_TYPE && (p.options?.account === id || inherited(p)))
503
- .map((p) => ({ name: String(p.name ?? p.options?.host ?? ''), profileId: p.id ? String(p.id) : undefined }));
848
+ .filter((p) => p?.type === targets_1.RDP_PROFILE_TYPE && (p.options?.account === id || p.options?.gatewayAccount === id || inherited(p)))
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 }));
504
850
  return [...desktops, ...profiles];
505
851
  }
506
852
  /**
@@ -584,18 +930,29 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
584
930
  async removeAccount(id) {
585
931
  const store = this.config.store.remoteDesktop;
586
932
  store.accounts = this.accounts().filter(a => a.id !== id);
587
- if (this.configuredDesktops().some(d => d.account === id)) {
933
+ if (this.configuredDesktops().some(d => (0, desktops_1.isDesktopEntry)(d) && (d.account === id || d.gatewayAccount === id))) {
588
934
  store.desktops = this.configuredDesktops().map(d => {
589
- if (d.account !== id) {
935
+ if (!(0, desktops_1.isDesktopEntry)(d)) {
590
936
  return d;
591
937
  }
592
- const { account: _account, ...rest } = d;
938
+ const rest = { ...d };
939
+ if (rest.account === id) {
940
+ delete rest.account;
941
+ }
942
+ // A gateway that signed in with it takes the desktop's sign-in from now on.
943
+ if (rest.gatewayAccount === id) {
944
+ delete rest.gatewayAccount;
945
+ }
593
946
  return rest;
594
947
  });
595
948
  }
949
+ // Null rather than '': '' takes the group's default account, should there be one, where the profile is to ask.
596
950
  for (const profile of this.config.store.profiles ?? []) {
597
951
  if (profile?.type === targets_1.RDP_PROFILE_TYPE && profile.options?.account === id) {
598
- profile.options.account = '';
952
+ profile.options.account = null;
953
+ }
954
+ if (profile?.type === targets_1.RDP_PROFILE_TYPE && profile.options?.gatewayAccount === id) {
955
+ profile.options.gatewayAccount = null;
599
956
  }
600
957
  }
601
958
  // A profile group's default for remote desktop profiles, too.
@@ -625,7 +982,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
625
982
  this.showConsole(pane);
626
983
  }
627
984
  // The resolved hostname, so a profile tab and a plain `ssh` to that machine both offer it.
628
- const hostname = target.key.replace(/:\d+$/, '').replace(/^[^@]*@/, '');
985
+ const hostname = (0, desktops_1.keyFields)(target.key).host;
629
986
  const entry = await (0, desktopForm_1.askDesktop)(pane.element.nativeElement, {
630
987
  title: `Add a desktop reached through ${target.label}`,
631
988
  action: 'Add and open',
@@ -633,6 +990,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
633
990
  addAccount: input => this.addAccountFromForm(input),
634
991
  entry: { via: hostname, host: '127.0.0.1', port: 3389 },
635
992
  check: e => this.addressTaken(e, -1),
993
+ clipboard: this.settings().clipboard,
636
994
  });
637
995
  if (!entry) {
638
996
  pane.frontend?.focus();
@@ -646,13 +1004,14 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
646
1004
  /** Another entry for the same host already has this address (desktops are told apart by it). */
647
1005
  addressTaken(entry, index) {
648
1006
  const id = (0, desktops_1.desktopIdOf)(entry);
649
- const via = (entry.via ?? '').trim().toLowerCase();
650
- const taken = this.configuredDesktops().some((d, i) => i !== index && (0, desktops_1.desktopIdOf)(d) === id && (d.via ?? '').trim().toLowerCase() === via);
651
- 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;
652
1011
  }
653
1012
  /**
654
- * "Edit a desktop": the same form, over the pane, filled in. A new address takes the desktop's saved accounts and
655
- * 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.
656
1015
  */
657
1016
  async editDesktop(pane, index) {
658
1017
  if (!this.configuredDesktops()[index]) {
@@ -674,17 +1033,19 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
674
1033
  /** "Edit a desktop" with the form over any element (a pane, or the settings page). */
675
1034
  async editDesktopIn(host, index, askVia = false) {
676
1035
  const old = this.configuredDesktops()[index];
677
- if (!old) {
1036
+ if (!(0, desktops_1.isDesktopEntry)(old)) {
678
1037
  return;
679
1038
  }
1039
+ const { name, via } = (0, desktops_1.entryText)(old);
680
1040
  const entry = await (0, desktopForm_1.askDesktop)(host, {
681
- title: `Edit ${old.name ?? (0, desktops_1.desktopIdOf)(old)}${askVia ? '' : ` (behind ${old.via})`}`,
1041
+ title: `Edit ${name}${askVia || !via ? '' : ` (behind ${via})`}`,
682
1042
  action: 'Save',
683
1043
  accounts: this.accounts(),
684
1044
  addAccount: input => this.addAccountFromForm(input),
685
1045
  hosts: askVia ? await this.sshHosts() : undefined,
686
1046
  entry: old,
687
1047
  check: e => this.addressTaken(e, index),
1048
+ clipboard: this.settings().clipboard,
688
1049
  });
689
1050
  // Compared with the entry as it is now: the config can change while the form shows.
690
1051
  const list = this.configuredDesktops();
@@ -696,45 +1057,80 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
696
1057
  this.config.save();
697
1058
  this.changed$.next();
698
1059
  const accountChanged = (old.username ?? '') !== (entry.username ?? '') || (old.domain ?? '') !== (entry.domain ?? '') || (old.account ?? '') !== (entry.account ?? '');
699
- 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 });
700
1061
  }
701
1062
  /**
702
1063
  * A desktop was edited (the edit form, or an RDP profile's settings): what is kept per desktop is keyed by its
703
- * 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
704
1065
  * choice along. (Should another machine answer at the new address, its certificate differs and the connection
705
- * stops to ask, rather than trusting it silently as a first use.) A new user name or domain drops the saved
706
- * account instead: it was for the old one. `direct`: a direct desktop (`rdp#<id>` keys), otherwise one behind SSH
707
- * 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.
708
1083
  */
709
- async desktopEdited(fromId, toId, accountChanged, direct) {
710
- if (direct) {
711
- const [from, to] = [`${desktops_1.DIRECT_KEY}#${fromId}`, `${desktops_1.DIRECT_KEY}#${toId}`];
712
- const saved = !accountChanged && from !== to ? await (0, signin_1.loadCredentials)(from) : null;
713
- if (saved) {
714
- await (0, signin_1.saveCredentials)(to, saved).catch(() => null);
715
- }
716
- if (accountChanged || from !== to) {
717
- await (0, signin_1.forgetCredentials)(from);
718
- }
719
- }
720
- else if (accountChanged) {
721
- 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 });
722
1089
  }
723
1090
  else if (fromId !== toId) {
724
- 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)}`);
725
1099
  }
726
1100
  if (fromId === toId) {
727
1101
  return;
728
1102
  }
729
- 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
+ };
730
1109
  const store = this.config.store.remoteDesktop;
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.
731
1115
  for (const list of ['desktopSharpness', 'trustedCertificates']) {
732
1116
  if (Array.isArray(store[list])) {
733
- store[list] = store[list].map((e) => typeof e?.desktop === 'string' && moved(e.desktop)
734
- ? { ...e, desktop: `${e.desktop.slice(0, -fromId.length)}${toId}` }
735
- : 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
+ });
736
1129
  }
737
1130
  }
1131
+ if (Array.isArray(store.withoutNla)) {
1132
+ store.withoutNla = store.withoutNla.filter((e) => !(typeof e?.desktop === 'string' && moved(e.desktop)));
1133
+ }
738
1134
  this.config.save();
739
1135
  for (const [key, id] of this.lastUsed) {
740
1136
  if (id === fromId && (key === desktops_1.DIRECT_KEY) === direct) {
@@ -755,7 +1151,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
755
1151
  input.click();
756
1152
  });
757
1153
  if (file) {
758
- 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
+ }
759
1161
  }
760
1162
  }
761
1163
  /**
@@ -764,56 +1166,203 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
764
1166
  * puts them.
765
1167
  */
766
1168
  async remoteDesktopGroup() {
767
- const groups = await this.profiles.getProfileGroups({ includeNonUserGroup: false });
768
- const existing = groups.find(g => g.name === 'Remote desktops');
769
- if (existing?.id) {
770
- return existing.id;
1169
+ const existing = await this.existingRemoteDesktopGroup();
1170
+ if (existing) {
1171
+ return existing;
771
1172
  }
772
1173
  const group = { name: 'Remote desktops', profiles: [] };
773
1174
  await this.profiles.newProfileGroup(group, { genId: true });
774
1175
  return group.id;
775
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
+ }
776
1182
  /**
777
1183
  * Adds a "Remote desktop (RDP)" profile from a .rdp file's contents (address, user name, domain; see rdpFile.ts)
778
- * 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.
779
1185
  */
780
- 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'), '�');
781
1189
  const parsed = (0, rdpFile_1.parseRdpFile)(data);
782
1190
  if (!parsed) {
783
1191
  this.notifications.error(`${fileName}: no address ("full address") in it`);
784
1192
  return null;
785
1193
  }
786
- const existing = (this.config.store.profiles ?? []).find((p) => p?.type === targets_1.RDP_PROFILE_TYPE && !p.options?.via &&
787
- p.options?.host === parsed.host && (p.options.port || 3389) === parsed.port && (p.options.username ?? '') === (parsed.username ?? ''));
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
+ });
788
1237
  if (existing) {
1238
+ if (parsed.clipboard && !await this.clipboardOffForFile(existing, fileName)) {
1239
+ return null;
1240
+ }
789
1241
  this.notifications.info(`Opening "${existing.name}", which is already a profile for ${fileName}`);
790
1242
  await this.profiles.openNewTabForProfile(existing);
791
1243
  return existing;
792
1244
  }
1245
+ // A file is someone else's word for where to connect: where it leads is said before anything goes there. The
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.
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}` : '';
1265
+ const { response } = await this.platform.showMessageBox({
1266
+ type: 'warning',
1267
+ message: `Add a remote desktop from ${base}?`,
1268
+ detail: `It connects to ${(0, desktopForm_1.formatAddress)(parsed.host, parsed.port)}${parsed.gateway ? `, through the gateway ${parsed.gateway}` : ''}` +
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.',
1275
+ buttons: ['Add and connect', 'Add only', 'Cancel'],
1276
+ defaultId: 0,
1277
+ cancelId: 2,
1278
+ });
1279
+ if (response !== 0 && response !== 1) {
1280
+ return null;
1281
+ }
793
1282
  const profile = {
794
1283
  type: targets_1.RDP_PROFILE_TYPE,
795
- name: fileName.replace(/^.*[\\/]/, '').replace(/\.rdp$/i, '') || parsed.host,
1284
+ name: base.replace(/\.rdp$/i, '') || parsed.host,
796
1285
  icon: 'fas fa-desktop',
797
1286
  group: await this.remoteDesktopGroup(),
798
- options: { host: parsed.host, port: parsed.port, kind: 'windows', username: parsed.username ?? '', domain: parsed.domain ?? '', via: '' },
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
+ },
799
1301
  };
800
1302
  await this.profiles.newProfile(profile);
1303
+ // The file's display scale, as this desktop's own sharpness (the key a direct desktop's settings are kept by).
1304
+ if (parsed.sharpness) {
1305
+ const store = this.config.store.remoteDesktop;
1306
+ const key = `${desktops_1.DIRECT_KEY}#${(0, desktops_1.desktopIdOf)(profile.options)}`;
1307
+ store.desktopSharpness = [...(Array.isArray(store.desktopSharpness) ? store.desktopSharpness : []).filter((e) => e?.desktop !== key), { desktop: key, sharpness: parsed.sharpness }];
1308
+ }
801
1309
  this.config.save();
802
1310
  this.notifications.notice(`Added the remote desktop profile "${profile.name}" (Settings › Profiles & connections)`);
803
- await this.profiles.openNewTabForProfile(profile);
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.`);
1318
+ }
1319
+ if (response === 0) {
1320
+ await this.profiles.openNewTabForProfile(profile);
1321
+ }
804
1322
  return profile;
805
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
+ }
806
1353
  /** Asks, then removes a configured desktop (see removeDesktop). The menus and the settings page use this. */
807
1354
  async confirmRemoveDesktop(index) {
808
1355
  const d = this.configuredDesktops()[index];
809
- if (!d) {
1356
+ if (!(0, desktops_1.isDesktopEntry)(d)) {
810
1357
  return false;
811
1358
  }
812
- 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);
813
1361
  const { response } = await this.platform.showMessageBox({
814
1362
  type: 'warning',
815
- message: `Remove the desktop "${d.name ?? address}"?`,
816
- 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. `) +
817
1366
  'To open it instead, use its host\'s SSH tab: right-click › Open.',
818
1367
  buttons: ['Remove', 'Keep'],
819
1368
  defaultId: 1,
@@ -822,22 +1371,57 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
822
1371
  if (response !== 0) {
823
1372
  return false;
824
1373
  }
825
- 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);
826
1376
  return true;
827
1377
  }
828
- /** Removes a configured desktop, its saved accounts and its remembered certificate. Open sessions to it keep running. */
829
- 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) {
830
1384
  const list = this.configuredDesktops();
831
1385
  const [removed] = list.splice(index, 1);
832
- if (!removed) {
1386
+ if (!(0, desktops_1.isDesktopEntry)(removed)) {
833
1387
  return;
834
1388
  }
835
- 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);
836
1391
  this.config.store.remoteDesktop.desktops = list;
837
- this.forgetCertificatesFor(id);
838
1392
  this.config.save();
839
1393
  this.changed$.next();
840
- (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.`;
841
1425
  }
842
1426
  /**
843
1427
  * Shows a desktop over the pane: `desktopId` (see desktopsOf), or the one it has open, or the default. A pane
@@ -845,23 +1429,33 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
845
1429
  */
846
1430
  async showDesktop(pane, desktopId, options = {}) {
847
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;
848
1437
  let session = this.sessions.get(pane);
849
- 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 }))) {
850
1442
  this.disconnect(pane);
851
1443
  session = undefined;
852
1444
  }
853
1445
  if (!session) {
854
- const target = options.target ?? await this.remoteFor(pane);
1446
+ const target = desktopId ? requested : options.target ?? await this.remoteFor(pane);
855
1447
  if (target === undefined) {
856
1448
  return;
857
1449
  }
858
1450
  if (!target) {
859
- 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');
860
1454
  return;
861
1455
  }
862
1456
  if (this.sessions.has(pane)) {
863
- // Opened meanwhile (e.g. a double click); just show it.
864
- 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 });
865
1459
  }
866
1460
  const spec = desktopId ? this.desktopsOf(target).find(s => s.id === desktopId) : this.defaultDesktop(target);
867
1461
  if (!spec) {
@@ -878,14 +1472,31 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
878
1472
  // Next to it, moving the focus there would go unnoticed.
879
1473
  this.help.note(pane.element.nativeElement, `${target.label}'s desktop is already open in the other pane.`, 'nested-ssh');
880
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
+ }
881
1482
  this.selectPane(existing);
882
1483
  return this.showDesktop(existing);
883
1484
  }
884
1485
  const created = new DesktopSession(pane.element.nativeElement, key, spec);
885
1486
  created.onHelp = message => this.help.explain(message);
886
- 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) {
887
1494
  created.setViewOnly(true);
888
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
+ }
889
1500
  session = created;
890
1501
  this.sessions.set(pane, created);
891
1502
  // Released with the session: a pane outlives the desktops opened and closed in it.
@@ -894,7 +1505,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
894
1505
  // Tabby focuses the terminal when the pane gets focus (tab switch, split focus); take it back.
895
1506
  const focused = pane.focused$.subscribe(() => {
896
1507
  if (created.visible) {
897
- setTimeout(() => created.visible && created.focusDesktop());
1508
+ setTimeout(() => created.visible && this.showsInFront(created) && created.focusDesktop());
898
1509
  this.label(pane, created);
899
1510
  }
900
1511
  });
@@ -913,37 +1524,234 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
913
1524
  const shown = session;
914
1525
  setTimeout(() => this.followPane(pane, shown));
915
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
+ }
916
1532
  /**
917
1533
  * `desk`: show this machine's desktop and bring the console along: a terminal on the desktop,
918
- * 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).
919
1535
  */
920
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) {
921
1604
  const target = await this.targets.targetOf(pane);
922
1605
  if (!target) {
923
1606
  return;
924
1607
  }
925
1608
  // `desk` may run on another machine than the pane is connected to: `ssh` typed in its console, with the script
926
1609
  // there through a shared home folder. That machine's desktop isn't this connection's to open.
1610
+ let there = target;
927
1611
  if (request.machine) {
928
1612
  const machineOf = async (t) => (await t.exec('sh -s', deskScript_1.MACHINE_ID_COMMAND).catch(() => '')).trim();
929
1613
  const machine = await machineOf(target);
930
- 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) {
931
1620
  // Typed `ssh` at the prompt: the machine it went to, when that is where desk ran.
1621
+ there = null;
932
1622
  for (const nested of (0, ssh_1.isSSHTab)(pane) ? await this.targets.nestedSSH(pane).catch(() => []) : []) {
933
- const there = await this.targets.nestedTarget(pane, nested).catch(() => null);
934
- if (there && await machineOf(there) === request.machine) {
935
- 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;
936
1627
  }
937
1628
  }
938
- const there = request.hostname || 'another machine';
939
- this.help.note(pane.element.nativeElement, `desk ran on ${there}, but this tab is connected to ${target.label}. ` +
940
- `Open ${there} in its own tab to use desk there.`, 'nested-ssh');
941
- 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
+ }
942
1635
  }
943
1636
  }
944
- return this.openConsoleOn(pane, target, request);
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);
1743
+ }
1744
+ throw e;
1745
+ }
1746
+ this.rememberDeskKey(target.key, endpoint, desk, generation);
1747
+ return endpoint;
945
1748
  }
946
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
+ }
947
1755
  await this.showDesktop(pane, desktops_1.OWN_DESKTOP, { target });
948
1756
  // Wait for the desktop (possibly another pane's, for the same account) to be connected.
949
1757
  let session;
@@ -965,8 +1773,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
965
1773
  }
966
1774
  try {
967
1775
  const out = await target.exec('sh -s', (0, deskScript_1.consoleScript)(request));
968
- const err = /^RD_ERR (.*)$/m.exec(out);
969
- 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'}`);
970
1779
  if (err) {
971
1780
  this.notifications.error(`desk: ${err[1]}`);
972
1781
  }
@@ -1004,9 +1813,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1004
1813
  }
1005
1814
  }
1006
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
+ }
1007
1820
  /** Brings a pane forward: its top-level tab, and the pane itself within a split. */
1008
1821
  selectPane(pane) {
1009
- 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);
1010
1823
  if (top) {
1011
1824
  this.app.selectTab(top);
1012
1825
  }
@@ -1044,7 +1857,30 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1044
1857
  clearTimeout(this.reconnects.get(pane)?.timer);
1045
1858
  this.reconnects.delete(pane);
1046
1859
  }
1047
- /** 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
+ */
1048
1884
  async reopen(pane, spec, automatic = false) {
1049
1885
  if (!automatic) {
1050
1886
  this.cancelReconnect(pane);
@@ -1052,8 +1888,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1052
1888
  const session = this.sessions.get(pane);
1053
1889
  const background = !!session && !session.visible;
1054
1890
  this.drop(pane, false);
1055
- // The same remote as before, even if the `ssh` it was found through has ended since.
1056
- 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
+ }));
1057
1898
  }
1058
1899
  /** After a desktop ended: reconnect by itself if it dropped, otherwise offer to. */
1059
1900
  afterEnd(pane, target, spec, session, outcome) {
@@ -1072,6 +1913,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1072
1913
  : `Couldn't reconnect: ${outcome.error ?? outcome.reason ?? 'unknown error'}.`);
1073
1914
  }
1074
1915
  else if (outcome.connected) {
1916
+ this.cancelReconnect(pane);
1075
1917
  session.status(`Remote desktop session ended: ${outcome.reason ?? 'unknown reason'}`, [again('Reconnect')]);
1076
1918
  }
1077
1919
  else {
@@ -1119,17 +1961,29 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1119
1961
  sharpness: store.sharpness === 'retina' ? 'retina' : 'standard',
1120
1962
  desk: store.desk === true,
1121
1963
  macShortcuts: store.macShortcuts !== false,
1964
+ unicodeKeys: store.unicodeKeys === true,
1122
1965
  sound: store.sound !== false,
1123
1966
  h264: store.h264 !== false,
1124
1967
  zoom: store.zoom === 'actual' ? 'actual' : 'fit',
1125
1968
  connectionStatus: store.connectionStatus === true,
1126
1969
  microphone: store.microphone === true,
1970
+ clipboard: (0, clipboard_1.clipboardSetting)(store.clipboard),
1127
1971
  osd: (0, osd_1.osdSettings)(store.osd),
1128
1972
  discoverVMs: store.discoverVMs !== false,
1129
1973
  checkUpdates: store.checkUpdates !== false,
1130
- 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,
1131
1975
  };
1132
1976
  }
1977
+ /** The folders shared with remote desktops as drives (`remoteDesktop.sharedFolders`), tidied. */
1978
+ sharedFolders() {
1979
+ return (0, drives_1.sharedFolders)(this.config.store.remoteDesktop);
1980
+ }
1981
+ /** Replaces the shared folders; applies to desktops connecting from now on. */
1982
+ setSharedFolders(folders) {
1983
+ this.config.store.remoteDesktop.sharedFolders = folders.map(f => ({ path: f.path, name: f.name, readOnly: f.readOnly }));
1984
+ this.config.save();
1985
+ this.changed$.next();
1986
+ }
1133
1987
  /** The sharpness chosen for the pane's desktop in particular (`remoteDesktop.desktopSharpness`), or null. */
1134
1988
  ownSharpness(pane) {
1135
1989
  const key = this.sessions.get(pane)?.key;
@@ -1156,30 +2010,94 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1156
2010
  const value = (Array.isArray(list) ? list : []).find((e) => e?.desktop === key)?.sharpness;
1157
2011
  return value === 'retina' || value === 'standard' ? value : null;
1158
2012
  }
1159
- /** 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
+ */
1160
2017
  settingsFor(session) {
1161
2018
  const settings = this.settings();
1162
- 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;
1163
2059
  }
1164
2060
  /** Changes a setting, saves it, and applies it to open desktops. */
1165
2061
  updateSettings(change) {
1166
2062
  const store = this.config.store.remoteDesktop;
1167
2063
  const { osd, ...rest } = change;
2064
+ const desk = this.settings().desk;
1168
2065
  Object.assign(store, rest);
2066
+ this.noteDesk(desk);
1169
2067
  if (osd) {
1170
2068
  // A nested object in Tabby's config takes its fields one by one (the object itself isn't replaced).
1171
2069
  Object.assign(store.osd, osd);
1172
2070
  }
1173
2071
  if (change.h264) {
1174
2072
  this.h264Failed.clear(); // turned on again: give it another try everywhere
2073
+ this.h264Reclaimed.clear();
1175
2074
  }
1176
2075
  this.config.save();
1177
2076
  for (const [pane, session] of this.sessions) {
2077
+ this.applyMicrophone(session);
1178
2078
  this.applyZoom(session);
2079
+ this.applyKeyboardMode(session);
2080
+ this.applyClipboard(session);
1179
2081
  this.syncIndicator(session);
1180
2082
  this.followPane(pane, session);
1181
2083
  }
1182
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
+ }
2094
+ /** IronRDP's keyboard mode for the session: characters (Unicode) or key positions (scancodes), per the setting. */
2095
+ applyKeyboardMode(session) {
2096
+ try {
2097
+ session.ui?.setKeyboardUnicodeMode(this.settings().unicodeKeys);
2098
+ }
2099
+ catch { }
2100
+ }
1183
2101
  /** IronRDP's ScreenScale for the picture: Real (1:1, scrolling) only with a fixed resolution and zoom 'actual'. */
1184
2102
  screenScale() {
1185
2103
  const settings = this.settings();
@@ -1243,10 +2161,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1243
2161
  }
1244
2162
  if (settings.resize === 'reconnect') {
1245
2163
  session.log.push(`resize: reconnecting at ${wanted.width}x${wanted.height}`);
1246
- 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;
1247
2170
  this.zone.run(() => {
1248
2171
  this.disconnect(pane);
1249
- this.showDesktop(pane, undefined, { viewOnly });
2172
+ this.showDesktop(pane, tab ? undefined : session.spec.id, { viewOnly, target, clipboardCap });
1250
2173
  });
1251
2174
  return;
1252
2175
  }
@@ -1290,6 +2213,14 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1290
2213
  setViewOnly(pane, viewOnly) {
1291
2214
  const session = this.sessions.get(pane);
1292
2215
  if (session) {
2216
+ if (viewOnly) {
2217
+ // From here on this desktop's keys stop at the plugin, their keyups too: let go of what is down, also
2218
+ // on the desktops typing into all had copy them.
2219
+ session.releaseKeys();
2220
+ if (this.isBroadcast(pane)) {
2221
+ this.othersInTab(pane).forEach(other => other.session.releaseKeys());
2222
+ }
2223
+ }
1293
2224
  session.setViewOnly(viewOnly);
1294
2225
  this.changed$.next();
1295
2226
  }
@@ -1309,8 +2240,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1309
2240
  if ((0, targets_1.desktopPaneOf)(this.app.activeTab) !== pane) {
1310
2241
  this.selectPane(pane);
1311
2242
  }
1312
- // After a tab switch, the pane shows on Angular's next change detection.
1313
- 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++) {
1314
2246
  session.focusDesktop(1);
1315
2247
  const focused = document.activeElement;
1316
2248
  if (focused?.tagName === 'IRON-REMOTE-DESKTOP' && session.overlay.contains(focused)) {
@@ -1373,20 +2305,27 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1373
2305
  * that connection's own (behind the host, or where ssh was typed on to).
1374
2306
  */
1375
2307
  label(pane, session, always = false) {
1376
- const target = session.remote;
1377
- const osd = this.settings().osd;
1378
- if (session.state !== 'connected' || !session.visible || !target || osd.show === 'off' && !always) {
1379
- 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);
1380
2325
  }
1381
- const inSplit = pane.parent instanceof tabby_core_1.SplitTabComponent && pane.parent.getAllTabs().length > 1;
1382
- const own = session.spec.id === desktops_1.OWN_DESKTOP;
1383
- if (osd.show === 'auto' && !always && !inSplit && own && !target.via) {
1384
- return;
2326
+ catch (e) {
2327
+ session.log.push(`overlay: ${e?.message ?? e}`);
1385
2328
  }
1386
- // A desktop named after its host already says where it is ("buildhost desktop (xrdp)").
1387
- const through = own ? target.via : target.direct || session.spec.name.startsWith(target.label) ? null : target.label;
1388
- const size = session.remoteSize ? `${session.remoteSize.width}×${session.remoteSize.height}` : '';
1389
- session.flashLabel(own ? target.label : session.spec.name, [through ? `via ${through}` : '', size].filter(Boolean).join(' · '), osd);
1390
2329
  }
1391
2330
  /** Shows the on-screen display on every desktop showing now, whatever `show` says (to try the settings). */
1392
2331
  showOsdEverywhere() {
@@ -1399,10 +2338,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1399
2338
  }
1400
2339
  return shown;
1401
2340
  }
1402
- /** 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
+ */
1403
2347
  tip(pane, session) {
1404
2348
  if (session.state === 'connected' && session.visible) {
1405
- 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());
1406
2350
  }
1407
2351
  }
1408
2352
  /** The open desktops, for the settings page (and bug reports): where they are, and their logs. */
@@ -1416,20 +2360,109 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1416
2360
  log: [...session.log],
1417
2361
  }));
1418
2362
  }
1419
- /** 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
+ */
1420
2440
  trustedCertificates() {
1421
2441
  const list = this.config.store.remoteDesktop?.trustedCertificates;
1422
2442
  return (Array.isArray(list) ? list : [])
1423
2443
  .filter((e) => typeof e?.desktop === 'string' && typeof e?.sha256 === 'string')
1424
- .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 } : {} }));
1425
2445
  }
1426
- /** 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
+ */
1427
2450
  forgetCertificate(key) {
1428
2451
  const store = this.config.store.remoteDesktop;
1429
2452
  store.trustedCertificates = (Array.isArray(store.trustedCertificates) ? store.trustedCertificates : []).filter((e) => e?.desktop !== key);
2453
+ // What else was decided about that server goes with it: it is asked again.
2454
+ store.withoutNla = (Array.isArray(store.withoutNla) ? store.withoutNla : []).filter((e) => e?.desktop !== key);
1430
2455
  this.config.save();
1431
2456
  this.changed$.next();
1432
2457
  }
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
+ */
2462
+ signsInWithoutNla(key) {
2463
+ const allowed = this.withoutNlaCertificate(key);
2464
+ return !!allowed && allowed === this.trustedCertificates().find(e => e.desktop === key)?.sha256;
2465
+ }
1433
2466
  /** The split tab holding the pane, if it has more than one pane. */
1434
2467
  splitOf(pane) {
1435
2468
  const split = this.app.tabs.find(t => t instanceof tabby_core_1.SplitTabComponent && t.getAllTabs().includes(pane));
@@ -1441,10 +2474,17 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1441
2474
  return split ? split.getAllTabs().filter(p => p !== pane).map(p => ({ pane: p, session: this.sessions.get(p) }))
1442
2475
  .filter(({ session }) => session?.state === 'connected' && !session.viewOnly && session.ui) : [];
1443
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
+ }
1444
2485
  /** How many desktops in the pane's tab would get a paste or typing (itself included), when that's more than one. */
1445
2486
  desktopsInTab(pane) {
1446
- const own = this.sessions.get(pane);
1447
- 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);
1448
2488
  }
1449
2489
  isBroadcast(pane) {
1450
2490
  const split = this.splitOf(pane);
@@ -1456,6 +2496,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1456
2496
  if (!split) {
1457
2497
  return;
1458
2498
  }
2499
+ if (!on && this.broadcast.has(split)) {
2500
+ // The keyups of keys held right now (the shortcut's own modifiers) won't be copied any more.
2501
+ this.othersInTab(pane).forEach(other => other.session.releaseKeys());
2502
+ }
1459
2503
  on ? this.broadcast.add(split) : this.broadcast.delete(split);
1460
2504
  for (const p of split.getAllTabs()) {
1461
2505
  const session = this.sessions.get(p);
@@ -1464,14 +2508,24 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1464
2508
  }
1465
2509
  this.changed$.next();
1466
2510
  }
1467
- /** A key event for the other desktops of a tab typing into all (see DesktopKeyboard). */
2511
+ /**
2512
+ * A key the pane's desktop was sent (see DesktopKeyboard): noted as held there or let go, and copied to the other
2513
+ * desktops of a tab typing into all.
2514
+ */
1468
2515
  broadcastKey(pane, type, init) {
2516
+ this.sessions.get(pane)?.noteKey(type, init);
1469
2517
  if (!this.isBroadcast(pane)) {
1470
2518
  return;
1471
2519
  }
1472
- 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
+ }
1473
2526
  try {
1474
2527
  session.ui.sendKeyboardEvent(new KeyboardEvent(type, { ...init, cancelable: true }));
2528
+ session.noteKey(type, init);
1475
2529
  }
1476
2530
  catch (e) {
1477
2531
  session.log.push(`typing into all desktops: ${e?.message ?? e}`);
@@ -1481,17 +2535,41 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1481
2535
  /**
1482
2536
  * Pastes on every connected desktop in the pane's tab: files copied here are offered to each first (see
1483
2537
  * FileTransfer.pasteClipboardFiles); text and pictures are sent to each, since only the desktop with the focus
1484
- * 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.
1485
2541
  */
1486
2542
  pasteToAll(pane) {
1487
- const targets = [...this.sessions.get(pane) ? [{ pane, session: this.sessions.get(pane) }] : [], ...this.othersInTab(pane)]
1488
- .filter(({ session }) => session.state === 'connected' && !session.viewOnly && session.ui);
1489
- 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) {
1490
2557
  // Each pastes once it has taken in the new clipboard, however long that takes it.
1491
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;
1492
2562
  (offered ? session.files.offerAnswered() : this.sendClipboard(session)).then(ok => {
1493
2563
  if (!ok) {
1494
- 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');
1495
2573
  return;
1496
2574
  }
1497
2575
  try {
@@ -1511,6 +2589,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1511
2589
  * send, or no answer came: a paste then gets what that desktop has.
1512
2590
  */
1513
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
+ }
1514
2596
  const answered = session.files?.clipboardAnswered() ?? new Promise(resolve => setTimeout(() => resolve(true), 300));
1515
2597
  try {
1516
2598
  await session.ui.sendClipboardData();
@@ -1565,9 +2647,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1565
2647
  // A Windows host is known from an earlier connect: no setup to run there (it would only fail again).
1566
2648
  if (spec.id === desktops_1.OWN_DESKTOP && !retryError && this.ownDesktops.get(target.key)?.kind !== 'windows') {
1567
2649
  session.status(`Preparing the remote desktop on ${target.label}…`);
1568
- 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);
1569
2651
  this.ownDesktops.set(target.key, { kind: endpoint.kind, xrdpPort: endpoint.xrdpPort, xrdpUser: endpoint.xrdpUser });
1570
- // 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).
1571
2655
  spec.kind = endpoint.kind;
1572
2656
  if (endpoint.kind === 'windows') {
1573
2657
  session.log.push('setup: the SSH host runs Windows; its own desktop is its RDP server');
@@ -1588,7 +2672,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1588
2672
  }
1589
2673
  if (choice === 'take') {
1590
2674
  session.status(`Taking over the desktop on ${target.label}…`);
1591
- 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);
1592
2676
  session.log.push(`setup: took the desktop over (${endpoint.clients} other connection(s))`);
1593
2677
  }
1594
2678
  else {
@@ -1601,6 +2685,20 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1601
2685
  spec.port = endpoint.port;
1602
2686
  spec.username ??= endpoint.username;
1603
2687
  }
2688
+ // A Hyper-V VM's console: which session the guest takes right now (enhanced once its Remote Desktop Services
2689
+ // are up, the basic console before that and for guests that have none). The sign-in is the host's.
2690
+ let hyperv;
2691
+ if (spec.hyperv) {
2692
+ session.status(`Connecting to ${spec.name} via ${target.label}…`);
2693
+ const state = await (0, hyperv_1.hyperVState)(target, spec.hyperv);
2694
+ if (!state.running) {
2695
+ throw new Error(`${spec.name} isn't running`);
2696
+ }
2697
+ hyperv = state.enhanced ? 'enhanced' : 'basic';
2698
+ session.log.push(`hyper-v: ${hyperv === 'enhanced' ? 'an enhanced session' : 'the basic console (the guest takes no enhanced session now)'}`);
2699
+ // The SSH user is who to try first: it got to list the VMs.
2700
+ spec.username ??= (0, desktops_1.keyUser)(target.key) || undefined;
2701
+ }
1604
2702
  // A saved account (Settings): its user name, and its password from the keychain. One that has none yet, or that
1605
2703
  // this desktop just refused, is asked for here and saved for every desktop that uses the account.
1606
2704
  const account = spec.account ? this.accounts().find(a => a.id === spec.account) : undefined;
@@ -1609,7 +2707,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1609
2707
  }
1610
2708
  const again = this.askAgain.delete(pane);
1611
2709
  // A desktop whose account is gone asks, rather than falling back to a login of its own saved earlier.
1612
- let saved = retryError || again || spec.account && !account ? null : await (0, signin_1.loadCredentials)(account ? (0, accounts_1.accountKey)(account.id) : session.key);
2710
+ let saved = retryError || again || spec.account && !account ? null : await (0, signin_1.loadCredentials)(account ? (0, accounts_1.accountKey)(account.id) : session.credentialKey);
1613
2711
  if (saved && account && saved.username !== (0, accounts_1.signInName)(account)) {
1614
2712
  // Kept for an earlier user name of the account (its change didn't reach the store): not this one's.
1615
2713
  session.log.push(`sign-in: the account's saved password is for ${saved.username}, not ${(0, accounts_1.signInName)(account)}; asking`);
@@ -1619,24 +2717,31 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1619
2717
  if (account) {
1620
2718
  session.log.push(`sign-in: the saved account "${account.name}"`);
1621
2719
  }
1622
- 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 };
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 };
1623
2721
  }
1624
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}`;
1625
2728
  const asked = (0, signin_1.askCredentials)(session.overlay, {
1626
- // The host's own desktops (its own, or its xrdp besides GNOME) and direct ones need no "via".
1627
- title: 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})`,
1628
2731
  username: account ? (0, accounts_1.signInName)(account) : spec.username,
1629
2732
  error: retryError,
1630
2733
  canRemember: true,
1631
2734
  account: account?.name,
1632
2735
  }, session.disposed);
1633
- if (session.visible) {
1634
- 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());
1635
2739
  }
1636
2740
  const entered = await asked;
1637
2741
  return entered && {
1638
2742
  host: spec.host, port: spec.port, credentials: entered, remember: entered.remember, domain: account ? account.domain ?? '' : undefined,
1639
2743
  saveKey: account && (0, accounts_1.accountKey)(account.id), saveLabel: account && `saved account ${account.name}`, saveFor: account && (0, accounts_1.signInName)(account),
2744
+ hyperv, mark,
1640
2745
  };
1641
2746
  }
1642
2747
  async connect(pane, target, spec, session) {
@@ -1648,7 +2753,12 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1648
2753
  let woken = false;
1649
2754
  let startupRetries = 0;
1650
2755
  let endpoint = null;
2756
+ let gatewayError;
1651
2757
  try {
2758
+ const gateway = spec.gateway ? (0, gateway_1.parseGateway)(spec.gateway) : null;
2759
+ if (spec.gateway && !gateway) {
2760
+ throw new Error(`"${spec.gateway}" isn't a gateway's address (a name or address, or name:port)`);
2761
+ }
1652
2762
  if (spec.wake) {
1653
2763
  const woke = await this.wakeIfDown(pane, target, spec, session, automatic);
1654
2764
  if (woke === null) {
@@ -1679,7 +2789,40 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1679
2789
  endpoint = asked;
1680
2790
  session.remote = target;
1681
2791
  session.pinnedCertificate = asked.certificate ?? null;
1682
- session.proxy ??= await (0, rdcleanpath_1.startRDCleanPathProxy)(() => target.openTcp(asked.host, asked.port), fingerprint => this.checkCertificate(session, fingerprint), m => session.log.push(m), { autologon: spec.kind === 'xrdp' });
2792
+ // A gateway with a saved account of its own: its password, asked for when there is none or it was refused.
2793
+ if (gateway && spec.gatewayAccount && !asked.gatewayAccount) {
2794
+ const signIn = await this.gatewayAccountFor(spec, session, gateway, gatewayError);
2795
+ if (!alive()) {
2796
+ return;
2797
+ }
2798
+ if (signIn === null) {
2799
+ session.state = 'ended';
2800
+ this.changed$.next();
2801
+ this.cancelReconnect(pane);
2802
+ session.status('Sign-in cancelled.', [{ label: 'Sign in', run: () => this.reopen(pane, spec) }]);
2803
+ return;
2804
+ }
2805
+ asked.gatewayAccount = signIn;
2806
+ }
2807
+ // The proxy outlives an attempt: it reads the sign-in of the attempt it serves.
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
+ }
1683
2826
  const outcome = await this.run(pane, target, spec, session, rdp, asked);
1684
2827
  if (outcome.certificate && alive()) {
1685
2828
  // A changed certificate, now trusted: the same account again, without asking for it again.
@@ -1688,10 +2831,23 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1688
2831
  }
1689
2832
  return;
1690
2833
  }
2834
+ if (outcome.withoutNla && alive()) {
2835
+ // Allowed now: the same account again, without asking for it again.
2836
+ if (await this.refusedWithoutNla(pane, target, spec, session)) {
2837
+ continue;
2838
+ }
2839
+ return;
2840
+ }
2841
+ if (outcome.gatewayRefused && asked.gatewayAccount && attempt < 5 && alive()) {
2842
+ // The gateway's own account was refused: asked for again, the desktop's sign-in kept.
2843
+ gatewayError = outcome.signInFailed;
2844
+ asked.gatewayAccount = undefined;
2845
+ continue;
2846
+ }
1691
2847
  if (outcome.signInFailed && this.signsIn(spec) && attempt < 5 && alive()) {
1692
2848
  // The desktop's own saved account goes. A shared one stays until a new password is entered: this
1693
2849
  // desktop refusing it doesn't make it wrong for the others.
1694
- await (0, signin_1.forgetCredentials)(session.key);
2850
+ await (0, signin_1.forgetCredentials)(session.credentialKey);
1695
2851
  retryError = outcome.signInFailed;
1696
2852
  endpoint = null;
1697
2853
  continue;
@@ -1728,14 +2884,249 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1728
2884
  }
1729
2885
  }
1730
2886
  }
2887
+ /**
2888
+ * The sign-in to a desktop's RD Gateway with the saved account it names: the account's password from the keychain,
2889
+ * or asked for (when none is saved, or `retryError`: the gateway just refused it). Undefined when that account no
2890
+ * longer exists (the desktop's own sign-in is then tried); null when the form was cancelled.
2891
+ */
2892
+ async gatewayAccountFor(spec, session, gateway, retryError) {
2893
+ const account = this.accounts().find(a => a.id === spec.gatewayAccount);
2894
+ if (!account) {
2895
+ session.log.push('gateway: its saved account no longer exists; signing in with the desktop\'s');
2896
+ return undefined;
2897
+ }
2898
+ const signIn = { domain: account.domain, saveKey: (0, accounts_1.accountKey)(account.id), saveLabel: `saved account ${account.name}`, saveFor: (0, accounts_1.signInName)(account) };
2899
+ const saved = retryError ? null : await (0, signin_1.loadCredentials)(signIn.saveKey);
2900
+ if (saved && saved.username === (0, accounts_1.signInName)(account)) {
2901
+ session.log.push(`gateway: the saved account "${account.name}"`);
2902
+ return { ...signIn, credentials: { username: (0, accounts_1.signInName)(account), password: saved.password }, remember: false };
2903
+ }
2904
+ session.status('');
2905
+ const asked = (0, signin_1.askCredentials)(session.overlay, {
2906
+ title: `Sign in to the gateway ${gateway.host}, to reach ${spec.name}`,
2907
+ username: (0, accounts_1.signInName)(account),
2908
+ error: retryError,
2909
+ canRemember: true,
2910
+ account: account.name,
2911
+ }, session.disposed);
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());
2915
+ }
2916
+ const entered = await asked;
2917
+ return entered && { ...signIn, credentials: entered, remember: entered.remember };
2918
+ }
2919
+ /**
2920
+ * The proxy's stream to a desktop behind an RD Gateway (see gateway.ts): to the gateway as the SSH host (or this
2921
+ * computer) reaches it, signed in with the gateway's own account or else the desktop's, then on to the desktop.
2922
+ */
2923
+ async throughGateway(target, spec, session, gateway, endpoint, signal) {
2924
+ session.gatewayRefused = null;
2925
+ const own = endpoint.gatewayAccount;
2926
+ const credentials = own?.credentials ?? endpoint.credentials;
2927
+ session.log.push(`gateway: ${gateway.host}:${gateway.port}, signing in with ${own ? 'its saved account' : 'the desktop\'s sign-in'}`);
2928
+ try {
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 });
2930
+ // Entered a moment ago and taken by the gateway: kept, while the account is still what it was signed in as.
2931
+ if (own?.remember) {
2932
+ own.remember = false;
2933
+ const accountNow = this.accounts().find(a => (0, accounts_1.accountKey)(a.id) === own.saveKey);
2934
+ if (accountNow && (0, accounts_1.signInName)(accountNow) === own.saveFor) {
2935
+ (0, signin_1.saveCredentials)(own.saveKey, own.credentials, own.saveLabel).then(() => session.log.push(`gateway: saved as the account's password in ${(0, signin_1.storeName)()}`), e => session.log.push(`gateway: ${(0, signin_1.storeName)()}: ${e?.message ?? e}`));
2936
+ }
2937
+ }
2938
+ return stream;
2939
+ }
2940
+ catch (e) {
2941
+ if (e instanceof gateway_1.GatewaySignInError) {
2942
+ session.gatewayRefused = e.message;
2943
+ }
2944
+ throw e;
2945
+ }
2946
+ }
2947
+ /**
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.
2952
+ */
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';
2971
+ const known = this.trustedCertificate(key);
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}` : ''}`);
2987
+ return;
2988
+ }
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;
2999
+ }
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'}`);
3011
+ }
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) {
3019
+ const list = this.config.store.remoteDesktop?.withoutNla;
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);
3026
+ }
3027
+ /**
3028
+ * The proxy found a server that doesn't use Network Level Authentication: the password would go to it as it is,
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).
3034
+ */
3035
+ checkWithoutNla(session) {
3036
+ if ((0, desktops_1.xrdpUnasked)(session.spec)) {
3037
+ session.plainSignIn = true;
3038
+ return;
3039
+ }
3040
+ if (session.sendAnyway || this.allowedWithoutNla(session.authKey)) {
3041
+ session.log.push('sign-in: without Network Level Authentication, as allowed for this desktop');
3042
+ session.plainSignIn = true;
3043
+ return;
3044
+ }
3045
+ session.withoutNla = true;
3046
+ throw new Error('the server doesn\'t use Network Level Authentication');
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
+ }
3073
+ /**
3074
+ * After the proxy stopped at a server without Network Level Authentication; nothing but the connection request
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).
3079
+ */
3080
+ async refusedWithoutNla(pane, target, spec, session) {
3081
+ this.cancelReconnect(pane);
3082
+ const alive = () => this.sessions.get(pane) === session;
3083
+ session.log.push('sign-in: the server doesn\'t use Network Level Authentication; asking before the password goes to it');
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.` : '.');
3097
+ const allowed = await new Promise(resolve => {
3098
+ session.status(`${spec.name} doesn't use Network Level Authentication, so your password would be sent to it as it is: ` +
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}`, [
3100
+ { label: 'Send the password anyway', run: () => resolve(true) },
3101
+ // What Enter picks, as for the certificate questions: sending the password is the risk.
3102
+ { label: 'Cancel', run: () => resolve(false), default: true },
3103
+ ], true);
3104
+ session.disposed.then(() => resolve(false));
3105
+ });
3106
+ if (!alive()) {
3107
+ return false;
3108
+ }
3109
+ if (!allowed) {
3110
+ session.state = 'ended';
3111
+ this.changed$.next();
3112
+ session.status('Not connected: the password was not sent.', [{ label: 'Try again', run: () => this.reopen(pane, spec) }], true);
3113
+ return false;
3114
+ }
3115
+ // Not remembered yet: the permission goes with a certificate, which the next attempt's server shows.
3116
+ session.sendAnyway = true;
3117
+ return true;
3118
+ }
1731
3119
  /**
1732
3120
  * The proxy's check of the server's certificate, before any credentials go out. The host's own desktop must show
1733
- * the certificate its setup reported. Others (behind a host, a Windows host's own, a remote desktop tab's) are
1734
- * 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
1735
3126
  * on every new desktop would only teach clicking it away), and refused when it changes, until the new one is
1736
3127
  * trusted (see refusedCertificate). Remembered by session key, like saved accounts.
1737
3128
  */
1738
- checkCertificate(session, fingerprint) {
3129
+ checkCertificate(session, fingerprint, valid = false, reason = '', details) {
1739
3130
  session.certificateProblem = null;
1740
3131
  // The host's own GNOME desktop; its own xrdp makes its certificate itself, so that one is trusted on first use.
1741
3132
  if (session.spec.id === desktops_1.OWN_DESKTOP && session.spec.kind === 'gnome') {
@@ -1747,14 +3138,21 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1747
3138
  session.log.push(`certificate: SHA-256 ${fingerprint}, as set up`);
1748
3139
  return;
1749
3140
  }
1750
- const known = this.trustedCertificate(session.key);
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
+ }
1751
3149
  if (!known) {
1752
- this.trustCertificate(session.key, fingerprint);
3150
+ this.trustCertificate(session.authKey, fingerprint);
1753
3151
  session.log.push(`certificate: SHA-256 ${fingerprint}, remembered (first connection)`);
1754
3152
  return;
1755
3153
  }
1756
3154
  if (fingerprint !== known) {
1757
- session.certificateProblem = { expected: known, actual: fingerprint, pinned: false };
3155
+ session.certificateProblem = { expected: known, actual: fingerprint, pinned: false, ...details ? { details } : {} };
1758
3156
  throw new Error(`certificate SHA-256 ${fingerprint} is not the one remembered (${known})`);
1759
3157
  }
1760
3158
  session.log.push(`certificate: SHA-256 ${fingerprint}, as remembered`);
@@ -1803,9 +3201,12 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1803
3201
  return choice ?? stopped(`Not opened: the desktop on ${target.label} is open somewhere else (another Tabby window or computer).`);
1804
3202
  }
1805
3203
  /**
1806
- * After the proxy refused a certificate; nothing was sent to that server. The host's own desktop: an error, since
1807
- * the plugin made that certificate itself. Others: both fingerprints, to trust the new certificate or not. Either
1808
- * 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).
1809
3210
  */
1810
3211
  async refusedCertificate(pane, target, spec, session, endpoint, problem) {
1811
3212
  this.cancelReconnect(pane);
@@ -1815,22 +3216,73 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1815
3216
  this.changed$.next();
1816
3217
  session.status(message, [{ label: 'Try again', run: () => this.reopen(pane, spec) }], true);
1817
3218
  };
1818
- const fingerprints = `${problem.pinned ? 'Expected' : 'Remembered'} (SHA-256):\n${showFingerprint(problem.expected)}\n\nNow:\n${showFingerprint(problem.actual)}`;
1819
3219
  if (problem.pinned) {
3220
+ const fingerprints = `Expected (SHA-256):\n${showFingerprint(problem.expected)}\n\nNow:\n${showFingerprint(problem.actual)}`;
1820
3221
  stop(`The remote desktop on ${target.label} answered with a certificate other than the one set up for it. ` +
1821
3222
  `The connection was stopped before signing in.\n\n${fingerprints}\n\n` +
1822
3223
  `Something other than GNOME Remote Desktop may be listening on port ${endpoint.port}.`);
1823
3224
  return false;
1824
3225
  }
1825
- if (!session.visible) {
1826
- this.notifications.notice(`The certificate of ${spec.name}${target.direct ? '' : ` (via ${target.label})`} has changed. Open that desktop to decide.`);
1827
- }
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).
3228
+ const whose = problem.gateway ? `${spec.name}'s gateway ${problem.gateway.address}` : spec.name;
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.`;
1828
3268
  const trusted = await new Promise(resolve => {
1829
- session.status(`The certificate of ${spec.name} has changed since it was last used. Nothing has been sent to it.\n\n` +
1830
- `${fingerprints}\n\nReinstalling the machine or renewing its certificate changes it. If neither happened, ` +
1831
- `something else may be answering at ${spec.host}:${spec.port}.`, [
1832
- { label: 'Trust the new certificate', run: () => resolve(true) },
1833
- { 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 },
1834
3286
  ], true);
1835
3287
  session.disposed.then(() => resolve(false));
1836
3288
  });
@@ -1838,23 +3290,36 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1838
3290
  return false;
1839
3291
  }
1840
3292
  if (!trusted) {
1841
- 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.');
1842
3294
  return false;
1843
3295
  }
1844
- this.trustCertificate(session.key, problem.actual);
1845
- session.log.push(`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}`}`);
1846
3309
  return true;
1847
3310
  }
1848
- /** 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. */
1849
3312
  trustedCertificate(key) {
1850
3313
  const list = this.config.store.remoteDesktop?.trustedCertificates;
1851
- const value = (Array.isArray(list) ? list : []).find((e) => e?.desktop === key)?.sha256;
1852
- 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;
1853
3317
  }
1854
- trustCertificate(key, fingerprint) {
3318
+ /** Remembers a server's certificate; `authority` as RememberedCertificate has it (none: trusted on first use). */
3319
+ trustCertificate(key, fingerprint, authority) {
1855
3320
  const store = this.config.store.remoteDesktop;
1856
3321
  const others = (Array.isArray(store.trustedCertificates) ? store.trustedCertificates : []).filter((e) => e?.desktop !== key);
1857
- store.trustedCertificates = [...others, { desktop: key, sha256: fingerprint }];
3322
+ store.trustedCertificates = [...others, { desktop: key, sha256: fingerprint, ...authority === undefined ? {} : { authority } }];
1858
3323
  this.config.save();
1859
3324
  }
1860
3325
  /**
@@ -1864,8 +3329,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1864
3329
  forgetCertificatesFor(desktopId, direct = false) {
1865
3330
  const store = this.config.store.remoteDesktop;
1866
3331
  const list = Array.isArray(store.trustedCertificates) ? store.trustedCertificates : [];
1867
- const forget = (key) => direct ? key === `${desktops_1.DIRECT_KEY}#${desktopId}` : key.endsWith(`#${desktopId}`) && !key.startsWith(`${desktops_1.DIRECT_KEY}#`);
1868
- store.trustedCertificates = list.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)));
1869
3341
  }
1870
3342
  /**
1871
3343
  * For a desktop with `wake`: when its RDP server doesn't answer (probed from the SSH host), starts it and waits
@@ -1875,7 +3347,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1875
3347
  async wakeIfDown(pane, target, spec, session, automatic) {
1876
3348
  const alive = () => this.sessions.get(pane) === session;
1877
3349
  session.status(`Connecting to ${spec.name} via ${target.label}…`);
1878
- if (await (0, wake_1.rdpAnswers)(target, spec.host, spec.port)) {
3350
+ if (await (0, wake_1.desktopUp)(target, spec.host, spec.port, spec.wake)) {
1879
3351
  return false;
1880
3352
  }
1881
3353
  if (!alive()) {
@@ -1900,7 +3372,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1900
3372
  const started = Date.now();
1901
3373
  const progress = () => session.status(`Starting ${spec.name}…\nWaiting for it to answer: ${Math.round((Date.now() - started) / 1000)} s`, [cancel]);
1902
3374
  progress();
1903
- const up = await this.zone.runOutsideAngular(() => (0, wake_1.waitForRdp)(target, spec.host, spec.port, WAKE_TIMEOUT_MS, () => cancelled || !alive(), progress));
3375
+ const up = await this.zone.runOutsideAngular(() => (0, wake_1.waitForRdp)(target, spec.host, spec.port, WAKE_TIMEOUT_MS, () => cancelled || !alive(), progress, spec.wake));
1904
3376
  if (up) {
1905
3377
  session.log.push(`wake: answered after ${Math.round((Date.now() - started) / 1000)} s`);
1906
3378
  // Started here, so it can go back off when it's no longer used, if the settings say so (a VM only: a machine
@@ -1922,6 +3394,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1922
3394
  const credentials = endpoint.credentials;
1923
3395
  const alive = () => this.sessions.get(pane) === session;
1924
3396
  session.certificateProblem = null;
3397
+ session.gatewayRefused = null;
3398
+ session.withoutNla = false;
3399
+ session.plainSignIn = false;
1925
3400
  const el = document.createElement('iron-remote-desktop');
1926
3401
  try {
1927
3402
  el.setAttribute('scale', this.screenScale() === 3 ? 'real' : 'fit');
@@ -1929,11 +3404,22 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1929
3404
  el.module = rdp.Backend;
1930
3405
  const ready = new Promise(resolve => el.addEventListener('ready', (e) => resolve(e.detail.irgUserInteraction), { once: true }));
1931
3406
  session.host.replaceChildren(el);
1932
- session.ui = await ready;
1933
- // 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).
1934
3415
  session.files?.dispose();
1935
- session.files = new fileTransfer_1.FileTransfer(rdp, session.overlay, m => session.log.push(m));
1936
- 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)'}`);
1937
3423
  // The component outlines itself while it has the keyboard; the desktop should look like the
1938
3424
  // terminal it replaces, edge to edge. Its styles live in a shadow root, so adopt a sheet there.
1939
3425
  // Not a <style> element: the component only forwards keys while its own container is the shadow
@@ -1954,7 +3440,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1954
3440
  const config = session.ui.configBuilder()
1955
3441
  .withUsername(username)
1956
3442
  .withPassword(credentials.password)
1957
- .withDestination(`${spec.id === desktops_1.OWN_DESKTOP ? target.hostname ?? target.label : endpoint.host}:${endpoint.port}`)
3443
+ // The name the server is signed in to as: the SSH host's for its own desktop, and for its VMs' consoles.
3444
+ .withDestination(`${spec.id === desktops_1.OWN_DESKTOP || spec.hyperv ? target.hostname ?? target.label : endpoint.host}:${endpoint.port}`)
1958
3445
  .withProxyAddress(session.proxy.url)
1959
3446
  .withServerDomain(domain)
1960
3447
  .withAuthToken(session.proxy.token)
@@ -1964,9 +3451,24 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1964
3451
  // H.264 in the graphics pipeline, decoded by the browser (hardware-accelerated where it can be). A decoder
1965
3452
  // failure ends the connection, which then reconnects without H.264 (see below). Not for xrdp: it encodes
1966
3453
  // H.264 only in some builds, and without it bitmaps do better there than the pipeline.
3454
+ // A Hyper-V VM's console: the VM's id goes ahead of everything, and the sign-in (the host's) before the RDP
3455
+ // handshake. Its basic console is a picture, keyboard and mouse: no channels for sound, files or resizing.
3456
+ const basic = endpoint.hyperv === 'basic';
3457
+ if (spec.hyperv) {
3458
+ if (typeof rdp.vmConnect !== 'function') {
3459
+ throw new Error('this build of IronRDP has no Hyper-V console support');
3460
+ }
3461
+ config.withExtension(rdp.vmConnect(spec.hyperv, endpoint.hyperv ?? 'basic'));
3462
+ }
1967
3463
  session.h264?.close();
1968
3464
  session.h264 = null;
1969
- if (spec.kind !== 'xrdp' && 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) {
1970
3472
  session.h264 = new rdp.WebCodecsH264Decoder({ onFailure: (reason) => session.log.push(`h264: failed: ${reason}`) });
1971
3473
  config.withExtension(rdp.h264Decoder(session.h264));
1972
3474
  }
@@ -1982,19 +3484,36 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1982
3484
  session.graphics = 'automatic graphics';
1983
3485
  }
1984
3486
  session.log.push(`graphics: ${session.graphics}`);
1985
- if (settings.sound) {
3487
+ if (settings.sound && !basic) {
1986
3488
  session.audio?.close();
1987
3489
  session.audio = new audio_1.AudioPlayer();
1988
3490
  config.withExtension(rdp.audioPlayback(session.audio.callback));
1989
3491
  }
1990
3492
  session.mic?.close();
1991
3493
  session.mic = null;
1992
- if (settings.microphone && 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') {
1993
3497
  const ui = session.ui;
1994
- 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
+ }
1995
3504
  session.mic = mic;
1996
3505
  config.withExtension(rdp.audioInput(mic.callback));
1997
3506
  }
3507
+ // Shared folders as drives (\\tsclient\<name>). GNOME Remote Desktop doesn't serve drives; Windows and xrdp
3508
+ // do. Also skipped with no folder shared: the server then sees no drive device at all.
3509
+ session.drives?.dispose();
3510
+ session.drives = null;
3511
+ const folders = this.sharedFolders();
3512
+ if (folders.length && spec.kind !== 'gnome' && !basic && typeof rdp.driveRedirection === 'function') {
3513
+ session.drives = new drives_1.SharedDrives(folders, m => session.log.push(m), session.proxy ?? undefined);
3514
+ config.withExtension(rdp.driveRedirection(session.drives));
3515
+ session.log.push(`drives: ${folders.map(f => `${f.name}${f.readOnly ? ' (read-only)' : ''}`).join(', ')}`);
3516
+ }
1998
3517
  const built = config.build();
1999
3518
  let info;
2000
3519
  try {
@@ -2005,9 +3524,19 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2005
3524
  if (session.certificateProblem) {
2006
3525
  return { connected: false, certificate: session.certificateProblem };
2007
3526
  }
3527
+ // The proxy stopped at a server that would get the password as it is (see checkWithoutNla).
3528
+ if (session.withoutNla) {
3529
+ return { connected: false, withoutNla: true };
3530
+ }
3531
+ // Its gateway refused the sign-in, before anything went to the desktop.
3532
+ if (session.gatewayRefused) {
3533
+ session.log.push(`gateway: ${session.gatewayRefused}`);
3534
+ return { connected: false, signInFailed: session.gatewayRefused, gatewayRefused: true };
3535
+ }
2008
3536
  // IronErrorKind: 1 WrongPassword, 2 LogonFailure.
2009
3537
  const kind = typeof e?.kind === 'function' ? e.kind() : undefined;
2010
- 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);
2011
3540
  if (kind === 1 || kind === 2) {
2012
3541
  session.log.push(`sign-in failed: ${detail.split('\n')[0]}`);
2013
3542
  return { connected: false, signInFailed: kind === 1 ? 'Wrong user name or password.' : 'The sign-in was refused.' };
@@ -2018,8 +3547,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2018
3547
  return { connected: false };
2019
3548
  }
2020
3549
  session.ui.setVisibility(true);
3550
+ this.applyKeyboardMode(session);
2021
3551
  session.remoteSize = { width, height, scale: 100 };
2022
- if (size.scale !== 100 || spec.kind === 'windows') {
3552
+ if (!basic && (size.scale !== 100 || spec.kind === 'windows')) {
2023
3553
  // Retina: the connection starts unscaled; ask for the matching remote scale right away. Windows also
2024
3554
  // keeps a signed-in session's scale from the last connection: set it either way.
2025
3555
  this.applySize(session, size);
@@ -2032,19 +3562,32 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2032
3562
  this.label(pane, session);
2033
3563
  this.syncIndicator(session);
2034
3564
  this.changed$.next();
2035
- this.reconnects.delete(pane); // connected again: a later drop starts over
2036
3565
  // Under a saved account only while that account is still there with the same sign-in name: the prompt may
2037
3566
  // have been open while it was removed or changed in Settings.
2038
3567
  const accountNow = endpoint.saveKey ? this.accounts().find(a => (0, accounts_1.accountKey)(a.id) === endpoint.saveKey) : undefined;
2039
3568
  if (endpoint.remember && endpoint.saveKey && (!accountNow || (0, accounts_1.signInName)(accountNow) !== endpoint.saveFor)) {
2040
3569
  session.log.push(`sign-in: not saved: the account ${accountNow ? 'changed' : 'was removed'} meanwhile`);
2041
3570
  }
2042
- else if (endpoint.remember) {
2043
- (0, signin_1.saveCredentials)(endpoint.saveKey ?? session.key, 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');
2044
3575
  }
2045
- if (session.visible) {
2046
- 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}`));
2047
3588
  }
3589
+ this.focusIfInFront(pane, session);
3590
+ const connectedAt = Date.now();
2048
3591
  let end;
2049
3592
  try {
2050
3593
  end = await info.run();
@@ -2053,17 +3596,30 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2053
3596
  if (session.h264?.failed) {
2054
3597
  // Reconnects (automatically, like any dropped connection) without H.264, unless the browser only
2055
3598
  // took an idle decoder back (a hidden window): then with it, as a new stream starts with a key frame.
2056
- 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)) {
2057
3602
  return { connected: true, error: 'the video decoder was reclaimed' };
2058
3603
  }
2059
3604
  this.h264Failed.add(session.key);
2060
3605
  return { connected: true, error: `H.264 decoding failed (${session.h264.failed}); continuing without it` };
2061
3606
  }
2062
- return { connected: true, 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);
3608
+ // A Hyper-V host signs anyone in that it knows, and ends the connection at once when that account may not
3609
+ // open the VM's console: a refused sign-in in effect, so the form comes back, saying so.
3610
+ if (spec.hyperv && Date.now() - connectedAt < 5000 && /disconnect provider ultimatum/i.test(error)) {
3611
+ session.log.push(`hyper-v: disconnected at once (${error}): the account may not open this VM's console`);
3612
+ return { connected: false, signInFailed: `${target.label} ended the connection at once: this account may not open ${spec.name}'s console. Use an administrator of the host, a member of its Hyper-V Administrators, or an account given access with Grant-VMConnectAccess.` };
3613
+ }
3614
+ return { connected: true, error };
2063
3615
  }
2064
3616
  finally {
2065
- // The server can't close the microphone once the connection is gone.
3617
+ // The server can't close the microphone, or its files, once the connection is gone.
2066
3618
  session.mic?.close();
3619
+ session.drives?.dispose();
3620
+ if (alive()) {
3621
+ this.reconnectedUntilNow(pane, connectedAt);
3622
+ }
2067
3623
  }
2068
3624
  return { connected: true, reason: end?.reason?.() };
2069
3625
  }
@@ -2074,6 +3630,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2074
3630
  };
2075
3631
  exports.RemoteDesktopService = RemoteDesktopService;
2076
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;
2077
3637
  exports.RemoteDesktopService = RemoteDesktopService = RemoteDesktopService_1 = __decorate([
2078
3638
  (0, core_1.Injectable)({ providedIn: 'root' }),
2079
3639
  __metadata("design:paramtypes", [tabby_core_1.AppService,
@@ -2127,4 +3687,3 @@ function keepWindowDraggable() {
2127
3687
  }
2128
3688
  }).observe(document.body, { childList: true });
2129
3689
  }
2130
- //# sourceMappingURL=desktop.service.js.map