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