tabby-rdp 0.2.7 → 0.3.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 +72 -2
- package/README.md +182 -21
- package/dist/connectionStatus.js +140 -0
- package/dist/connectionStatus.js.map +1 -0
- package/dist/desk.js +21 -10
- package/dist/desk.js.map +1 -1
- package/dist/deskScript.js +3 -0
- package/dist/deskScript.js.map +1 -1
- package/dist/desktop.service.js +994 -53
- package/dist/desktop.service.js.map +1 -1
- package/dist/desktopForm.js +64 -19
- package/dist/desktopForm.js.map +1 -1
- package/dist/desktops.js +60 -21
- package/dist/desktops.js.map +1 -1
- package/dist/fileTransfer.js +152 -5
- package/dist/fileTransfer.js.map +1 -1
- package/dist/header.js +22 -7
- package/dist/header.js.map +1 -1
- package/dist/help.js +241 -0
- package/dist/help.js.map +1 -0
- package/dist/index.js +16 -1
- package/dist/index.js.map +1 -1
- package/dist/keyboard.js +142 -5
- package/dist/keyboard.js.map +1 -1
- package/dist/microphone.js +295 -0
- package/dist/microphone.js.map +1 -0
- package/dist/osd.js +79 -0
- package/dist/osd.js.map +1 -0
- package/dist/probe.js +33 -0
- package/dist/probe.js.map +1 -0
- package/dist/rdcleanpath.js +132 -7
- package/dist/rdcleanpath.js.map +1 -1
- package/dist/rdpFile.js +47 -0
- package/dist/rdpFile.js.map +1 -0
- package/dist/rdpProfile.js +367 -0
- package/dist/rdpProfile.js.map +1 -0
- package/dist/remoteSetup.js +90 -11
- package/dist/remoteSetup.js.map +1 -1
- package/dist/settingsPage.js +554 -0
- package/dist/settingsPage.js.map +1 -0
- package/dist/signin.js +21 -2
- package/dist/signin.js.map +1 -1
- package/dist/ssh.js +28 -0
- package/dist/ssh.js.map +1 -1
- package/dist/targets.js +148 -8
- package/dist/targets.js.map +1 -1
- package/dist/ui.js +163 -24
- package/dist/ui.js.map +1 -1
- package/dist/updates.js +123 -0
- package/dist/updates.js.map +1 -0
- package/dist/vms.js +90 -0
- package/dist/vms.js.map +1 -0
- package/dist/wake.js +124 -0
- package/dist/wake.js.map +1 -0
- package/package.json +2 -1
- package/vendor/IRONRDP-LICENSE +3 -3
- package/vendor/iron-remote-desktop-rdp.js +698 -451
- package/vendor/iron-remote-desktop.js +87 -81
- package/vendor/ironrdp_web_bg.wasm +0 -0
package/dist/desktop.service.js
CHANGED
|
@@ -15,17 +15,29 @@ exports.installStyle = installStyle;
|
|
|
15
15
|
const core_1 = require("@angular/core");
|
|
16
16
|
const rxjs_1 = require("rxjs");
|
|
17
17
|
const tabby_core_1 = require("tabby-core");
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const os = require("os");
|
|
18
20
|
const path = require("path");
|
|
19
21
|
const url_1 = require("url");
|
|
20
22
|
const targets_1 = require("./targets");
|
|
21
23
|
const deskScript_1 = require("./deskScript");
|
|
22
24
|
const audio_1 = require("./audio");
|
|
25
|
+
const connectionStatus_1 = require("./connectionStatus");
|
|
26
|
+
const microphone_1 = require("./microphone");
|
|
23
27
|
const desktopForm_1 = require("./desktopForm");
|
|
24
28
|
const fileTransfer_1 = require("./fileTransfer");
|
|
25
29
|
const desktops_1 = require("./desktops");
|
|
30
|
+
const help_1 = require("./help");
|
|
31
|
+
const osd_1 = require("./osd");
|
|
32
|
+
const rdpFile_1 = require("./rdpFile");
|
|
26
33
|
const remoteSetup_1 = require("./remoteSetup");
|
|
27
34
|
const rdcleanpath_1 = require("./rdcleanpath");
|
|
28
35
|
const signin_1 = require("./signin");
|
|
36
|
+
const ssh_1 = require("./ssh");
|
|
37
|
+
const wake_1 = require("./wake");
|
|
38
|
+
const vms_1 = require("./vms");
|
|
39
|
+
/** How long a desktop that was started (`wake`) gets to answer. */
|
|
40
|
+
const WAKE_TIMEOUT_MS = 3 * 60 * 1000;
|
|
29
41
|
// The vendored IronRDP bundles are ES modules. tsc turns import() into require(), and Node's import() is refused in
|
|
30
42
|
// Tabby on Windows (no dynamic import callback), so they go through a module script: the page's own loader.
|
|
31
43
|
let imports = 0;
|
|
@@ -48,7 +60,8 @@ function importESM(url) {
|
|
|
48
60
|
script.remove();
|
|
49
61
|
});
|
|
50
62
|
}
|
|
51
|
-
const
|
|
63
|
+
const vendorFile = (f) => path.join(__dirname, '..', 'vendor', f);
|
|
64
|
+
const vendor = (f) => (0, url_1.pathToFileURL)(vendorFile(f)).href;
|
|
52
65
|
const STYLE = `
|
|
53
66
|
.trd-overlay { position: absolute; inset: 0; z-index: 30; display: flex; background: #111; }
|
|
54
67
|
.trd-host { flex: auto; min-width: 0; min-height: 0; display: flex; }
|
|
@@ -58,8 +71,23 @@ const STYLE = `
|
|
|
58
71
|
pointer-events: none; }
|
|
59
72
|
.trd-status-actions { display: flex; gap: 8px; pointer-events: auto; }
|
|
60
73
|
.trd-status-actions:empty { display: none; }
|
|
74
|
+
.trd-status-help { pointer-events: auto; color: #8ab4f8; font-size: 12px; cursor: pointer; }
|
|
75
|
+
.trd-status-help:hover { text-decoration: underline; }
|
|
61
76
|
.trd-overlay.trd-dim .trd-host { opacity: 0.2; }
|
|
77
|
+
.trd-view-only { display: none; position: absolute; inset: 0; }
|
|
78
|
+
.trd-overlay.trd-view-only-on .trd-view-only { display: block; }
|
|
79
|
+
.trd-overlay.trd-broadcast-on::before { content: 'Typing into all desktops in this tab'; position: absolute; top: 8px; left: 50%;
|
|
80
|
+
transform: translateX(-50%); z-index: 4; padding: 2px 10px; border-radius: 4px; background: rgba(214, 120, 0, 0.92); color: #fff;
|
|
81
|
+
font-size: 11px; pointer-events: none; white-space: nowrap; }
|
|
82
|
+
.trd-view-only::after { content: 'View only'; position: absolute; top: 8px; left: 8px; padding: 2px 8px; border-radius: 4px;
|
|
83
|
+
background: rgba(30, 30, 30, 0.85); color: #ddd; font-size: 11px; pointer-events: none; }
|
|
84
|
+
.trd-mic { position: absolute; top: 8px; right: 8px; width: 22px; height: 22px; border-radius: 50%; display: none;
|
|
85
|
+
align-items: center; justify-content: center; background: rgba(220, 38, 38, 0.85); color: #fff; font-size: 11px;
|
|
86
|
+
pointer-events: none; }
|
|
87
|
+
.trd-overlay.trd-mic-on .trd-mic { display: flex; }
|
|
62
88
|
`;
|
|
89
|
+
/** A SHA-256 fingerprint on two lines of 16 bytes, so it fits a narrow pane. */
|
|
90
|
+
const showFingerprint = (fingerprint) => fingerprint ? `${fingerprint.slice(0, 47)}\n${fingerprint.slice(48)}` : '(none)';
|
|
63
91
|
/**
|
|
64
92
|
* grd ignores the scale factor in RDP monitor layouts, so a Retina-sized remote monitor would render its UI at
|
|
65
93
|
* half size. This applies the scale through Mutter's DisplayConfig (temporarily, like GNOME's Display settings
|
|
@@ -123,8 +151,24 @@ class DesktopSession {
|
|
|
123
151
|
this.remote = null;
|
|
124
152
|
/** Plays the remote's sound, when the sound setting was on at connect. */
|
|
125
153
|
this.audio = null;
|
|
154
|
+
/** Captures the microphone for the remote, when the microphone setting was on at connect. */
|
|
155
|
+
this.mic = null;
|
|
126
156
|
/** Files through the clipboard (drop on the desktop, or copy on the remote). */
|
|
127
157
|
this.files = null;
|
|
158
|
+
/** Decodes H.264 for the graphics pipeline (IronRDP's WebCodecsH264Decoder), when this connection uses it. */
|
|
159
|
+
this.h264 = null;
|
|
160
|
+
/** No keyboard or mouse input goes to the remote (keys: see keyboard.ts; the mouse: a layer over the picture). */
|
|
161
|
+
this.viewOnly = false;
|
|
162
|
+
/** The host's own desktop: the certificate its setup reported for this connection. */
|
|
163
|
+
this.pinnedCertificate = null;
|
|
164
|
+
/** Set when the proxy refused the server's certificate on the current connection attempt. */
|
|
165
|
+
this.certificateProblem = null;
|
|
166
|
+
/** The connection-status indicator, made the first time it shows. */
|
|
167
|
+
this.indicator = null;
|
|
168
|
+
/** How the pictures come (for the indicator): the graphics pipeline, bitmaps, or IronRDP's choice. */
|
|
169
|
+
this.graphics = '';
|
|
170
|
+
/** "What does this mean?" under a message (see status()): opens help about it. */
|
|
171
|
+
this.onHelp = null;
|
|
128
172
|
this.reclaimFocus = (event) => {
|
|
129
173
|
if (this.visible && !this.overlay.contains(event.target)) {
|
|
130
174
|
setTimeout(() => this.visible && this.focusDesktop());
|
|
@@ -136,7 +180,10 @@ class DesktopSession {
|
|
|
136
180
|
this.overlay.className = 'trd-overlay';
|
|
137
181
|
this.overlay.innerHTML = `
|
|
138
182
|
<div class="trd-host"></div>
|
|
139
|
-
<div class="trd-
|
|
183
|
+
<div class="trd-view-only" title="View only: no keyboard or mouse input goes to the remote desktop"></div>
|
|
184
|
+
<div class="trd-mic"><i class="fas fa-microphone"></i></div>
|
|
185
|
+
<div class="trd-osd"></div>
|
|
186
|
+
<div class="trd-status"><div class="trd-status-text"></div><div class="trd-status-actions"></div><a class="trd-status-help">What does this mean?</a></div>`;
|
|
140
187
|
this.host = this.overlay.querySelector('.trd-host');
|
|
141
188
|
this.statusEl = this.overlay.querySelector('.trd-status');
|
|
142
189
|
if (getComputedStyle(container).position === 'static') {
|
|
@@ -149,6 +196,28 @@ class DesktopSession {
|
|
|
149
196
|
this.container = container;
|
|
150
197
|
container.addEventListener('focusin', this.reclaimFocus, true);
|
|
151
198
|
this.overlay.addEventListener('mousedown', () => this.focusDesktop());
|
|
199
|
+
// View only: the layer takes the mouse, so the wheel scrolls an actual-size picture instead of the remote.
|
|
200
|
+
this.overlay.querySelector('.trd-view-only').addEventListener('wheel', event => {
|
|
201
|
+
const wheel = event;
|
|
202
|
+
this.host.querySelector('iron-remote-desktop')?.shadowRoot?.querySelector('.screen-wrapper')?.scrollBy(wheel.deltaX, wheel.deltaY);
|
|
203
|
+
}, { passive: true });
|
|
204
|
+
}
|
|
205
|
+
setViewOnly(viewOnly) {
|
|
206
|
+
this.viewOnly = viewOnly;
|
|
207
|
+
this.overlay.classList.toggle('trd-view-only-on', viewOnly);
|
|
208
|
+
this.log.push(`view only: ${viewOnly ? 'on' : 'off'}`);
|
|
209
|
+
}
|
|
210
|
+
/** Shows which desktop this is, for a moment (see osd.ts). */
|
|
211
|
+
flashLabel(name, sub, settings) {
|
|
212
|
+
const osd = this.overlay.querySelector('.trd-osd');
|
|
213
|
+
(0, osd_1.renderOsd)(osd, name, sub, settings);
|
|
214
|
+
osd.classList.add('trd-shown');
|
|
215
|
+
clearTimeout(this.labelTimer);
|
|
216
|
+
this.labelTimer = setTimeout(() => osd.classList.remove('trd-shown'), settings.seconds * 1000);
|
|
217
|
+
}
|
|
218
|
+
/** IronRDP's canvas: the remote frame, at the remote resolution. */
|
|
219
|
+
canvas() {
|
|
220
|
+
return this.host.querySelector('iron-remote-desktop')?.shadowRoot?.querySelector('canvas') ?? null;
|
|
152
221
|
}
|
|
153
222
|
/**
|
|
154
223
|
* Shows or hides the desktop layer. While it shows, the terminal's input textarea is disabled so
|
|
@@ -165,10 +234,16 @@ class DesktopSession {
|
|
|
165
234
|
setTimeout(() => this.visible && this.focusDesktop());
|
|
166
235
|
}
|
|
167
236
|
}
|
|
168
|
-
/**
|
|
169
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Shows a message over the desktop (empty: none), with buttons; with buttons the last picture is dimmed. `help`:
|
|
239
|
+
* with a "What does this mean?" link, for messages that end the connection.
|
|
240
|
+
*/
|
|
241
|
+
status(msg, actions = [], help = false) {
|
|
170
242
|
this.log.push(`${new Date().toISOString()} ${msg}`);
|
|
171
243
|
this.statusEl.querySelector('.trd-status-text').textContent = msg;
|
|
244
|
+
const link = this.statusEl.querySelector('.trd-status-help');
|
|
245
|
+
link.style.display = help && this.onHelp ? '' : 'none';
|
|
246
|
+
link.onclick = () => this.onHelp?.(msg);
|
|
172
247
|
this.statusEl.querySelector('.trd-status-actions').replaceChildren(...actions.map(action => {
|
|
173
248
|
const button = document.createElement('button');
|
|
174
249
|
button.className = 'btn btn-secondary';
|
|
@@ -194,8 +269,7 @@ class DesktopSession {
|
|
|
194
269
|
return;
|
|
195
270
|
}
|
|
196
271
|
const host = this.host.querySelector('iron-remote-desktop');
|
|
197
|
-
|
|
198
|
-
canvas?.focus();
|
|
272
|
+
this.canvas()?.focus();
|
|
199
273
|
if (host && document.activeElement !== host && attempts > 1 && this.visible) {
|
|
200
274
|
setTimeout(() => this.visible && this.focusDesktop(attempts - 1), 16);
|
|
201
275
|
}
|
|
@@ -205,11 +279,14 @@ class DesktopSession {
|
|
|
205
279
|
this.setVisible(false);
|
|
206
280
|
this.container.removeEventListener('focusin', this.reclaimFocus, true);
|
|
207
281
|
this.disposers.forEach(f => f());
|
|
282
|
+
this.indicator?.dispose();
|
|
208
283
|
try {
|
|
209
284
|
this.ui?.shutdown();
|
|
210
285
|
}
|
|
211
286
|
catch { }
|
|
212
287
|
this.audio?.close();
|
|
288
|
+
this.h264?.close();
|
|
289
|
+
this.mic?.close();
|
|
213
290
|
this.files?.dispose();
|
|
214
291
|
this.proxy?.close();
|
|
215
292
|
this.overlay.remove();
|
|
@@ -221,19 +298,63 @@ class DesktopSession {
|
|
|
221
298
|
* running until disconnected or the pane closes. One desktop per remote account.
|
|
222
299
|
*/
|
|
223
300
|
let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
224
|
-
constructor(app, targets, notifications, config, zone) {
|
|
301
|
+
constructor(app, targets, notifications, config, zone, profiles, help, selector, platform) {
|
|
225
302
|
this.app = app;
|
|
226
303
|
this.targets = targets;
|
|
227
304
|
this.notifications = notifications;
|
|
228
305
|
this.config = config;
|
|
229
306
|
this.zone = zone;
|
|
307
|
+
this.profiles = profiles;
|
|
308
|
+
this.help = help;
|
|
309
|
+
this.selector = selector;
|
|
310
|
+
this.platform = platform;
|
|
230
311
|
/** Emits whenever a pane's desktop is opened, shown, hidden, connected, ended or disconnected. */
|
|
231
312
|
this.changed$ = new rxjs_1.Subject();
|
|
232
313
|
this.sessions = new Map();
|
|
233
314
|
this.ironrdp = null;
|
|
315
|
+
/** Whether the browser decodes H.264 (asked once). */
|
|
316
|
+
this.h264Support = null;
|
|
317
|
+
/** Desktops (session keys) where H.264 decoding failed: they connect without it until Tabby restarts. */
|
|
318
|
+
this.h264Failed = new Set();
|
|
319
|
+
/**
|
|
320
|
+
* What the setup last found on each SSH host (by target key): GNOME, xrdp (and its port) or Windows. Kept for as
|
|
321
|
+
* long as Tabby runs; a Windows host is found out on the first connect there, which costs a failed setup.
|
|
322
|
+
*/
|
|
323
|
+
this.ownDesktops = new Map();
|
|
324
|
+
/** VMs found on SSH hosts (see vms.ts), by target key: when, and as desktops. */
|
|
325
|
+
this.vms = new Map();
|
|
234
326
|
this.lastUsed = new Map();
|
|
235
327
|
/** Automatic reconnection after a desktop dropped, per pane: attempts so far and the pending timer. */
|
|
236
328
|
this.reconnects = new Map();
|
|
329
|
+
/** Split tabs where keys typed on one desktop go to all of them (see DesktopKeyboard). */
|
|
330
|
+
this.broadcast = new WeakSet();
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* 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
|
|
334
|
+
* the prompt, the machine it went to (as for a local terminal running ssh), through the tab's host. Asks when it
|
|
335
|
+
* can't tell which. Undefined: cancelled.
|
|
336
|
+
*/
|
|
337
|
+
async remoteFor(pane) {
|
|
338
|
+
const own = await this.targets.targetOf(pane);
|
|
339
|
+
const nested = own && (0, ssh_1.isSSHTab)(pane) ? await this.targets.nestedSSH(pane).catch(() => []) : [];
|
|
340
|
+
if (!own || !nested.length) {
|
|
341
|
+
return own;
|
|
342
|
+
}
|
|
343
|
+
let chosen = nested[0];
|
|
344
|
+
if (nested.length > 1) {
|
|
345
|
+
const picked = await this.selector.show('Which remote desktop?', [
|
|
346
|
+
...nested.map(n => ({ name: n.cmd.destination, description: `ssh running in a terminal on ${own.label}`, icon: 'fas fa-desktop', result: n })),
|
|
347
|
+
{ name: own.label, description: 'this tab\'s own SSH connection', icon: 'fas fa-desktop', result: null },
|
|
348
|
+
]).catch(() => undefined);
|
|
349
|
+
if (picked === undefined) {
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
if (!picked) {
|
|
353
|
+
return own;
|
|
354
|
+
}
|
|
355
|
+
chosen = picked;
|
|
356
|
+
}
|
|
357
|
+
return await this.targets.nestedTarget(pane, chosen).catch(() => null) ?? own;
|
|
237
358
|
}
|
|
238
359
|
has(pane) {
|
|
239
360
|
this.prune();
|
|
@@ -243,16 +364,90 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
243
364
|
return !!this.sessions.get(pane)?.visible;
|
|
244
365
|
}
|
|
245
366
|
async toggle(pane) {
|
|
246
|
-
if (this.isVisible(pane)) {
|
|
367
|
+
if (!this.isVisible(pane)) {
|
|
368
|
+
await this.showDesktop(pane);
|
|
369
|
+
}
|
|
370
|
+
else if (this.hasConsole(pane)) {
|
|
247
371
|
this.showConsole(pane);
|
|
248
372
|
}
|
|
249
|
-
|
|
250
|
-
|
|
373
|
+
}
|
|
374
|
+
/** Whether there is a console to switch to: not in a remote desktop tab, where the desktop is all there is. */
|
|
375
|
+
hasConsole(pane) {
|
|
376
|
+
return !(0, targets_1.isRDPTab)(pane);
|
|
377
|
+
}
|
|
378
|
+
/** Whether a desktop signs in with an account of its own: all but the host's own GNOME desktop. */
|
|
379
|
+
signsIn(spec) {
|
|
380
|
+
return spec.id !== desktops_1.OWN_DESKTOP || spec.kind !== 'gnome';
|
|
381
|
+
}
|
|
382
|
+
/** Whether the pane's desktop signed in with an account (which "Sign in again…" can replace). */
|
|
383
|
+
canSignInAgain(pane) {
|
|
384
|
+
const spec = this.sessions.get(pane)?.spec;
|
|
385
|
+
return !!spec && this.signsIn(spec);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Forgets the saved account of the pane's desktop and connects again, with the sign-in form. For xrdp above all:
|
|
389
|
+
* with a wrong password it shows its own login window rather than refusing, so the form never comes back.
|
|
390
|
+
*/
|
|
391
|
+
async signInAgain(pane) {
|
|
392
|
+
const session = this.sessions.get(pane);
|
|
393
|
+
if (session && this.signsIn(session.spec)) {
|
|
394
|
+
await (0, signin_1.forgetCredentials)(session.key);
|
|
395
|
+
await this.reopen(pane, session.spec);
|
|
251
396
|
}
|
|
252
397
|
}
|
|
253
|
-
/**
|
|
398
|
+
/**
|
|
399
|
+
* The desktops this pane's SSH host offers: its own, then those configured behind it, then the RDP profiles that go
|
|
400
|
+
* through its SSH profile. A remote desktop tab has just its own one.
|
|
401
|
+
*/
|
|
254
402
|
desktopsOf(target) {
|
|
255
|
-
|
|
403
|
+
if (target.direct) {
|
|
404
|
+
return [target.direct];
|
|
405
|
+
}
|
|
406
|
+
const viaProfile = target.profileId
|
|
407
|
+
? (this.config.store.profiles ?? []).filter((p) => p?.type === targets_1.RDP_PROFILE_TYPE && p.options?.host && p.options.via === target.profileId)
|
|
408
|
+
.map((p) => ({ ...p.options, name: p.name }))
|
|
409
|
+
: [];
|
|
410
|
+
const specs = (0, desktops_1.desktopsFor)(target, this.config.store.remoteDesktop?.desktops, this.ownDesktops.get(target.key), viaProfile);
|
|
411
|
+
// Found VMs come last, and not where a configured desktop has the same address.
|
|
412
|
+
const found = this.settings().discoverVMs ? this.vms.get(target.key)?.specs ?? [] : [];
|
|
413
|
+
return [...specs, ...found.filter(f => !specs.some(s => s.id === f.id))];
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Looks for VMs with a desktop on the host (libvirt), unless it did in the last minute or the setting is off.
|
|
417
|
+
* Resolves when the list is up to date; menus wait a little for it, and show what they have.
|
|
418
|
+
*/
|
|
419
|
+
discoverVMs(target) {
|
|
420
|
+
if (!this.settings().discoverVMs || target.direct || typeof target.exec !== 'function') {
|
|
421
|
+
return Promise.resolve();
|
|
422
|
+
}
|
|
423
|
+
const known = this.vms.get(target.key);
|
|
424
|
+
if (known?.scan) {
|
|
425
|
+
return known.scan;
|
|
426
|
+
}
|
|
427
|
+
if (known && Date.now() - known.at < 60000) {
|
|
428
|
+
return Promise.resolve();
|
|
429
|
+
}
|
|
430
|
+
const scan = (0, vms_1.scanVMs)(target).then(found => {
|
|
431
|
+
this.vms.set(target.key, { at: Date.now(), specs: found.map(vms_1.vmSpec) });
|
|
432
|
+
this.changed$.next();
|
|
433
|
+
}, () => {
|
|
434
|
+
this.vms.set(target.key, { at: Date.now(), specs: known?.specs ?? [] });
|
|
435
|
+
});
|
|
436
|
+
this.vms.set(target.key, { at: known?.at ?? 0, specs: known?.specs ?? [], scan });
|
|
437
|
+
return scan;
|
|
438
|
+
}
|
|
439
|
+
/** Keeps a found VM open in the pane among the host's desktops (`remoteDesktop.desktops`), under its name. */
|
|
440
|
+
saveFoundDesktop(pane) {
|
|
441
|
+
const session = this.sessions.get(pane);
|
|
442
|
+
const spec = session?.spec;
|
|
443
|
+
if (!session?.remote || !spec?.found) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const entry = { name: spec.name, via: session.remote.label, host: spec.host, port: spec.port, kind: spec.kind, ...spec.wake ? { wake: spec.wake } : {} };
|
|
447
|
+
this.config.store.remoteDesktop.desktops = [...this.configuredDesktops(), entry];
|
|
448
|
+
this.config.save();
|
|
449
|
+
this.changed$.next();
|
|
450
|
+
this.notifications.notice(`Saved "${spec.name}" to ${session.remote.label}'s desktops (Settings › Remote Desktop)`);
|
|
256
451
|
}
|
|
257
452
|
/** The desktop a plain toggle (hotkey, header, toolbar) opens for this target: the last one used there. */
|
|
258
453
|
defaultDesktop(target) {
|
|
@@ -268,7 +463,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
268
463
|
const specs = target ? this.desktopsOf(target) : [];
|
|
269
464
|
return { specs, current: this.desktopOf(pane) ?? (target ? this.defaultDesktop(target) : null) };
|
|
270
465
|
}
|
|
271
|
-
/** "Send files
|
|
466
|
+
/** "Send files…": picks files to paste there. */
|
|
272
467
|
sendFiles(pane) {
|
|
273
468
|
this.sessions.get(pane)?.files?.pickAndSend().catch(() => null);
|
|
274
469
|
}
|
|
@@ -297,7 +492,12 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
297
492
|
}
|
|
298
493
|
// The resolved hostname, so a profile tab and a plain `ssh` to that machine both offer it.
|
|
299
494
|
const hostname = target.key.replace(/:\d+$/, '').replace(/^[^@]*@/, '');
|
|
300
|
-
const entry = await (0, desktopForm_1.
|
|
495
|
+
const entry = await (0, desktopForm_1.askDesktop)(pane.element.nativeElement, {
|
|
496
|
+
title: `Add a desktop reached through ${target.label}`,
|
|
497
|
+
action: 'Add and open',
|
|
498
|
+
entry: { via: hostname, host: '127.0.0.1', port: 3389 },
|
|
499
|
+
check: e => this.addressTaken(e, -1),
|
|
500
|
+
});
|
|
301
501
|
if (!entry) {
|
|
302
502
|
pane.frontend?.focus();
|
|
303
503
|
return;
|
|
@@ -305,19 +505,170 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
305
505
|
this.config.store.remoteDesktop.desktops = [...this.configuredDesktops(), entry];
|
|
306
506
|
this.config.save();
|
|
307
507
|
this.changed$.next();
|
|
308
|
-
await this.showDesktop(pane,
|
|
508
|
+
await this.showDesktop(pane, (0, desktops_1.desktopIdOf)(entry));
|
|
509
|
+
}
|
|
510
|
+
/** Another entry for the same host already has this address (desktops are told apart by it). */
|
|
511
|
+
addressTaken(entry, index) {
|
|
512
|
+
const id = (0, desktops_1.desktopIdOf)(entry);
|
|
513
|
+
const via = (entry.via ?? '').trim().toLowerCase();
|
|
514
|
+
const taken = this.configuredDesktops().some((d, i) => i !== index && (0, desktops_1.desktopIdOf)(d) === id && (d.via ?? '').trim().toLowerCase() === via);
|
|
515
|
+
return taken ? `There is already a desktop at ${id} behind ${entry.via}.` : null;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* "Edit a desktop": the same form, over the pane, filled in. A new address takes the desktop's saved accounts and
|
|
519
|
+
* sharpness along; a new user name drops the saved account, which was for the old one. Open sessions keep running.
|
|
520
|
+
*/
|
|
521
|
+
async editDesktop(pane, index) {
|
|
522
|
+
const old = this.configuredDesktops()[index];
|
|
523
|
+
if (!old) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (this.isVisible(pane)) {
|
|
527
|
+
this.showConsole(pane);
|
|
528
|
+
}
|
|
529
|
+
const entry = await (0, desktopForm_1.askDesktop)(pane.element.nativeElement, {
|
|
530
|
+
title: `Edit ${old.name ?? (0, desktops_1.desktopIdOf)(old)} (behind ${old.via})`,
|
|
531
|
+
action: 'Save',
|
|
532
|
+
entry: old,
|
|
533
|
+
check: e => this.addressTaken(e, index),
|
|
534
|
+
});
|
|
535
|
+
pane.frontend?.focus();
|
|
536
|
+
// Compared with the entry as it is now: the config can change while the form shows.
|
|
537
|
+
const list = this.configuredDesktops();
|
|
538
|
+
if (!entry || list[index] !== old) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
list[index] = entry;
|
|
542
|
+
this.config.store.remoteDesktop.desktops = list;
|
|
543
|
+
this.config.save();
|
|
544
|
+
this.changed$.next();
|
|
545
|
+
const accountChanged = (old.username ?? '') !== (entry.username ?? '') || (old.domain ?? '') !== (entry.domain ?? '');
|
|
546
|
+
await this.desktopEdited((0, desktops_1.desktopIdOf)(old), (0, desktops_1.desktopIdOf)(entry), accountChanged, false);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* A desktop was edited (the edit form, or an RDP profile's settings): what is kept per desktop is keyed by its
|
|
550
|
+
* address, so a new address takes the saved account, its sharpness, its remembered certificate and the last-used
|
|
551
|
+
* choice along. (Should another machine answer at the new address, its certificate differs and the connection
|
|
552
|
+
* stops to ask, rather than trusting it silently as a first use.) A new user name or domain drops the saved
|
|
553
|
+
* account instead: it was for the old one. `direct`: a direct desktop (`rdp#<id>` keys), otherwise one behind SSH
|
|
554
|
+
* hosts (`<ssh key>#<id>`).
|
|
555
|
+
*/
|
|
556
|
+
async desktopEdited(fromId, toId, accountChanged, direct) {
|
|
557
|
+
if (direct) {
|
|
558
|
+
const [from, to] = [`${desktops_1.DIRECT_KEY}#${fromId}`, `${desktops_1.DIRECT_KEY}#${toId}`];
|
|
559
|
+
const saved = !accountChanged && from !== to ? await (0, signin_1.loadCredentials)(from) : null;
|
|
560
|
+
if (saved) {
|
|
561
|
+
await (0, signin_1.saveCredentials)(to, saved).catch(() => null);
|
|
562
|
+
}
|
|
563
|
+
if (accountChanged || from !== to) {
|
|
564
|
+
await (0, signin_1.forgetCredentials)(from);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
else if (accountChanged) {
|
|
568
|
+
await (0, signin_1.forgetCredentialsFor)(fromId);
|
|
569
|
+
}
|
|
570
|
+
else if (fromId !== toId) {
|
|
571
|
+
await (0, signin_1.moveCredentialsFor)(fromId, toId);
|
|
572
|
+
}
|
|
573
|
+
if (fromId === toId) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const moved = (key) => direct ? key === `${desktops_1.DIRECT_KEY}#${fromId}` : key.endsWith(`#${fromId}`) && !key.startsWith(`${desktops_1.DIRECT_KEY}#`);
|
|
577
|
+
const store = this.config.store.remoteDesktop;
|
|
578
|
+
for (const list of ['desktopSharpness', 'trustedCertificates']) {
|
|
579
|
+
if (Array.isArray(store[list])) {
|
|
580
|
+
store[list] = store[list].map((e) => typeof e?.desktop === 'string' && moved(e.desktop)
|
|
581
|
+
? { ...e, desktop: `${e.desktop.slice(0, -fromId.length)}${toId}` }
|
|
582
|
+
: e);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
this.config.save();
|
|
586
|
+
for (const [key, id] of this.lastUsed) {
|
|
587
|
+
if (id === fromId && (key === desktops_1.DIRECT_KEY) === direct) {
|
|
588
|
+
this.lastUsed.set(key, toId);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/** "Import an .rdp file…": picks one and adds it as a remote desktop profile (see importRdp). */
|
|
593
|
+
async importRdpFile() {
|
|
594
|
+
const file = await new Promise(resolve => {
|
|
595
|
+
const input = document.createElement('input');
|
|
596
|
+
input.type = 'file';
|
|
597
|
+
input.accept = '.rdp';
|
|
598
|
+
input.style.display = 'none';
|
|
599
|
+
input.addEventListener('change', () => { input.remove(); resolve(input.files?.[0] ?? null); });
|
|
600
|
+
input.addEventListener('cancel', () => { input.remove(); resolve(null); });
|
|
601
|
+
document.body.appendChild(input);
|
|
602
|
+
input.click();
|
|
603
|
+
});
|
|
604
|
+
if (file) {
|
|
605
|
+
await this.importRdp(new Uint8Array(await file.arrayBuffer()), file.name);
|
|
606
|
+
}
|
|
309
607
|
}
|
|
310
|
-
/**
|
|
608
|
+
/**
|
|
609
|
+
* Adds a "Remote desktop (RDP)" profile from a .rdp file's contents (address, user name, domain; see rdpFile.ts)
|
|
610
|
+
* and opens it; or opens the profile that already has that address and user. Returns the profile, or null.
|
|
611
|
+
*/
|
|
612
|
+
async importRdp(data, fileName) {
|
|
613
|
+
const parsed = (0, rdpFile_1.parseRdpFile)(data);
|
|
614
|
+
if (!parsed) {
|
|
615
|
+
this.notifications.error(`${fileName}: no address ("full address") in it`);
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
const existing = (this.config.store.profiles ?? []).find((p) => p?.type === targets_1.RDP_PROFILE_TYPE && !p.options?.via &&
|
|
619
|
+
p.options?.host === parsed.host && (p.options.port || 3389) === parsed.port && (p.options.username ?? '') === (parsed.username ?? ''));
|
|
620
|
+
if (existing) {
|
|
621
|
+
this.notifications.info(`Opening "${existing.name}", which is already a profile for ${fileName}`);
|
|
622
|
+
await this.profiles.openNewTabForProfile(existing);
|
|
623
|
+
return existing;
|
|
624
|
+
}
|
|
625
|
+
const profile = {
|
|
626
|
+
type: targets_1.RDP_PROFILE_TYPE,
|
|
627
|
+
name: fileName.replace(/^.*[\\/]/, '').replace(/\.rdp$/i, '') || parsed.host,
|
|
628
|
+
icon: 'fas fa-desktop',
|
|
629
|
+
options: { host: parsed.host, port: parsed.port, kind: 'windows', username: parsed.username ?? '', domain: parsed.domain ?? '', via: '' },
|
|
630
|
+
};
|
|
631
|
+
await this.profiles.newProfile(profile);
|
|
632
|
+
this.config.save();
|
|
633
|
+
this.notifications.notice(`Added the remote desktop profile "${profile.name}" (Settings › Profiles & connections)`);
|
|
634
|
+
await this.profiles.openNewTabForProfile(profile);
|
|
635
|
+
return profile;
|
|
636
|
+
}
|
|
637
|
+
/** Asks, then removes a configured desktop (see removeDesktop). The menus and the settings page use this. */
|
|
638
|
+
async confirmRemoveDesktop(index) {
|
|
639
|
+
const d = this.configuredDesktops()[index];
|
|
640
|
+
if (!d) {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
const address = `${d.host ?? '127.0.0.1'}:${d.port ?? 3389}`;
|
|
644
|
+
const { response } = await this.platform.showMessageBox({
|
|
645
|
+
type: 'warning',
|
|
646
|
+
message: `Remove the desktop "${d.name ?? address}"?`,
|
|
647
|
+
detail: `${address} behind ${d.via}. Its saved password and remembered certificate are forgotten too. ` +
|
|
648
|
+
'To open it instead, use its host\'s SSH tab: right-click › Open.',
|
|
649
|
+
buttons: ['Remove', 'Keep'],
|
|
650
|
+
defaultId: 1,
|
|
651
|
+
cancelId: 1,
|
|
652
|
+
});
|
|
653
|
+
if (response !== 0) {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
this.removeDesktop(index);
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
/** Removes a configured desktop, its saved accounts and its remembered certificate. Open sessions to it keep running. */
|
|
311
660
|
removeDesktop(index) {
|
|
312
661
|
const list = this.configuredDesktops();
|
|
313
662
|
const [removed] = list.splice(index, 1);
|
|
314
663
|
if (!removed) {
|
|
315
664
|
return;
|
|
316
665
|
}
|
|
666
|
+
const id = `${removed.host ?? '127.0.0.1'}:${removed.port ?? 3389}`;
|
|
317
667
|
this.config.store.remoteDesktop.desktops = list;
|
|
668
|
+
this.forgetCertificatesFor(id);
|
|
318
669
|
this.config.save();
|
|
319
670
|
this.changed$.next();
|
|
320
|
-
(0, signin_1.forgetCredentialsFor)(
|
|
671
|
+
(0, signin_1.forgetCredentialsFor)(id);
|
|
321
672
|
}
|
|
322
673
|
/**
|
|
323
674
|
* Shows a desktop over the pane: `desktopId` (see desktopsOf), or the one it has open, or the default. A pane
|
|
@@ -331,7 +682,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
331
682
|
session = undefined;
|
|
332
683
|
}
|
|
333
684
|
if (!session) {
|
|
334
|
-
const target = await this.
|
|
685
|
+
const target = options.target ?? await this.remoteFor(pane);
|
|
686
|
+
if (target === undefined) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
335
689
|
if (!target) {
|
|
336
690
|
this.notifications.error('No SSH connection found in this terminal');
|
|
337
691
|
return;
|
|
@@ -351,10 +705,18 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
351
705
|
// over the session (Windows).
|
|
352
706
|
const existing = this.paneWithDesktopFor(key);
|
|
353
707
|
if (existing) {
|
|
708
|
+
if (existing.parent instanceof tabby_core_1.SplitTabComponent && existing.parent === pane.parent) {
|
|
709
|
+
// Next to it, moving the focus there would go unnoticed.
|
|
710
|
+
this.help.note(pane.element.nativeElement, `${target.label}'s desktop is already open in the other pane.`, 'nested-ssh');
|
|
711
|
+
}
|
|
354
712
|
this.selectPane(existing);
|
|
355
713
|
return this.showDesktop(existing);
|
|
356
714
|
}
|
|
357
715
|
const created = new DesktopSession(pane.element.nativeElement, key, spec);
|
|
716
|
+
created.onHelp = message => this.help.explain(message);
|
|
717
|
+
if (options.viewOnly) {
|
|
718
|
+
created.setViewOnly(true);
|
|
719
|
+
}
|
|
358
720
|
session = created;
|
|
359
721
|
this.sessions.set(pane, created);
|
|
360
722
|
pane.destroyed$.subscribe(() => this.disconnect(pane));
|
|
@@ -362,6 +724,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
362
724
|
const focused = pane.focused$.subscribe(() => {
|
|
363
725
|
if (created.visible) {
|
|
364
726
|
setTimeout(() => created.visible && created.focusDesktop());
|
|
727
|
+
this.label(pane, created);
|
|
365
728
|
}
|
|
366
729
|
});
|
|
367
730
|
created.onDispose(() => focused.unsubscribe());
|
|
@@ -371,7 +734,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
371
734
|
return;
|
|
372
735
|
}
|
|
373
736
|
session.setVisible(true);
|
|
737
|
+
session.overlay.classList.toggle('trd-broadcast-on', this.isBroadcast(pane));
|
|
738
|
+
this.syncIndicator(session);
|
|
374
739
|
this.changed$.next();
|
|
740
|
+
this.tip(pane, session);
|
|
741
|
+
this.label(pane, session);
|
|
375
742
|
const shown = session;
|
|
376
743
|
setTimeout(() => this.followPane(pane, shown));
|
|
377
744
|
}
|
|
@@ -380,11 +747,33 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
380
747
|
* attached to the console's tmux session (or opened in its folder when it isn't in tmux).
|
|
381
748
|
*/
|
|
382
749
|
async openConsole(pane, request) {
|
|
383
|
-
await this.showDesktop(pane, desktops_1.OWN_DESKTOP);
|
|
384
750
|
const target = await this.targets.targetOf(pane);
|
|
385
751
|
if (!target) {
|
|
386
752
|
return;
|
|
387
753
|
}
|
|
754
|
+
// `desk` may run on another machine than the pane is connected to: `ssh` typed in its console, with the script
|
|
755
|
+
// there through a shared home folder. That machine's desktop isn't this connection's to open.
|
|
756
|
+
if (request.machine) {
|
|
757
|
+
const machineOf = async (t) => (await t.exec('sh -s', deskScript_1.MACHINE_ID_COMMAND).catch(() => '')).trim();
|
|
758
|
+
const machine = await machineOf(target);
|
|
759
|
+
if (machine && machine !== request.machine) {
|
|
760
|
+
// Typed `ssh` at the prompt: the machine it went to, when that is where desk ran.
|
|
761
|
+
for (const nested of (0, ssh_1.isSSHTab)(pane) ? await this.targets.nestedSSH(pane).catch(() => []) : []) {
|
|
762
|
+
const there = await this.targets.nestedTarget(pane, nested).catch(() => null);
|
|
763
|
+
if (there && await machineOf(there) === request.machine) {
|
|
764
|
+
return this.openConsoleOn(pane, there, request);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const there = request.hostname || 'another machine';
|
|
768
|
+
this.help.note(pane.element.nativeElement, `desk ran on ${there}, but this tab is connected to ${target.label}. ` +
|
|
769
|
+
`Open ${there} in its own tab to use desk there.`, 'nested-ssh');
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return this.openConsoleOn(pane, target, request);
|
|
774
|
+
}
|
|
775
|
+
async openConsoleOn(pane, target, request) {
|
|
776
|
+
await this.showDesktop(pane, desktops_1.OWN_DESKTOP, { target });
|
|
388
777
|
// Wait for the desktop (possibly another pane's, for the same account) to be connected.
|
|
389
778
|
let session;
|
|
390
779
|
for (let i = 0; i < 240; i++) {
|
|
@@ -398,6 +787,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
398
787
|
if (session?.state !== 'connected') {
|
|
399
788
|
return;
|
|
400
789
|
}
|
|
790
|
+
if (session.spec.kind !== 'gnome') {
|
|
791
|
+
// The terminal it opens is GNOME's, on the headless GNOME session.
|
|
792
|
+
this.notifications.error(`desk: needs a GNOME desktop; this one is ${session.spec.kind === 'xrdp' ? 'served by xrdp' : 'Windows'}`);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
401
795
|
try {
|
|
402
796
|
const out = await target.exec('sh -s', (0, deskScript_1.consoleScript)(request));
|
|
403
797
|
const err = /^RD_ERR (.*)$/m.exec(out);
|
|
@@ -453,6 +847,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
453
847
|
const session = this.sessions.get(pane);
|
|
454
848
|
if (session) {
|
|
455
849
|
session.setVisible(false);
|
|
850
|
+
this.syncIndicator(session);
|
|
456
851
|
this.changed$.next();
|
|
457
852
|
}
|
|
458
853
|
pane.frontend?.focus();
|
|
@@ -486,11 +881,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
486
881
|
const session = this.sessions.get(pane);
|
|
487
882
|
const background = !!session && !session.visible;
|
|
488
883
|
this.drop(pane, false);
|
|
489
|
-
|
|
884
|
+
// The same remote as before, even if the `ssh` it was found through has ended since.
|
|
885
|
+
await this.zone.run(() => this.showDesktop(pane, spec.id, { background, viewOnly: session?.viewOnly, target: session?.remote ?? undefined }));
|
|
490
886
|
}
|
|
491
887
|
/** After a desktop ended: reconnect by itself if it dropped, otherwise offer to. */
|
|
492
888
|
afterEnd(pane, target, spec, session, outcome) {
|
|
493
889
|
session.state = 'ended';
|
|
890
|
+
this.syncIndicator(session);
|
|
494
891
|
this.changed$.next();
|
|
495
892
|
if (this.sessions.get(pane) !== session) {
|
|
496
893
|
return;
|
|
@@ -507,7 +904,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
507
904
|
session.status(`Remote desktop session ended: ${outcome.reason ?? 'unknown reason'}`, [again('Reconnect')]);
|
|
508
905
|
}
|
|
509
906
|
else {
|
|
510
|
-
session.status(`Remote desktop failed: ${outcome.error ?? 'unknown error'}`, [again('Try again')]);
|
|
907
|
+
session.status(`Remote desktop failed: ${outcome.error ?? 'unknown error'}`, [again('Try again')], true);
|
|
511
908
|
}
|
|
512
909
|
}
|
|
513
910
|
scheduleReconnect(pane, target, spec, session, message) {
|
|
@@ -536,7 +933,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
536
933
|
const delays = RemoteDesktopService_1.RECONNECT_DELAYS;
|
|
537
934
|
if (state.attempts >= delays.length) {
|
|
538
935
|
this.reconnects.delete(pane);
|
|
539
|
-
session.status(`${message}\nStopped reconnecting automatically.`, [{ label: 'Reconnect', run: () => this.reopen(pane, spec) }]);
|
|
936
|
+
session.status(`${message}\nStopped reconnecting automatically.`, [{ label: 'Reconnect', run: () => this.reopen(pane, spec) }], true);
|
|
540
937
|
return;
|
|
541
938
|
}
|
|
542
939
|
const delay = delays[state.attempts++];
|
|
@@ -551,6 +948,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
551
948
|
desk: store.desk === true,
|
|
552
949
|
macShortcuts: store.macShortcuts !== false,
|
|
553
950
|
sound: store.sound !== false,
|
|
951
|
+
h264: store.h264 !== false,
|
|
952
|
+
zoom: store.zoom === 'actual' ? 'actual' : 'fit',
|
|
953
|
+
connectionStatus: store.connectionStatus === true,
|
|
954
|
+
microphone: store.microphone === true,
|
|
955
|
+
osd: (0, osd_1.osdSettings)(store.osd),
|
|
956
|
+
discoverVMs: store.discoverVMs !== false,
|
|
957
|
+
checkUpdates: store.checkUpdates !== false,
|
|
554
958
|
};
|
|
555
959
|
}
|
|
556
960
|
/** The sharpness chosen for the pane's desktop in particular (`remoteDesktop.desktopSharpness`), or null. */
|
|
@@ -587,21 +991,62 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
587
991
|
/** Changes a setting, saves it, and applies it to open desktops. */
|
|
588
992
|
updateSettings(change) {
|
|
589
993
|
const store = this.config.store.remoteDesktop;
|
|
590
|
-
|
|
994
|
+
const { osd, ...rest } = change;
|
|
995
|
+
Object.assign(store, rest);
|
|
996
|
+
if (osd) {
|
|
997
|
+
// A nested object in Tabby's config takes its fields one by one (the object itself isn't replaced).
|
|
998
|
+
Object.assign(store.osd, osd);
|
|
999
|
+
}
|
|
1000
|
+
if (change.h264) {
|
|
1001
|
+
this.h264Failed.clear(); // turned on again: give it another try everywhere
|
|
1002
|
+
}
|
|
591
1003
|
this.config.save();
|
|
592
1004
|
for (const [pane, session] of this.sessions) {
|
|
1005
|
+
this.applyZoom(session);
|
|
1006
|
+
this.syncIndicator(session);
|
|
593
1007
|
this.followPane(pane, session);
|
|
594
1008
|
}
|
|
595
1009
|
}
|
|
1010
|
+
/** IronRDP's ScreenScale for the picture: Real (1:1, scrolling) only with a fixed resolution and zoom 'actual'. */
|
|
1011
|
+
screenScale() {
|
|
1012
|
+
const settings = this.settings();
|
|
1013
|
+
return settings.resize === 'off' && settings.zoom === 'actual' ? 3 /* ScreenScale.Real */ : 1; /* ScreenScale.Fit */
|
|
1014
|
+
}
|
|
1015
|
+
applyZoom(session) {
|
|
1016
|
+
try {
|
|
1017
|
+
session.ui?.setScale(this.screenScale());
|
|
1018
|
+
}
|
|
1019
|
+
catch { }
|
|
1020
|
+
}
|
|
1021
|
+
/** Shows the desktop's connection-status indicator while the setting is on and the desktop shows, connected. */
|
|
1022
|
+
syncIndicator(session) {
|
|
1023
|
+
const on = this.settings().connectionStatus && session.visible && session.state === 'connected';
|
|
1024
|
+
if (on && !session.indicator) {
|
|
1025
|
+
const remote = session.remote;
|
|
1026
|
+
session.indicator = new connectionStatus_1.ConnectionStatus(session.overlay, this.zone, {
|
|
1027
|
+
path: () => {
|
|
1028
|
+
const spec = session.spec;
|
|
1029
|
+
const size = session.remoteSize;
|
|
1030
|
+
return [
|
|
1031
|
+
spec.id === desktops_1.OWN_DESKTOP ? spec.name : remote?.direct ? `${spec.name} (direct)` : `${spec.name} via ${remote?.label ?? '?'}`,
|
|
1032
|
+
size ? `${size.width}×${size.height}` : '',
|
|
1033
|
+
session.graphics,
|
|
1034
|
+
this.settingsFor(session).sharpness === 'retina' ? 'Retina' : 'Standard',
|
|
1035
|
+
].filter(Boolean).join(' · ');
|
|
1036
|
+
},
|
|
1037
|
+
bytes: () => session.proxy?.stats ?? null,
|
|
1038
|
+
ping: remote?.ping ? () => remote.ping() : undefined,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
const canvas = session.host.querySelector('iron-remote-desktop')?.shadowRoot?.querySelector('canvas');
|
|
1042
|
+
session.indicator?.setActive(on, on ? canvas : null);
|
|
1043
|
+
}
|
|
596
1044
|
/** Follows the pane's size (debounced) while the desktop shows, per the resize setting. */
|
|
597
1045
|
watchSize(pane, session) {
|
|
598
1046
|
let timer;
|
|
599
1047
|
const observer = new ResizeObserver(() => {
|
|
600
1048
|
// Refit the view right away (the component only refits on window resizes by itself) ...
|
|
601
|
-
|
|
602
|
-
session.ui?.setScale(1 /* ScreenScale.Fit */);
|
|
603
|
-
}
|
|
604
|
-
catch { }
|
|
1049
|
+
this.applyZoom(session);
|
|
605
1050
|
// ... and change the remote resolution once the size settles.
|
|
606
1051
|
clearTimeout(timer);
|
|
607
1052
|
timer = setTimeout(() => this.followPane(pane, session), 300);
|
|
@@ -625,9 +1070,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
625
1070
|
}
|
|
626
1071
|
if (settings.resize === 'reconnect') {
|
|
627
1072
|
session.log.push(`resize: reconnecting at ${wanted.width}x${wanted.height}`);
|
|
1073
|
+
const viewOnly = session.viewOnly;
|
|
628
1074
|
this.zone.run(() => {
|
|
629
1075
|
this.disconnect(pane);
|
|
630
|
-
this.showDesktop(pane);
|
|
1076
|
+
this.showDesktop(pane, undefined, { viewOnly });
|
|
631
1077
|
});
|
|
632
1078
|
return;
|
|
633
1079
|
}
|
|
@@ -637,8 +1083,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
637
1083
|
session.log.push(`resize: ${size.width}x${size.height} @${size.scale}%`);
|
|
638
1084
|
const before = session.remoteSize;
|
|
639
1085
|
session.remoteSize = size;
|
|
640
|
-
if (session.spec.kind
|
|
1086
|
+
if (session.spec.kind !== 'gnome') {
|
|
641
1087
|
// Windows takes the scale from the monitor layout only when it also has the monitor's physical size (mm).
|
|
1088
|
+
// xrdp gets it too; it can take its session's DPI from it.
|
|
642
1089
|
const mm = (px) => Math.round(px / (size.scale / 100) / 96 * 25.4);
|
|
643
1090
|
session.ui.resize(size.width, size.height, size.scale, mm(size.width), mm(size.height));
|
|
644
1091
|
}
|
|
@@ -646,41 +1093,301 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
646
1093
|
session.ui.resize(size.width, size.height, size.scale);
|
|
647
1094
|
}
|
|
648
1095
|
// grd ignores the scale in the monitor layout: it's set through Mutter (also back to 100%).
|
|
649
|
-
if ((size.scale !== 100 || (before?.scale ?? 100) !== 100) && session.remote && session.spec.id === desktops_1.OWN_DESKTOP) {
|
|
1096
|
+
if ((size.scale !== 100 || (before?.scale ?? 100) !== 100) && session.remote && session.spec.id === desktops_1.OWN_DESKTOP && session.spec.kind === 'gnome') {
|
|
650
1097
|
session.remote.exec(`python3 - ${size.width} ${size.height} ${size.scale / 100}`, SCALE_SCRIPT).then(out => {
|
|
651
1098
|
session.log.push(`scale: ${/^RD_(OK|ERR) (.*)$/m.exec(out)?.[2] ?? out.trim().slice(-120)}`);
|
|
652
1099
|
}, e => session.log.push(`scale: ${e?.message ?? e}`));
|
|
653
1100
|
}
|
|
654
1101
|
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Whether this connection decodes H.264: the setting, the browser (WebCodecs), and no failure on this desktop
|
|
1104
|
+
* since Tabby started.
|
|
1105
|
+
*/
|
|
1106
|
+
async useH264(rdp, session, settings) {
|
|
1107
|
+
if (!settings.h264 || this.h264Failed.has(session.key) || typeof rdp.h264Decoder !== 'function') {
|
|
1108
|
+
return false;
|
|
1109
|
+
}
|
|
1110
|
+
this.h264Support ??= Promise.resolve(rdp.h264Supported()).catch(() => false);
|
|
1111
|
+
return this.h264Support;
|
|
1112
|
+
}
|
|
1113
|
+
isViewOnly(pane) {
|
|
1114
|
+
return !!this.sessions.get(pane)?.viewOnly;
|
|
1115
|
+
}
|
|
1116
|
+
/** View only: the pane's desktop gets no keyboard or mouse input (the picture, clipboard and sound go on). */
|
|
1117
|
+
setViewOnly(pane, viewOnly) {
|
|
1118
|
+
const session = this.sessions.get(pane);
|
|
1119
|
+
if (session) {
|
|
1120
|
+
session.setViewOnly(viewOnly);
|
|
1121
|
+
this.changed$.next();
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* For keys sent from a menu: brings the pane's desktop forward and focuses it, since IronRDP only takes keys
|
|
1126
|
+
* while it has focus. False when it can't take them (not connected, view only, or focus didn't stick).
|
|
1127
|
+
*/
|
|
1128
|
+
async takeKeyboard(pane) {
|
|
1129
|
+
const session = this.sessions.get(pane);
|
|
1130
|
+
if (!session || session.state !== 'connected' || session.viewOnly) {
|
|
1131
|
+
return false;
|
|
1132
|
+
}
|
|
1133
|
+
if (!session.visible) {
|
|
1134
|
+
await this.showDesktop(pane);
|
|
1135
|
+
}
|
|
1136
|
+
if ((0, targets_1.desktopPaneOf)(this.app.activeTab) !== pane) {
|
|
1137
|
+
this.selectPane(pane);
|
|
1138
|
+
}
|
|
1139
|
+
// After a tab switch, the pane shows on Angular's next change detection.
|
|
1140
|
+
for (let i = 0; i < 30; i++) {
|
|
1141
|
+
session.focusDesktop(1);
|
|
1142
|
+
const focused = document.activeElement;
|
|
1143
|
+
if (focused?.tagName === 'IRON-REMOTE-DESKTOP' && session.overlay.contains(focused)) {
|
|
1144
|
+
return true;
|
|
1145
|
+
}
|
|
1146
|
+
await new Promise(resolve => setTimeout(resolve, 16));
|
|
1147
|
+
}
|
|
1148
|
+
session.log.push('keys: the desktop did not take the keyboard');
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Saves the remote frame as a PNG in Downloads (never overwriting) and copies it to the clipboard. The canvas is
|
|
1153
|
+
* at the remote resolution, so this is the full picture whatever the pane's size. Returns the file.
|
|
1154
|
+
*/
|
|
1155
|
+
async saveScreenshot(pane) {
|
|
1156
|
+
const session = this.sessions.get(pane);
|
|
1157
|
+
const canvas = session?.state === 'connected' ? session.canvas() : null;
|
|
1158
|
+
if (!session || !canvas) {
|
|
1159
|
+
return null;
|
|
1160
|
+
}
|
|
1161
|
+
try {
|
|
1162
|
+
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
|
1163
|
+
if (!blob) {
|
|
1164
|
+
throw new Error('the picture could not be encoded');
|
|
1165
|
+
}
|
|
1166
|
+
const png = Buffer.from(await blob.arrayBuffer());
|
|
1167
|
+
const dir = path.join(os.homedir(), 'Downloads');
|
|
1168
|
+
const now = new Date();
|
|
1169
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
1170
|
+
const stamp = `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())} at ${two(now.getHours())}.${two(now.getMinutes())}.${two(now.getSeconds())}`;
|
|
1171
|
+
const name = `Screenshot ${session.spec.name} ${stamp}`.replace(/[\\/:*?"<>|]/g, '-');
|
|
1172
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
1173
|
+
const file = (0, fileTransfer_1.uniquePath)(path.join(dir, `${name}.png`));
|
|
1174
|
+
await fs.promises.writeFile(file, png, { flag: 'wx' });
|
|
1175
|
+
session.log.push(`screenshot: saved ${file}`);
|
|
1176
|
+
let copied = true;
|
|
1177
|
+
try {
|
|
1178
|
+
const { clipboard, nativeImage } = require('electron');
|
|
1179
|
+
clipboard.writeImage(nativeImage.createFromBuffer(png));
|
|
1180
|
+
}
|
|
1181
|
+
catch (e) {
|
|
1182
|
+
copied = false;
|
|
1183
|
+
session.log.push(`screenshot: clipboard: ${e?.message ?? e}`);
|
|
1184
|
+
}
|
|
1185
|
+
session.files?.toast(`Screenshot saved to ${path.basename(dir)}${copied ? ' and copied to the clipboard' : ''}`, [{
|
|
1186
|
+
label: process.platform === 'darwin' ? 'Show in Finder' : 'Show in folder',
|
|
1187
|
+
run: () => require('electron').shell.showItemInFolder(file),
|
|
1188
|
+
}]);
|
|
1189
|
+
return file;
|
|
1190
|
+
}
|
|
1191
|
+
catch (e) {
|
|
1192
|
+
session.log.push(`screenshot: ${e?.message ?? e}`);
|
|
1193
|
+
this.notifications.error(`Couldn't save the screenshot: ${e?.message ?? e}`);
|
|
1194
|
+
return null;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Which desktop a pane shows, for a moment (connected, shown, focused), like a TV naming its input. By default only
|
|
1199
|
+
* where nothing else says: in a split, whose pane headers name the SSH connection, and for a desktop other than
|
|
1200
|
+
* that connection's own (behind the host, or where ssh was typed on to).
|
|
1201
|
+
*/
|
|
1202
|
+
label(pane, session, always = false) {
|
|
1203
|
+
const target = session.remote;
|
|
1204
|
+
const osd = this.settings().osd;
|
|
1205
|
+
if (session.state !== 'connected' || !session.visible || !target || osd.show === 'off' && !always) {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const inSplit = pane.parent instanceof tabby_core_1.SplitTabComponent && pane.parent.getAllTabs().length > 1;
|
|
1209
|
+
const own = session.spec.id === desktops_1.OWN_DESKTOP;
|
|
1210
|
+
if (osd.show === 'auto' && !always && !inSplit && own && !target.via) {
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
// A desktop named after its host already says where it is ("buildhost desktop (xrdp)").
|
|
1214
|
+
const through = own ? target.via : target.direct || session.spec.name.startsWith(target.label) ? null : target.label;
|
|
1215
|
+
const size = session.remoteSize ? `${session.remoteSize.width}×${session.remoteSize.height}` : '';
|
|
1216
|
+
session.flashLabel(own ? target.label : session.spec.name, [through ? `via ${through}` : '', size].filter(Boolean).join(' · '), osd);
|
|
1217
|
+
}
|
|
1218
|
+
/** Shows the on-screen display on every desktop showing now, whatever `show` says (to try the settings). */
|
|
1219
|
+
showOsdEverywhere() {
|
|
1220
|
+
let shown = 0;
|
|
1221
|
+
for (const [pane, session] of this.sessions) {
|
|
1222
|
+
if (session.state === 'connected' && session.visible) {
|
|
1223
|
+
this.label(pane, session, true);
|
|
1224
|
+
shown++;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return shown;
|
|
1228
|
+
}
|
|
1229
|
+
/** The one-time tip (see RemoteDesktopHelp.tipOnce), once a desktop is connected and showing. */
|
|
1230
|
+
tip(pane, session) {
|
|
1231
|
+
if (session.state === 'connected' && session.visible) {
|
|
1232
|
+
this.help.tipOnce(session.overlay, this.hasConsole(pane), () => session.visible && session.focusDesktop());
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
/** The open desktops, for the settings page (and bug reports): where they are, and their logs. */
|
|
1236
|
+
sessionSummaries() {
|
|
1237
|
+
this.prune();
|
|
1238
|
+
return [...this.sessions.values()].map(session => ({
|
|
1239
|
+
name: session.spec.name,
|
|
1240
|
+
where: session.remote && !session.remote.direct ? session.remote.label : null,
|
|
1241
|
+
state: session.state === 'connected' ? (session.visible ? 'connected, showing' : 'connected, in the background')
|
|
1242
|
+
: session.state === 'connecting' ? 'connecting' : 'not connected',
|
|
1243
|
+
log: [...session.log],
|
|
1244
|
+
}));
|
|
1245
|
+
}
|
|
1246
|
+
/** The certificates remembered for desktops (`remoteDesktop.trustedCertificates`). */
|
|
1247
|
+
trustedCertificates() {
|
|
1248
|
+
const list = this.config.store.remoteDesktop?.trustedCertificates;
|
|
1249
|
+
return (Array.isArray(list) ? list : [])
|
|
1250
|
+
.filter((e) => typeof e?.desktop === 'string' && typeof e?.sha256 === 'string')
|
|
1251
|
+
.map((e) => ({ desktop: e.desktop, sha256: (0, rdcleanpath_1.normalizeFingerprint)(e.sha256) || e.sha256 }));
|
|
1252
|
+
}
|
|
1253
|
+
/** Forgets one remembered certificate (by session key); the next connection remembers the one it meets. */
|
|
1254
|
+
forgetCertificate(key) {
|
|
1255
|
+
const store = this.config.store.remoteDesktop;
|
|
1256
|
+
store.trustedCertificates = (Array.isArray(store.trustedCertificates) ? store.trustedCertificates : []).filter((e) => e?.desktop !== key);
|
|
1257
|
+
this.config.save();
|
|
1258
|
+
this.changed$.next();
|
|
1259
|
+
}
|
|
1260
|
+
/** The split tab holding the pane, if it has more than one pane. */
|
|
1261
|
+
splitOf(pane) {
|
|
1262
|
+
const split = this.app.tabs.find(t => t instanceof tabby_core_1.SplitTabComponent && t.getAllTabs().includes(pane));
|
|
1263
|
+
return split && split.getAllTabs().length > 1 ? split : null;
|
|
1264
|
+
}
|
|
1265
|
+
/** The other connected desktops in the pane's tab that take input (not view only), with their sessions. */
|
|
1266
|
+
othersInTab(pane) {
|
|
1267
|
+
const split = this.splitOf(pane);
|
|
1268
|
+
return split ? split.getAllTabs().filter(p => p !== pane).map(p => ({ pane: p, session: this.sessions.get(p) }))
|
|
1269
|
+
.filter(({ session }) => session?.state === 'connected' && !session.viewOnly && session.ui) : [];
|
|
1270
|
+
}
|
|
1271
|
+
/** How many desktops in the pane's tab would get a paste or typing (itself included), when that's more than one. */
|
|
1272
|
+
desktopsInTab(pane) {
|
|
1273
|
+
const own = this.sessions.get(pane);
|
|
1274
|
+
return this.othersInTab(pane).length + (own?.state === 'connected' && !own.viewOnly ? 1 : 0);
|
|
1275
|
+
}
|
|
1276
|
+
isBroadcast(pane) {
|
|
1277
|
+
const split = this.splitOf(pane);
|
|
1278
|
+
return !!split && this.broadcast.has(split);
|
|
1279
|
+
}
|
|
1280
|
+
/** Keys typed on one desktop of the pane's tab go to all of them, or no longer; each shows it while it does. */
|
|
1281
|
+
setBroadcast(pane, on) {
|
|
1282
|
+
const split = this.splitOf(pane);
|
|
1283
|
+
if (!split) {
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
on ? this.broadcast.add(split) : this.broadcast.delete(split);
|
|
1287
|
+
for (const p of split.getAllTabs()) {
|
|
1288
|
+
const session = this.sessions.get(p);
|
|
1289
|
+
session?.overlay.classList.toggle('trd-broadcast-on', on);
|
|
1290
|
+
session?.log.push(`typing into all desktops: ${on ? 'on' : 'off'}`);
|
|
1291
|
+
}
|
|
1292
|
+
this.changed$.next();
|
|
1293
|
+
}
|
|
1294
|
+
/** A key event for the other desktops of a tab typing into all (see DesktopKeyboard). */
|
|
1295
|
+
broadcastKey(pane, type, init) {
|
|
1296
|
+
if (!this.isBroadcast(pane)) {
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
for (const { session } of this.othersInTab(pane)) {
|
|
1300
|
+
try {
|
|
1301
|
+
session.ui.sendKeyboardEvent(new KeyboardEvent(type, { ...init, cancelable: true }));
|
|
1302
|
+
}
|
|
1303
|
+
catch (e) {
|
|
1304
|
+
session.log.push(`typing into all desktops: ${e?.message ?? e}`);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Pastes on every connected desktop in the pane's tab: files copied here are offered to each first (see
|
|
1310
|
+
* FileTransfer.pasteClipboardFiles); text and pictures are on their clipboards already. Then Ctrl+V on each, in
|
|
1311
|
+
* whatever window is active there.
|
|
1312
|
+
*/
|
|
1313
|
+
pasteToAll(pane) {
|
|
1314
|
+
const targets = [...this.sessions.get(pane) ? [{ pane, session: this.sessions.get(pane) }] : [], ...this.othersInTab(pane)]
|
|
1315
|
+
.filter(({ session }) => session.state === 'connected' && !session.viewOnly && session.ui);
|
|
1316
|
+
for (const { session } of targets) {
|
|
1317
|
+
// Each pastes once it has taken in the files (when there are files to offer), however long that takes it.
|
|
1318
|
+
const offered = session.files?.pasteClipboardFiles() ?? false;
|
|
1319
|
+
(offered ? session.files.offerAnswered() : Promise.resolve(true)).then(ok => {
|
|
1320
|
+
if (!ok) {
|
|
1321
|
+
session.log.push('paste to all desktops: the remote refused the files');
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
try {
|
|
1325
|
+
session.ui.ctrlV();
|
|
1326
|
+
session.log.push('keys: Ctrl+V (paste to all desktops)');
|
|
1327
|
+
}
|
|
1328
|
+
catch (e) {
|
|
1329
|
+
session.log.push(`paste to all desktops: ${e?.message ?? e}`);
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
return targets.length;
|
|
1334
|
+
}
|
|
1335
|
+
/** Resolves when the pane's desktop has taken in the files just offered to it, so a paste then gets them. */
|
|
1336
|
+
pasteReady(pane) {
|
|
1337
|
+
return this.sessions.get(pane)?.files?.offerAnswered() ?? Promise.resolve(true);
|
|
1338
|
+
}
|
|
1339
|
+
/** ⌘V on the pane's desktop: offers files copied here for it (see FileTransfer.pasteClipboardFiles). */
|
|
1340
|
+
pasteClipboardFiles(pane) {
|
|
1341
|
+
const session = this.sessions.get(pane);
|
|
1342
|
+
return !!session?.files && this.isConnected(pane) && !session.viewOnly && session.files.pasteClipboardFiles();
|
|
1343
|
+
}
|
|
655
1344
|
/** Status log of the pane's desktop session (for tests and troubleshooting). */
|
|
656
1345
|
logOf(pane) {
|
|
657
1346
|
return this.sessions.get(pane)?.log ?? [];
|
|
658
1347
|
}
|
|
659
1348
|
loadIronRDP() {
|
|
660
1349
|
this.ironrdp ??= (async () => {
|
|
661
|
-
|
|
662
|
-
|
|
1350
|
+
// The WebAssembly ships as its own file (not inlined in the bundle). It is read here and handed to init,
|
|
1351
|
+
// rather than fetched by the bundle from next to itself: fetch() of file:// URLs is up to the Electron
|
|
1352
|
+
// build (a fuse turns it off), and a plain file path needs no URL escaping for spaces or drive letters.
|
|
1353
|
+
const [rdp, wasm] = await Promise.all([
|
|
1354
|
+
importESM(vendor('iron-remote-desktop.js')).then(() => importESM(vendor('iron-remote-desktop-rdp.js'))),
|
|
1355
|
+
fs.promises.readFile(vendorFile('ironrdp_web_bg.wasm')),
|
|
1356
|
+
]);
|
|
663
1357
|
// Troubleshooting: localStorage.trdLogLevel = 'DEBUG' (or TRACE), then restart Tabby.
|
|
664
1358
|
let level = 'INFO';
|
|
665
1359
|
try {
|
|
666
1360
|
level = localStorage.getItem('trdLogLevel') || level;
|
|
667
1361
|
}
|
|
668
1362
|
catch { }
|
|
669
|
-
await rdp.init(level);
|
|
1363
|
+
await rdp.init(level, wasm);
|
|
670
1364
|
return rdp;
|
|
671
1365
|
})();
|
|
672
1366
|
this.ironrdp.catch(() => { this.ironrdp = null; });
|
|
673
1367
|
return this.ironrdp;
|
|
674
1368
|
}
|
|
675
1369
|
/**
|
|
676
|
-
* Where to connect and as whom. The host's own desktop: the setup script (grd, generated credentials)
|
|
677
|
-
* Others: the saved account, or the sign-in form (
|
|
1370
|
+
* Where to connect and as whom. The host's own desktop: the setup script (grd, generated credentials), or, where
|
|
1371
|
+
* it finds xrdp or Windows instead of GNOME, as below. Others: the saved account, or the sign-in form (retryError: after a
|
|
1372
|
+
* failed sign-in).
|
|
678
1373
|
*/
|
|
679
1374
|
async endpointFor(target, spec, session, retryError) {
|
|
680
|
-
|
|
1375
|
+
// A Windows host is known from an earlier connect: no setup to run there (it would only fail again).
|
|
1376
|
+
if (spec.id === desktops_1.OWN_DESKTOP && !retryError && this.ownDesktops.get(target.key)?.kind !== 'windows') {
|
|
681
1377
|
session.status(`Preparing the remote desktop on ${target.label}…`);
|
|
682
1378
|
const endpoint = await (0, remoteSetup_1.prepareRemoteDesktop)(target, this.settings().desk, this.config.store.remoteDesktop?.sessionBackend);
|
|
683
|
-
|
|
1379
|
+
this.ownDesktops.set(target.key, { kind: endpoint.kind, xrdpPort: endpoint.xrdpPort, xrdpUser: endpoint.xrdpUser });
|
|
1380
|
+
// Only the setup knows which desktop the host has; the session follows it (graphics, sign-in, resizing).
|
|
1381
|
+
spec.kind = endpoint.kind;
|
|
1382
|
+
if (endpoint.kind === 'windows') {
|
|
1383
|
+
session.log.push('setup: the SSH host runs Windows; its own desktop is its RDP server');
|
|
1384
|
+
}
|
|
1385
|
+
if (endpoint.kind === 'gnome') {
|
|
1386
|
+
return { host: '127.0.0.1', port: endpoint.port, credentials: { username: endpoint.username, password: endpoint.password }, remember: false, certificate: endpoint.certificate };
|
|
1387
|
+
}
|
|
1388
|
+
spec.host = '127.0.0.1';
|
|
1389
|
+
spec.port = endpoint.port;
|
|
1390
|
+
spec.username ??= endpoint.username;
|
|
684
1391
|
}
|
|
685
1392
|
const saved = retryError ? null : await (0, signin_1.loadCredentials)(session.key);
|
|
686
1393
|
if (saved) {
|
|
@@ -688,7 +1395,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
688
1395
|
}
|
|
689
1396
|
session.status('');
|
|
690
1397
|
const asked = (0, signin_1.askCredentials)(session.overlay, {
|
|
691
|
-
|
|
1398
|
+
// The host's own desktops (its own, or its xrdp besides GNOME) and direct ones need no "via".
|
|
1399
|
+
title: spec.id === desktops_1.OWN_DESKTOP || target.direct || spec.kind === 'xrdp' && spec.host === '127.0.0.1' ? `Sign in to ${spec.name}` : `Sign in to ${spec.name} (via ${target.label})`,
|
|
692
1400
|
username: spec.username,
|
|
693
1401
|
error: retryError,
|
|
694
1402
|
canRemember: true,
|
|
@@ -702,28 +1410,71 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
702
1410
|
async connect(pane, target, spec, session) {
|
|
703
1411
|
const alive = () => this.sessions.get(pane) === session;
|
|
704
1412
|
let retryError;
|
|
1413
|
+
// Automatic reconnects never start a desktop (`wake`): it may have been shut down on purpose.
|
|
1414
|
+
const automatic = this.reconnects.has(pane);
|
|
1415
|
+
// A desktop that was just started can answer before it takes connections: a few more tries, same account.
|
|
1416
|
+
let woken = false;
|
|
1417
|
+
let startupRetries = 0;
|
|
1418
|
+
let endpoint = null;
|
|
705
1419
|
try {
|
|
706
|
-
|
|
1420
|
+
if (spec.wake) {
|
|
1421
|
+
const woke = await this.wakeIfDown(pane, target, spec, session, automatic);
|
|
1422
|
+
if (woke === null) {
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
woken = woke;
|
|
1426
|
+
}
|
|
1427
|
+
// Signing in to a Windows account can take a few tries; a GNOME desktop the plugin set up can't fail that way.
|
|
707
1428
|
for (let attempt = 0;; attempt++) {
|
|
708
|
-
const [
|
|
1429
|
+
const [asked, rdp] = await Promise.all([endpoint ?? this.endpointFor(target, spec, session, retryError), this.loadIronRDP()]);
|
|
709
1430
|
if (!alive()) {
|
|
710
1431
|
return;
|
|
711
1432
|
}
|
|
712
|
-
if (!
|
|
1433
|
+
if (!asked) {
|
|
713
1434
|
session.state = 'ended';
|
|
714
1435
|
this.changed$.next();
|
|
715
1436
|
this.cancelReconnect(pane);
|
|
716
1437
|
session.status('Sign-in cancelled.', [{ label: 'Sign in', run: () => this.reopen(pane, spec) }]);
|
|
717
1438
|
return;
|
|
718
1439
|
}
|
|
1440
|
+
endpoint = asked;
|
|
719
1441
|
session.remote = target;
|
|
720
|
-
session.
|
|
721
|
-
|
|
722
|
-
|
|
1442
|
+
session.pinnedCertificate = asked.certificate ?? null;
|
|
1443
|
+
session.proxy ??= await (0, rdcleanpath_1.startRDCleanPathProxy)(() => target.openTcp(asked.host, asked.port), fingerprint => this.checkCertificate(session, fingerprint), m => session.log.push(m), { autologon: spec.kind === 'xrdp' });
|
|
1444
|
+
const outcome = await this.run(pane, target, spec, session, rdp, asked);
|
|
1445
|
+
if (outcome.certificate && alive()) {
|
|
1446
|
+
// A changed certificate, now trusted: the same account again, without asking for it again.
|
|
1447
|
+
if (await this.refusedCertificate(pane, target, spec, session, asked, outcome.certificate)) {
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
if (outcome.signInFailed && this.signsIn(spec) && attempt < 5 && alive()) {
|
|
723
1453
|
await (0, signin_1.forgetCredentials)(session.key);
|
|
724
1454
|
retryError = outcome.signInFailed;
|
|
1455
|
+
endpoint = null;
|
|
725
1456
|
continue;
|
|
726
1457
|
}
|
|
1458
|
+
if (spec.wake && !outcome.connected && !outcome.signInFailed && alive()) {
|
|
1459
|
+
if (woken && startupRetries < 3) {
|
|
1460
|
+
startupRetries++;
|
|
1461
|
+
session.log.push(`wake: not taking connections yet: ${outcome.error ?? outcome.reason ?? 'unknown error'}`);
|
|
1462
|
+
session.status(`${spec.name} is still starting…`);
|
|
1463
|
+
await this.zone.runOutsideAngular(() => new Promise(resolve => setTimeout(resolve, 5000)));
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
// It answered before, but not now: it may have been shut down since.
|
|
1467
|
+
if (!woken && !automatic) {
|
|
1468
|
+
const woke = await this.wakeIfDown(pane, target, spec, session, false);
|
|
1469
|
+
if (woke === null) {
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
if (woke) {
|
|
1473
|
+
woken = true;
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
727
1478
|
if (alive()) {
|
|
728
1479
|
this.afterEnd(pane, target, spec, session, outcome);
|
|
729
1480
|
}
|
|
@@ -736,13 +1487,153 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
736
1487
|
}
|
|
737
1488
|
}
|
|
738
1489
|
}
|
|
1490
|
+
/**
|
|
1491
|
+
* The proxy's check of the server's certificate, before any credentials go out. The host's own desktop must show
|
|
1492
|
+
* the certificate its setup reported. Others (behind a host, a Windows host's own, a remote desktop tab's) are
|
|
1493
|
+
* trusted on first use: remembered silently the first time (most connections already run inside SSH, and a prompt
|
|
1494
|
+
* on every new desktop would only teach clicking it away), and refused when it changes, until the new one is
|
|
1495
|
+
* trusted (see refusedCertificate). Remembered by session key, like saved accounts.
|
|
1496
|
+
*/
|
|
1497
|
+
checkCertificate(session, fingerprint) {
|
|
1498
|
+
session.certificateProblem = null;
|
|
1499
|
+
// The host's own GNOME desktop; its own xrdp makes its certificate itself, so that one is trusted on first use.
|
|
1500
|
+
if (session.spec.id === desktops_1.OWN_DESKTOP && session.spec.kind === 'gnome') {
|
|
1501
|
+
const expected = session.pinnedCertificate ?? '';
|
|
1502
|
+
if (fingerprint !== expected) {
|
|
1503
|
+
session.certificateProblem = { expected, actual: fingerprint, pinned: true };
|
|
1504
|
+
throw new Error(`certificate SHA-256 ${fingerprint} is not the one set up (${expected || 'none'})`);
|
|
1505
|
+
}
|
|
1506
|
+
session.log.push(`certificate: SHA-256 ${fingerprint}, as set up`);
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
const known = this.trustedCertificate(session.key);
|
|
1510
|
+
if (!known) {
|
|
1511
|
+
this.trustCertificate(session.key, fingerprint);
|
|
1512
|
+
session.log.push(`certificate: SHA-256 ${fingerprint}, remembered (first connection)`);
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
if (fingerprint !== known) {
|
|
1516
|
+
session.certificateProblem = { expected: known, actual: fingerprint, pinned: false };
|
|
1517
|
+
throw new Error(`certificate SHA-256 ${fingerprint} is not the one remembered (${known})`);
|
|
1518
|
+
}
|
|
1519
|
+
session.log.push(`certificate: SHA-256 ${fingerprint}, as remembered`);
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* After the proxy refused a certificate; nothing was sent to that server. The host's own desktop: an error, since
|
|
1523
|
+
* the plugin made that certificate itself. Others: both fingerprints, to trust the new certificate or not. Either
|
|
1524
|
+
* way, automatic reconnecting stops here. Resolves true to connect again (the new certificate is then trusted).
|
|
1525
|
+
*/
|
|
1526
|
+
async refusedCertificate(pane, target, spec, session, endpoint, problem) {
|
|
1527
|
+
this.cancelReconnect(pane);
|
|
1528
|
+
const alive = () => this.sessions.get(pane) === session;
|
|
1529
|
+
const stop = (message) => {
|
|
1530
|
+
session.state = 'ended';
|
|
1531
|
+
this.changed$.next();
|
|
1532
|
+
session.status(message, [{ label: 'Try again', run: () => this.reopen(pane, spec) }], true);
|
|
1533
|
+
};
|
|
1534
|
+
const fingerprints = `${problem.pinned ? 'Expected' : 'Remembered'} (SHA-256):\n${showFingerprint(problem.expected)}\n\nNow:\n${showFingerprint(problem.actual)}`;
|
|
1535
|
+
if (problem.pinned) {
|
|
1536
|
+
stop(`The remote desktop on ${target.label} answered with a certificate other than the one set up for it. ` +
|
|
1537
|
+
`The connection was stopped before signing in.\n\n${fingerprints}\n\n` +
|
|
1538
|
+
`Something other than GNOME Remote Desktop may be listening on port ${endpoint.port}.`);
|
|
1539
|
+
return false;
|
|
1540
|
+
}
|
|
1541
|
+
if (!session.visible) {
|
|
1542
|
+
this.notifications.notice(`The certificate of ${spec.name}${target.direct ? '' : ` (via ${target.label})`} has changed. Open that desktop to decide.`);
|
|
1543
|
+
}
|
|
1544
|
+
const trusted = await new Promise(resolve => {
|
|
1545
|
+
session.status(`The certificate of ${spec.name} has changed since it was last used. Nothing has been sent to it.\n\n` +
|
|
1546
|
+
`${fingerprints}\n\nReinstalling the machine or renewing its certificate changes it. If neither happened, ` +
|
|
1547
|
+
`something else may be answering at ${spec.host}:${spec.port}.`, [
|
|
1548
|
+
{ label: 'Trust the new certificate', run: () => resolve(true) },
|
|
1549
|
+
{ label: 'Cancel', run: () => resolve(false) },
|
|
1550
|
+
], true);
|
|
1551
|
+
session.disposed.then(() => resolve(false));
|
|
1552
|
+
});
|
|
1553
|
+
if (!alive()) {
|
|
1554
|
+
return false;
|
|
1555
|
+
}
|
|
1556
|
+
if (!trusted) {
|
|
1557
|
+
stop('Not connected: the new certificate was not trusted.');
|
|
1558
|
+
return false;
|
|
1559
|
+
}
|
|
1560
|
+
this.trustCertificate(session.key, problem.actual);
|
|
1561
|
+
session.log.push(`certificate: SHA-256 ${problem.actual}, trusted instead of ${problem.expected}`);
|
|
1562
|
+
return true;
|
|
1563
|
+
}
|
|
1564
|
+
/** The certificate remembered for a desktop behind a host (`remoteDesktop.trustedCertificates`), if any. */
|
|
1565
|
+
trustedCertificate(key) {
|
|
1566
|
+
const list = this.config.store.remoteDesktop?.trustedCertificates;
|
|
1567
|
+
const value = (Array.isArray(list) ? list : []).find((e) => e?.desktop === key)?.sha256;
|
|
1568
|
+
return typeof value === 'string' ? (0, rdcleanpath_1.normalizeFingerprint)(value) || null : null;
|
|
1569
|
+
}
|
|
1570
|
+
trustCertificate(key, fingerprint) {
|
|
1571
|
+
const store = this.config.store.remoteDesktop;
|
|
1572
|
+
const others = (Array.isArray(store.trustedCertificates) ? store.trustedCertificates : []).filter((e) => e?.desktop !== key);
|
|
1573
|
+
store.trustedCertificates = [...others, { desktop: key, sha256: fingerprint }];
|
|
1574
|
+
this.config.save();
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Forgets the certificates remembered for a desktop behind any SSH host (keys ending in `#<id>`, not a direct
|
|
1578
|
+
* one's `rdp#<id>`), or with `direct`, for that direct desktop; doesn't save.
|
|
1579
|
+
*/
|
|
1580
|
+
forgetCertificatesFor(desktopId, direct = false) {
|
|
1581
|
+
const store = this.config.store.remoteDesktop;
|
|
1582
|
+
const list = Array.isArray(store.trustedCertificates) ? store.trustedCertificates : [];
|
|
1583
|
+
const forget = (key) => direct ? key === `${desktops_1.DIRECT_KEY}#${desktopId}` : key.endsWith(`#${desktopId}`) && !key.startsWith(`${desktops_1.DIRECT_KEY}#`);
|
|
1584
|
+
store.trustedCertificates = list.filter((e) => !forget(String(e?.desktop ?? '')));
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* For a desktop with `wake`: when its RDP server doesn't answer (probed from the SSH host), starts it and waits
|
|
1588
|
+
* until it does. True when it had to be started, false when it was up, null when the wait was cancelled (the layer
|
|
1589
|
+
* says so). Throws when it couldn't be started, didn't come up in time, or is down during an automatic reconnect.
|
|
1590
|
+
*/
|
|
1591
|
+
async wakeIfDown(pane, target, spec, session, automatic) {
|
|
1592
|
+
const alive = () => this.sessions.get(pane) === session;
|
|
1593
|
+
session.status(`Connecting to ${spec.name} via ${target.label}…`);
|
|
1594
|
+
if (await (0, wake_1.rdpAnswers)(target, spec.host, spec.port)) {
|
|
1595
|
+
return false;
|
|
1596
|
+
}
|
|
1597
|
+
if (!alive()) {
|
|
1598
|
+
return null;
|
|
1599
|
+
}
|
|
1600
|
+
if (automatic) {
|
|
1601
|
+
throw new Error(`${spec.name} doesn't answer; it may have been shut down`);
|
|
1602
|
+
}
|
|
1603
|
+
session.status(`Starting ${spec.name}…`);
|
|
1604
|
+
session.log.push(`wake: ${await (0, wake_1.wakeDesktop)(target, spec.wake)}`);
|
|
1605
|
+
let cancelled = false;
|
|
1606
|
+
const cancel = {
|
|
1607
|
+
label: 'Cancel',
|
|
1608
|
+
run: () => {
|
|
1609
|
+
cancelled = true;
|
|
1610
|
+
session.state = 'ended';
|
|
1611
|
+
this.changed$.next();
|
|
1612
|
+
this.cancelReconnect(pane);
|
|
1613
|
+
session.status(`Stopped waiting for ${spec.name}.`, [{ label: 'Try again', run: () => this.reopen(pane, spec) }]);
|
|
1614
|
+
},
|
|
1615
|
+
};
|
|
1616
|
+
const started = Date.now();
|
|
1617
|
+
const progress = () => session.status(`Starting ${spec.name}…\nWaiting for it to answer: ${Math.round((Date.now() - started) / 1000)} s`, [cancel]);
|
|
1618
|
+
progress();
|
|
1619
|
+
const up = await this.zone.runOutsideAngular(() => (0, wake_1.waitForRdp)(target, spec.host, spec.port, WAKE_TIMEOUT_MS, () => cancelled || !alive(), progress));
|
|
1620
|
+
if (up) {
|
|
1621
|
+
session.log.push(`wake: answered after ${Math.round((Date.now() - started) / 1000)} s`);
|
|
1622
|
+
return true;
|
|
1623
|
+
}
|
|
1624
|
+
if (cancelled || !alive()) {
|
|
1625
|
+
return null;
|
|
1626
|
+
}
|
|
1627
|
+
throw new Error(`${spec.name} didn't answer within ${WAKE_TIMEOUT_MS / 60000} minutes of starting it`);
|
|
1628
|
+
}
|
|
739
1629
|
/** One RDP connection attempt, until it ends. */
|
|
740
1630
|
async run(pane, target, spec, session, rdp, endpoint) {
|
|
741
1631
|
const credentials = endpoint.credentials;
|
|
742
1632
|
const alive = () => this.sessions.get(pane) === session;
|
|
1633
|
+
session.certificateProblem = null;
|
|
743
1634
|
const el = document.createElement('iron-remote-desktop');
|
|
744
1635
|
try {
|
|
745
|
-
el.setAttribute('scale', 'fit');
|
|
1636
|
+
el.setAttribute('scale', this.screenScale() === 3 ? 'real' : 'fit');
|
|
746
1637
|
el.setAttribute('flexcenter', 'true');
|
|
747
1638
|
el.module = rdp.Backend;
|
|
748
1639
|
const ready = new Promise(resolve => el.addEventListener('ready', (e) => resolve(e.detail.irgUserInteraction), { once: true }));
|
|
@@ -767,33 +1658,62 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
767
1658
|
const { width, height } = size;
|
|
768
1659
|
// DOMAIN\user or user@domain go to the server as typed; only the former needs splitting.
|
|
769
1660
|
const [domain, username] = /^([^\\]+)\\(.+)$/.exec(credentials.username)?.slice(1) ?? [spec.domain ?? '', credentials.username];
|
|
770
|
-
session.status(spec.id === desktops_1.OWN_DESKTOP ? `Connecting to ${target.label}…`
|
|
1661
|
+
session.status(spec.id === desktops_1.OWN_DESKTOP ? `Connecting to ${target.label}…`
|
|
1662
|
+
: target.direct ? `Connecting to ${spec.name}…` : `Connecting to ${spec.name} via ${target.label}…`);
|
|
771
1663
|
const config = session.ui.configBuilder()
|
|
772
1664
|
.withUsername(username)
|
|
773
1665
|
.withPassword(credentials.password)
|
|
774
|
-
.withDestination(`${spec.id === desktops_1.OWN_DESKTOP ? target.label : endpoint.host}:${endpoint.port}`)
|
|
1666
|
+
.withDestination(`${spec.id === desktops_1.OWN_DESKTOP ? target.hostname ?? target.label : endpoint.host}:${endpoint.port}`)
|
|
775
1667
|
.withProxyAddress(session.proxy.url)
|
|
776
1668
|
.withServerDomain(domain)
|
|
777
1669
|
.withAuthToken(session.proxy.token)
|
|
778
1670
|
.withDesktopSize({ width, height })
|
|
779
1671
|
// Display control lets the remote monitor follow the pane (live resize, remote scaling).
|
|
780
1672
|
.withExtension(rdp.displayControl(true));
|
|
781
|
-
//
|
|
782
|
-
//
|
|
1673
|
+
// H.264 in the graphics pipeline, decoded by the browser (hardware-accelerated where it can be). A decoder
|
|
1674
|
+
// failure ends the connection, which then reconnects without H.264 (see below). Not for xrdp: it encodes
|
|
1675
|
+
// H.264 only in some builds, and without it bitmaps do better there than the pipeline.
|
|
1676
|
+
session.h264?.close();
|
|
1677
|
+
session.h264 = null;
|
|
1678
|
+
if (spec.kind !== 'xrdp' && typeof rdp.graphicsPipeline === 'function' && await this.useH264(rdp, session, settings)) {
|
|
1679
|
+
session.h264 = new rdp.WebCodecsH264Decoder({ onFailure: (reason) => session.log.push(`h264: failed: ${reason}`) });
|
|
1680
|
+
config.withExtension(rdp.h264Decoder(session.h264));
|
|
1681
|
+
}
|
|
1682
|
+
// GNOME Remote Desktop requires the graphics pipeline. Windows (and xrdp) do better with bitmaps than with
|
|
1683
|
+
// the pipeline without H.264; with H.264, Windows streams what changes a lot (video) as video. A build
|
|
1684
|
+
// without the switch decides by itself.
|
|
783
1685
|
if (typeof rdp.graphicsPipeline === 'function') {
|
|
784
|
-
|
|
1686
|
+
const pipeline = spec.kind === 'gnome' || !!session.h264;
|
|
1687
|
+
config.withExtension(rdp.graphicsPipeline(pipeline));
|
|
1688
|
+
session.graphics = pipeline ? `graphics pipeline${session.h264 ? ' with H.264' : ''}` : 'bitmaps';
|
|
1689
|
+
}
|
|
1690
|
+
else {
|
|
1691
|
+
session.graphics = 'automatic graphics';
|
|
785
1692
|
}
|
|
1693
|
+
session.log.push(`graphics: ${session.graphics}`);
|
|
786
1694
|
if (settings.sound) {
|
|
787
1695
|
session.audio?.close();
|
|
788
1696
|
session.audio = new audio_1.AudioPlayer();
|
|
789
1697
|
config.withExtension(rdp.audioPlayback(session.audio.callback));
|
|
790
1698
|
}
|
|
1699
|
+
session.mic?.close();
|
|
1700
|
+
session.mic = null;
|
|
1701
|
+
if (settings.microphone && typeof rdp.audioInput === 'function') {
|
|
1702
|
+
const ui = session.ui;
|
|
1703
|
+
const mic = new microphone_1.Microphone(pcm => ui.invokeExtension(rdp.audioInputData(pcm)), () => session.overlay.classList.toggle('trd-mic-on', mic.capturing), m => session.log.push(m), m => this.zone.run(() => this.notifications.error(m)));
|
|
1704
|
+
session.mic = mic;
|
|
1705
|
+
config.withExtension(rdp.audioInput(mic.callback));
|
|
1706
|
+
}
|
|
791
1707
|
const built = config.build();
|
|
792
1708
|
let info;
|
|
793
1709
|
try {
|
|
794
1710
|
info = await session.ui.connect(built);
|
|
795
1711
|
}
|
|
796
1712
|
catch (e) {
|
|
1713
|
+
// The proxy refused the server's certificate, before CredSSP (see checkCertificate).
|
|
1714
|
+
if (session.certificateProblem) {
|
|
1715
|
+
return { connected: false, certificate: session.certificateProblem };
|
|
1716
|
+
}
|
|
797
1717
|
// IronErrorKind: 1 WrongPassword, 2 LogonFailure.
|
|
798
1718
|
const kind = typeof e?.kind === 'function' ? e.kind() : undefined;
|
|
799
1719
|
const detail = typeof e?.backtrace === 'function' ? e.backtrace() : (e?.message ?? String(e));
|
|
@@ -801,7 +1721,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
801
1721
|
session.log.push(`sign-in failed: ${detail.split('\n')[0]}`);
|
|
802
1722
|
return { connected: false, signInFailed: kind === 1 ? 'Wrong user name or password.' : 'The sign-in was refused.' };
|
|
803
1723
|
}
|
|
804
|
-
throw new Error(detail);
|
|
1724
|
+
throw new Error(session.proxy?.failure ?? detail);
|
|
805
1725
|
}
|
|
806
1726
|
if (!alive()) {
|
|
807
1727
|
return { connected: false };
|
|
@@ -815,7 +1735,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
815
1735
|
}
|
|
816
1736
|
this.watchSize(pane, session);
|
|
817
1737
|
session.state = 'connected';
|
|
1738
|
+
session.log.push(`connected: ${width}x${height}`);
|
|
818
1739
|
session.status('');
|
|
1740
|
+
this.tip(pane, session);
|
|
1741
|
+
this.label(pane, session);
|
|
1742
|
+
this.syncIndicator(session);
|
|
819
1743
|
this.changed$.next();
|
|
820
1744
|
this.reconnects.delete(pane); // connected again: a later drop starts over
|
|
821
1745
|
if (endpoint.remember) {
|
|
@@ -829,8 +1753,21 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
|
|
|
829
1753
|
end = await info.run();
|
|
830
1754
|
}
|
|
831
1755
|
catch (e) {
|
|
1756
|
+
if (session.h264?.failed) {
|
|
1757
|
+
// Reconnects (automatically, like any dropped connection) without H.264, unless the browser only
|
|
1758
|
+
// took an idle decoder back (a hidden window): then with it, as a new stream starts with a key frame.
|
|
1759
|
+
if (session.h264.reclaimed) {
|
|
1760
|
+
return { connected: true, error: 'the video decoder was reclaimed' };
|
|
1761
|
+
}
|
|
1762
|
+
this.h264Failed.add(session.key);
|
|
1763
|
+
return { connected: true, error: `H.264 decoding failed (${session.h264.failed}); continuing without it` };
|
|
1764
|
+
}
|
|
832
1765
|
return { connected: true, error: typeof e?.backtrace === 'function' ? e.backtrace().split('\n')[0] : (e?.message ?? String(e)) };
|
|
833
1766
|
}
|
|
1767
|
+
finally {
|
|
1768
|
+
// The server can't close the microphone once the connection is gone.
|
|
1769
|
+
session.mic?.close();
|
|
1770
|
+
}
|
|
834
1771
|
return { connected: true, reason: end?.reason?.() };
|
|
835
1772
|
}
|
|
836
1773
|
catch (e) {
|
|
@@ -846,7 +1783,11 @@ exports.RemoteDesktopService = RemoteDesktopService = RemoteDesktopService_1 = _
|
|
|
846
1783
|
targets_1.RemoteTargets,
|
|
847
1784
|
tabby_core_1.NotificationsService,
|
|
848
1785
|
tabby_core_1.ConfigService,
|
|
849
|
-
core_1.NgZone
|
|
1786
|
+
core_1.NgZone,
|
|
1787
|
+
tabby_core_1.ProfilesService,
|
|
1788
|
+
help_1.RemoteDesktopHelp,
|
|
1789
|
+
tabby_core_1.SelectorService,
|
|
1790
|
+
tabby_core_1.PlatformService])
|
|
850
1791
|
], RemoteDesktopService);
|
|
851
1792
|
let styleInstalled = false;
|
|
852
1793
|
function installStyle() {
|
|
@@ -855,7 +1796,7 @@ function installStyle() {
|
|
|
855
1796
|
}
|
|
856
1797
|
styleInstalled = true;
|
|
857
1798
|
const style = document.createElement('style');
|
|
858
|
-
style.textContent = STYLE + signin_1.STYLE + fileTransfer_1.STYLE;
|
|
1799
|
+
style.textContent = STYLE + signin_1.STYLE + fileTransfer_1.STYLE + connectionStatus_1.STYLE + osd_1.OSD_STYLE;
|
|
859
1800
|
document.head.appendChild(style);
|
|
860
1801
|
}
|
|
861
1802
|
//# sourceMappingURL=desktop.service.js.map
|