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