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