tabby-rdp 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +61 -2
  2. package/README.md +181 -21
  3. package/dist/connectionStatus.js +140 -0
  4. package/dist/connectionStatus.js.map +1 -0
  5. package/dist/desk.js +21 -10
  6. package/dist/desk.js.map +1 -1
  7. package/dist/deskScript.js +3 -0
  8. package/dist/deskScript.js.map +1 -1
  9. package/dist/desktop.service.js +993 -53
  10. package/dist/desktop.service.js.map +1 -1
  11. package/dist/desktopForm.js +64 -19
  12. package/dist/desktopForm.js.map +1 -1
  13. package/dist/desktops.js +60 -21
  14. package/dist/desktops.js.map +1 -1
  15. package/dist/fileTransfer.js +152 -5
  16. package/dist/fileTransfer.js.map +1 -1
  17. package/dist/header.js +22 -7
  18. package/dist/header.js.map +1 -1
  19. package/dist/help.js +235 -0
  20. package/dist/help.js.map +1 -0
  21. package/dist/index.js +14 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/keyboard.js +142 -5
  24. package/dist/keyboard.js.map +1 -1
  25. package/dist/microphone.js +295 -0
  26. package/dist/microphone.js.map +1 -0
  27. package/dist/osd.js +79 -0
  28. package/dist/osd.js.map +1 -0
  29. package/dist/probe.js +33 -0
  30. package/dist/probe.js.map +1 -0
  31. package/dist/rdcleanpath.js +132 -7
  32. package/dist/rdcleanpath.js.map +1 -1
  33. package/dist/rdpFile.js +47 -0
  34. package/dist/rdpFile.js.map +1 -0
  35. package/dist/rdpProfile.js +367 -0
  36. package/dist/rdpProfile.js.map +1 -0
  37. package/dist/remoteSetup.js +90 -11
  38. package/dist/remoteSetup.js.map +1 -1
  39. package/dist/settingsPage.js +538 -0
  40. package/dist/settingsPage.js.map +1 -0
  41. package/dist/signin.js +21 -2
  42. package/dist/signin.js.map +1 -1
  43. package/dist/ssh.js +28 -0
  44. package/dist/ssh.js.map +1 -1
  45. package/dist/targets.js +148 -8
  46. package/dist/targets.js.map +1 -1
  47. package/dist/ui.js +154 -24
  48. package/dist/ui.js.map +1 -1
  49. package/dist/vms.js +90 -0
  50. package/dist/vms.js.map +1 -0
  51. package/dist/wake.js +124 -0
  52. package/dist/wake.js.map +1 -0
  53. package/package.json +2 -1
  54. package/vendor/IRONRDP-LICENSE +3 -3
  55. package/vendor/iron-remote-desktop-rdp.js +698 -451
  56. package/vendor/iron-remote-desktop.js +87 -81
  57. package/vendor/ironrdp_web_bg.wasm +0 -0
@@ -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 vendor = (f) => (0, url_1.pathToFileURL)(path.join(__dirname, '..', 'vendor', f)).href;
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-status"><div class="trd-status-text"></div><div class="trd-status-actions"></div></div>`;
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
- /** Shows a message over the desktop (empty: none), with buttons; with buttons the last picture is dimmed. */
169
- status(msg, actions = []) {
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
- const canvas = host?.shadowRoot?.querySelector('canvas');
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
- else {
250
- await this.showDesktop(pane);
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
- /** The desktops this pane's SSH host offers: its own, then those configured behind it. */
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
- return (0, desktops_1.desktopsFor)(target, this.config.store.remoteDesktop?.desktops);
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 to the remote desktop…": picks files to paste there. */
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.askNewDesktop)(pane.element.nativeElement, hostname, target.label);
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, `${entry.host}:${entry.port}`);
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
- /** Removes a configured desktop and its saved accounts. Open sessions to it keep running. */
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)(`${removed.host ?? '127.0.0.1'}:${removed.port ?? 3389}`);
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.targets.targetOf(pane);
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
- await this.zone.run(() => this.showDesktop(pane, spec.id, { background }));
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,12 @@ 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,
554
957
  };
555
958
  }
556
959
  /** The sharpness chosen for the pane's desktop in particular (`remoteDesktop.desktopSharpness`), or null. */
@@ -587,21 +990,62 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
587
990
  /** Changes a setting, saves it, and applies it to open desktops. */
588
991
  updateSettings(change) {
589
992
  const store = this.config.store.remoteDesktop;
590
- Object.assign(store, change);
993
+ const { osd, ...rest } = change;
994
+ Object.assign(store, rest);
995
+ if (osd) {
996
+ // A nested object in Tabby's config takes its fields one by one (the object itself isn't replaced).
997
+ Object.assign(store.osd, osd);
998
+ }
999
+ if (change.h264) {
1000
+ this.h264Failed.clear(); // turned on again: give it another try everywhere
1001
+ }
591
1002
  this.config.save();
592
1003
  for (const [pane, session] of this.sessions) {
1004
+ this.applyZoom(session);
1005
+ this.syncIndicator(session);
593
1006
  this.followPane(pane, session);
594
1007
  }
595
1008
  }
1009
+ /** IronRDP's ScreenScale for the picture: Real (1:1, scrolling) only with a fixed resolution and zoom 'actual'. */
1010
+ screenScale() {
1011
+ const settings = this.settings();
1012
+ return settings.resize === 'off' && settings.zoom === 'actual' ? 3 /* ScreenScale.Real */ : 1; /* ScreenScale.Fit */
1013
+ }
1014
+ applyZoom(session) {
1015
+ try {
1016
+ session.ui?.setScale(this.screenScale());
1017
+ }
1018
+ catch { }
1019
+ }
1020
+ /** Shows the desktop's connection-status indicator while the setting is on and the desktop shows, connected. */
1021
+ syncIndicator(session) {
1022
+ const on = this.settings().connectionStatus && session.visible && session.state === 'connected';
1023
+ if (on && !session.indicator) {
1024
+ const remote = session.remote;
1025
+ session.indicator = new connectionStatus_1.ConnectionStatus(session.overlay, this.zone, {
1026
+ path: () => {
1027
+ const spec = session.spec;
1028
+ const size = session.remoteSize;
1029
+ return [
1030
+ spec.id === desktops_1.OWN_DESKTOP ? spec.name : remote?.direct ? `${spec.name} (direct)` : `${spec.name} via ${remote?.label ?? '?'}`,
1031
+ size ? `${size.width}×${size.height}` : '',
1032
+ session.graphics,
1033
+ this.settingsFor(session).sharpness === 'retina' ? 'Retina' : 'Standard',
1034
+ ].filter(Boolean).join(' · ');
1035
+ },
1036
+ bytes: () => session.proxy?.stats ?? null,
1037
+ ping: remote?.ping ? () => remote.ping() : undefined,
1038
+ });
1039
+ }
1040
+ const canvas = session.host.querySelector('iron-remote-desktop')?.shadowRoot?.querySelector('canvas');
1041
+ session.indicator?.setActive(on, on ? canvas : null);
1042
+ }
596
1043
  /** Follows the pane's size (debounced) while the desktop shows, per the resize setting. */
597
1044
  watchSize(pane, session) {
598
1045
  let timer;
599
1046
  const observer = new ResizeObserver(() => {
600
1047
  // Refit the view right away (the component only refits on window resizes by itself) ...
601
- try {
602
- session.ui?.setScale(1 /* ScreenScale.Fit */);
603
- }
604
- catch { }
1048
+ this.applyZoom(session);
605
1049
  // ... and change the remote resolution once the size settles.
606
1050
  clearTimeout(timer);
607
1051
  timer = setTimeout(() => this.followPane(pane, session), 300);
@@ -625,9 +1069,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
625
1069
  }
626
1070
  if (settings.resize === 'reconnect') {
627
1071
  session.log.push(`resize: reconnecting at ${wanted.width}x${wanted.height}`);
1072
+ const viewOnly = session.viewOnly;
628
1073
  this.zone.run(() => {
629
1074
  this.disconnect(pane);
630
- this.showDesktop(pane);
1075
+ this.showDesktop(pane, undefined, { viewOnly });
631
1076
  });
632
1077
  return;
633
1078
  }
@@ -637,8 +1082,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
637
1082
  session.log.push(`resize: ${size.width}x${size.height} @${size.scale}%`);
638
1083
  const before = session.remoteSize;
639
1084
  session.remoteSize = size;
640
- if (session.spec.kind === 'windows') {
1085
+ if (session.spec.kind !== 'gnome') {
641
1086
  // Windows takes the scale from the monitor layout only when it also has the monitor's physical size (mm).
1087
+ // xrdp gets it too; it can take its session's DPI from it.
642
1088
  const mm = (px) => Math.round(px / (size.scale / 100) / 96 * 25.4);
643
1089
  session.ui.resize(size.width, size.height, size.scale, mm(size.width), mm(size.height));
644
1090
  }
@@ -646,41 +1092,301 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
646
1092
  session.ui.resize(size.width, size.height, size.scale);
647
1093
  }
648
1094
  // 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) {
1095
+ if ((size.scale !== 100 || (before?.scale ?? 100) !== 100) && session.remote && session.spec.id === desktops_1.OWN_DESKTOP && session.spec.kind === 'gnome') {
650
1096
  session.remote.exec(`python3 - ${size.width} ${size.height} ${size.scale / 100}`, SCALE_SCRIPT).then(out => {
651
1097
  session.log.push(`scale: ${/^RD_(OK|ERR) (.*)$/m.exec(out)?.[2] ?? out.trim().slice(-120)}`);
652
1098
  }, e => session.log.push(`scale: ${e?.message ?? e}`));
653
1099
  }
654
1100
  }
1101
+ /**
1102
+ * Whether this connection decodes H.264: the setting, the browser (WebCodecs), and no failure on this desktop
1103
+ * since Tabby started.
1104
+ */
1105
+ async useH264(rdp, session, settings) {
1106
+ if (!settings.h264 || this.h264Failed.has(session.key) || typeof rdp.h264Decoder !== 'function') {
1107
+ return false;
1108
+ }
1109
+ this.h264Support ??= Promise.resolve(rdp.h264Supported()).catch(() => false);
1110
+ return this.h264Support;
1111
+ }
1112
+ isViewOnly(pane) {
1113
+ return !!this.sessions.get(pane)?.viewOnly;
1114
+ }
1115
+ /** View only: the pane's desktop gets no keyboard or mouse input (the picture, clipboard and sound go on). */
1116
+ setViewOnly(pane, viewOnly) {
1117
+ const session = this.sessions.get(pane);
1118
+ if (session) {
1119
+ session.setViewOnly(viewOnly);
1120
+ this.changed$.next();
1121
+ }
1122
+ }
1123
+ /**
1124
+ * For keys sent from a menu: brings the pane's desktop forward and focuses it, since IronRDP only takes keys
1125
+ * while it has focus. False when it can't take them (not connected, view only, or focus didn't stick).
1126
+ */
1127
+ async takeKeyboard(pane) {
1128
+ const session = this.sessions.get(pane);
1129
+ if (!session || session.state !== 'connected' || session.viewOnly) {
1130
+ return false;
1131
+ }
1132
+ if (!session.visible) {
1133
+ await this.showDesktop(pane);
1134
+ }
1135
+ if ((0, targets_1.desktopPaneOf)(this.app.activeTab) !== pane) {
1136
+ this.selectPane(pane);
1137
+ }
1138
+ // After a tab switch, the pane shows on Angular's next change detection.
1139
+ for (let i = 0; i < 30; i++) {
1140
+ session.focusDesktop(1);
1141
+ const focused = document.activeElement;
1142
+ if (focused?.tagName === 'IRON-REMOTE-DESKTOP' && session.overlay.contains(focused)) {
1143
+ return true;
1144
+ }
1145
+ await new Promise(resolve => setTimeout(resolve, 16));
1146
+ }
1147
+ session.log.push('keys: the desktop did not take the keyboard');
1148
+ return false;
1149
+ }
1150
+ /**
1151
+ * Saves the remote frame as a PNG in Downloads (never overwriting) and copies it to the clipboard. The canvas is
1152
+ * at the remote resolution, so this is the full picture whatever the pane's size. Returns the file.
1153
+ */
1154
+ async saveScreenshot(pane) {
1155
+ const session = this.sessions.get(pane);
1156
+ const canvas = session?.state === 'connected' ? session.canvas() : null;
1157
+ if (!session || !canvas) {
1158
+ return null;
1159
+ }
1160
+ try {
1161
+ const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
1162
+ if (!blob) {
1163
+ throw new Error('the picture could not be encoded');
1164
+ }
1165
+ const png = Buffer.from(await blob.arrayBuffer());
1166
+ const dir = path.join(os.homedir(), 'Downloads');
1167
+ const now = new Date();
1168
+ const two = (n) => String(n).padStart(2, '0');
1169
+ const stamp = `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())} at ${two(now.getHours())}.${two(now.getMinutes())}.${two(now.getSeconds())}`;
1170
+ const name = `Screenshot ${session.spec.name} ${stamp}`.replace(/[\\/:*?"<>|]/g, '-');
1171
+ await fs.promises.mkdir(dir, { recursive: true });
1172
+ const file = (0, fileTransfer_1.uniquePath)(path.join(dir, `${name}.png`));
1173
+ await fs.promises.writeFile(file, png, { flag: 'wx' });
1174
+ session.log.push(`screenshot: saved ${file}`);
1175
+ let copied = true;
1176
+ try {
1177
+ const { clipboard, nativeImage } = require('electron');
1178
+ clipboard.writeImage(nativeImage.createFromBuffer(png));
1179
+ }
1180
+ catch (e) {
1181
+ copied = false;
1182
+ session.log.push(`screenshot: clipboard: ${e?.message ?? e}`);
1183
+ }
1184
+ session.files?.toast(`Screenshot saved to ${path.basename(dir)}${copied ? ' and copied to the clipboard' : ''}`, [{
1185
+ label: process.platform === 'darwin' ? 'Show in Finder' : 'Show in folder',
1186
+ run: () => require('electron').shell.showItemInFolder(file),
1187
+ }]);
1188
+ return file;
1189
+ }
1190
+ catch (e) {
1191
+ session.log.push(`screenshot: ${e?.message ?? e}`);
1192
+ this.notifications.error(`Couldn't save the screenshot: ${e?.message ?? e}`);
1193
+ return null;
1194
+ }
1195
+ }
1196
+ /**
1197
+ * Which desktop a pane shows, for a moment (connected, shown, focused), like a TV naming its input. By default only
1198
+ * where nothing else says: in a split, whose pane headers name the SSH connection, and for a desktop other than
1199
+ * that connection's own (behind the host, or where ssh was typed on to).
1200
+ */
1201
+ label(pane, session, always = false) {
1202
+ const target = session.remote;
1203
+ const osd = this.settings().osd;
1204
+ if (session.state !== 'connected' || !session.visible || !target || osd.show === 'off' && !always) {
1205
+ return;
1206
+ }
1207
+ const inSplit = pane.parent instanceof tabby_core_1.SplitTabComponent && pane.parent.getAllTabs().length > 1;
1208
+ const own = session.spec.id === desktops_1.OWN_DESKTOP;
1209
+ if (osd.show === 'auto' && !always && !inSplit && own && !target.via) {
1210
+ return;
1211
+ }
1212
+ // A desktop named after its host already says where it is ("buildhost desktop (xrdp)").
1213
+ const through = own ? target.via : target.direct || session.spec.name.startsWith(target.label) ? null : target.label;
1214
+ const size = session.remoteSize ? `${session.remoteSize.width}×${session.remoteSize.height}` : '';
1215
+ session.flashLabel(own ? target.label : session.spec.name, [through ? `via ${through}` : '', size].filter(Boolean).join(' · '), osd);
1216
+ }
1217
+ /** Shows the on-screen display on every desktop showing now, whatever `show` says (to try the settings). */
1218
+ showOsdEverywhere() {
1219
+ let shown = 0;
1220
+ for (const [pane, session] of this.sessions) {
1221
+ if (session.state === 'connected' && session.visible) {
1222
+ this.label(pane, session, true);
1223
+ shown++;
1224
+ }
1225
+ }
1226
+ return shown;
1227
+ }
1228
+ /** The one-time tip (see RemoteDesktopHelp.tipOnce), once a desktop is connected and showing. */
1229
+ tip(pane, session) {
1230
+ if (session.state === 'connected' && session.visible) {
1231
+ this.help.tipOnce(session.overlay, this.hasConsole(pane), () => session.visible && session.focusDesktop());
1232
+ }
1233
+ }
1234
+ /** The open desktops, for the settings page (and bug reports): where they are, and their logs. */
1235
+ sessionSummaries() {
1236
+ this.prune();
1237
+ return [...this.sessions.values()].map(session => ({
1238
+ name: session.spec.name,
1239
+ where: session.remote && !session.remote.direct ? session.remote.label : null,
1240
+ state: session.state === 'connected' ? (session.visible ? 'connected, showing' : 'connected, in the background')
1241
+ : session.state === 'connecting' ? 'connecting' : 'not connected',
1242
+ log: [...session.log],
1243
+ }));
1244
+ }
1245
+ /** The certificates remembered for desktops (`remoteDesktop.trustedCertificates`). */
1246
+ trustedCertificates() {
1247
+ const list = this.config.store.remoteDesktop?.trustedCertificates;
1248
+ return (Array.isArray(list) ? list : [])
1249
+ .filter((e) => typeof e?.desktop === 'string' && typeof e?.sha256 === 'string')
1250
+ .map((e) => ({ desktop: e.desktop, sha256: (0, rdcleanpath_1.normalizeFingerprint)(e.sha256) || e.sha256 }));
1251
+ }
1252
+ /** Forgets one remembered certificate (by session key); the next connection remembers the one it meets. */
1253
+ forgetCertificate(key) {
1254
+ const store = this.config.store.remoteDesktop;
1255
+ store.trustedCertificates = (Array.isArray(store.trustedCertificates) ? store.trustedCertificates : []).filter((e) => e?.desktop !== key);
1256
+ this.config.save();
1257
+ this.changed$.next();
1258
+ }
1259
+ /** The split tab holding the pane, if it has more than one pane. */
1260
+ splitOf(pane) {
1261
+ const split = this.app.tabs.find(t => t instanceof tabby_core_1.SplitTabComponent && t.getAllTabs().includes(pane));
1262
+ return split && split.getAllTabs().length > 1 ? split : null;
1263
+ }
1264
+ /** The other connected desktops in the pane's tab that take input (not view only), with their sessions. */
1265
+ othersInTab(pane) {
1266
+ const split = this.splitOf(pane);
1267
+ return split ? split.getAllTabs().filter(p => p !== pane).map(p => ({ pane: p, session: this.sessions.get(p) }))
1268
+ .filter(({ session }) => session?.state === 'connected' && !session.viewOnly && session.ui) : [];
1269
+ }
1270
+ /** How many desktops in the pane's tab would get a paste or typing (itself included), when that's more than one. */
1271
+ desktopsInTab(pane) {
1272
+ const own = this.sessions.get(pane);
1273
+ return this.othersInTab(pane).length + (own?.state === 'connected' && !own.viewOnly ? 1 : 0);
1274
+ }
1275
+ isBroadcast(pane) {
1276
+ const split = this.splitOf(pane);
1277
+ return !!split && this.broadcast.has(split);
1278
+ }
1279
+ /** Keys typed on one desktop of the pane's tab go to all of them, or no longer; each shows it while it does. */
1280
+ setBroadcast(pane, on) {
1281
+ const split = this.splitOf(pane);
1282
+ if (!split) {
1283
+ return;
1284
+ }
1285
+ on ? this.broadcast.add(split) : this.broadcast.delete(split);
1286
+ for (const p of split.getAllTabs()) {
1287
+ const session = this.sessions.get(p);
1288
+ session?.overlay.classList.toggle('trd-broadcast-on', on);
1289
+ session?.log.push(`typing into all desktops: ${on ? 'on' : 'off'}`);
1290
+ }
1291
+ this.changed$.next();
1292
+ }
1293
+ /** A key event for the other desktops of a tab typing into all (see DesktopKeyboard). */
1294
+ broadcastKey(pane, type, init) {
1295
+ if (!this.isBroadcast(pane)) {
1296
+ return;
1297
+ }
1298
+ for (const { session } of this.othersInTab(pane)) {
1299
+ try {
1300
+ session.ui.sendKeyboardEvent(new KeyboardEvent(type, { ...init, cancelable: true }));
1301
+ }
1302
+ catch (e) {
1303
+ session.log.push(`typing into all desktops: ${e?.message ?? e}`);
1304
+ }
1305
+ }
1306
+ }
1307
+ /**
1308
+ * Pastes on every connected desktop in the pane's tab: files copied here are offered to each first (see
1309
+ * FileTransfer.pasteClipboardFiles); text and pictures are on their clipboards already. Then Ctrl+V on each, in
1310
+ * whatever window is active there.
1311
+ */
1312
+ pasteToAll(pane) {
1313
+ const targets = [...this.sessions.get(pane) ? [{ pane, session: this.sessions.get(pane) }] : [], ...this.othersInTab(pane)]
1314
+ .filter(({ session }) => session.state === 'connected' && !session.viewOnly && session.ui);
1315
+ for (const { session } of targets) {
1316
+ // Each pastes once it has taken in the files (when there are files to offer), however long that takes it.
1317
+ const offered = session.files?.pasteClipboardFiles() ?? false;
1318
+ (offered ? session.files.offerAnswered() : Promise.resolve(true)).then(ok => {
1319
+ if (!ok) {
1320
+ session.log.push('paste to all desktops: the remote refused the files');
1321
+ return;
1322
+ }
1323
+ try {
1324
+ session.ui.ctrlV();
1325
+ session.log.push('keys: Ctrl+V (paste to all desktops)');
1326
+ }
1327
+ catch (e) {
1328
+ session.log.push(`paste to all desktops: ${e?.message ?? e}`);
1329
+ }
1330
+ });
1331
+ }
1332
+ return targets.length;
1333
+ }
1334
+ /** Resolves when the pane's desktop has taken in the files just offered to it, so a paste then gets them. */
1335
+ pasteReady(pane) {
1336
+ return this.sessions.get(pane)?.files?.offerAnswered() ?? Promise.resolve(true);
1337
+ }
1338
+ /** ⌘V on the pane's desktop: offers files copied here for it (see FileTransfer.pasteClipboardFiles). */
1339
+ pasteClipboardFiles(pane) {
1340
+ const session = this.sessions.get(pane);
1341
+ return !!session?.files && this.isConnected(pane) && !session.viewOnly && session.files.pasteClipboardFiles();
1342
+ }
655
1343
  /** Status log of the pane's desktop session (for tests and troubleshooting). */
656
1344
  logOf(pane) {
657
1345
  return this.sessions.get(pane)?.log ?? [];
658
1346
  }
659
1347
  loadIronRDP() {
660
1348
  this.ironrdp ??= (async () => {
661
- await importESM(vendor('iron-remote-desktop.js'));
662
- const rdp = await importESM(vendor('iron-remote-desktop-rdp.js'));
1349
+ // The WebAssembly ships as its own file (not inlined in the bundle). It is read here and handed to init,
1350
+ // rather than fetched by the bundle from next to itself: fetch() of file:// URLs is up to the Electron
1351
+ // build (a fuse turns it off), and a plain file path needs no URL escaping for spaces or drive letters.
1352
+ const [rdp, wasm] = await Promise.all([
1353
+ importESM(vendor('iron-remote-desktop.js')).then(() => importESM(vendor('iron-remote-desktop-rdp.js'))),
1354
+ fs.promises.readFile(vendorFile('ironrdp_web_bg.wasm')),
1355
+ ]);
663
1356
  // Troubleshooting: localStorage.trdLogLevel = 'DEBUG' (or TRACE), then restart Tabby.
664
1357
  let level = 'INFO';
665
1358
  try {
666
1359
  level = localStorage.getItem('trdLogLevel') || level;
667
1360
  }
668
1361
  catch { }
669
- await rdp.init(level);
1362
+ await rdp.init(level, wasm);
670
1363
  return rdp;
671
1364
  })();
672
1365
  this.ironrdp.catch(() => { this.ironrdp = null; });
673
1366
  return this.ironrdp;
674
1367
  }
675
1368
  /**
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 (retry=true after a failed sign-in).
1369
+ * Where to connect and as whom. The host's own desktop: the setup script (grd, generated credentials), or, where
1370
+ * it finds xrdp or Windows instead of GNOME, as below. Others: the saved account, or the sign-in form (retryError: after a
1371
+ * failed sign-in).
678
1372
  */
679
1373
  async endpointFor(target, spec, session, retryError) {
680
- if (spec.id === desktops_1.OWN_DESKTOP) {
1374
+ // A Windows host is known from an earlier connect: no setup to run there (it would only fail again).
1375
+ if (spec.id === desktops_1.OWN_DESKTOP && !retryError && this.ownDesktops.get(target.key)?.kind !== 'windows') {
681
1376
  session.status(`Preparing the remote desktop on ${target.label}…`);
682
1377
  const endpoint = await (0, remoteSetup_1.prepareRemoteDesktop)(target, this.settings().desk, this.config.store.remoteDesktop?.sessionBackend);
683
- return { host: '127.0.0.1', port: endpoint.port, credentials: { username: endpoint.username, password: endpoint.password }, remember: false };
1378
+ this.ownDesktops.set(target.key, { kind: endpoint.kind, xrdpPort: endpoint.xrdpPort, xrdpUser: endpoint.xrdpUser });
1379
+ // Only the setup knows which desktop the host has; the session follows it (graphics, sign-in, resizing).
1380
+ spec.kind = endpoint.kind;
1381
+ if (endpoint.kind === 'windows') {
1382
+ session.log.push('setup: the SSH host runs Windows; its own desktop is its RDP server');
1383
+ }
1384
+ if (endpoint.kind === 'gnome') {
1385
+ return { host: '127.0.0.1', port: endpoint.port, credentials: { username: endpoint.username, password: endpoint.password }, remember: false, certificate: endpoint.certificate };
1386
+ }
1387
+ spec.host = '127.0.0.1';
1388
+ spec.port = endpoint.port;
1389
+ spec.username ??= endpoint.username;
684
1390
  }
685
1391
  const saved = retryError ? null : await (0, signin_1.loadCredentials)(session.key);
686
1392
  if (saved) {
@@ -688,7 +1394,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
688
1394
  }
689
1395
  session.status('');
690
1396
  const asked = (0, signin_1.askCredentials)(session.overlay, {
691
- title: `Sign in to ${spec.name} (via ${target.label})`,
1397
+ // The host's own desktops (its own, or its xrdp besides GNOME) and direct ones need no "via".
1398
+ 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
1399
  username: spec.username,
693
1400
  error: retryError,
694
1401
  canRemember: true,
@@ -702,28 +1409,71 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
702
1409
  async connect(pane, target, spec, session) {
703
1410
  const alive = () => this.sessions.get(pane) === session;
704
1411
  let retryError;
1412
+ // Automatic reconnects never start a desktop (`wake`): it may have been shut down on purpose.
1413
+ const automatic = this.reconnects.has(pane);
1414
+ // A desktop that was just started can answer before it takes connections: a few more tries, same account.
1415
+ let woken = false;
1416
+ let startupRetries = 0;
1417
+ let endpoint = null;
705
1418
  try {
706
- // Signing in to a desktop behind the host can take a few tries; the host's own one can't fail that way.
1419
+ if (spec.wake) {
1420
+ const woke = await this.wakeIfDown(pane, target, spec, session, automatic);
1421
+ if (woke === null) {
1422
+ return;
1423
+ }
1424
+ woken = woke;
1425
+ }
1426
+ // Signing in to a Windows account can take a few tries; a GNOME desktop the plugin set up can't fail that way.
707
1427
  for (let attempt = 0;; attempt++) {
708
- const [endpoint, rdp] = await Promise.all([this.endpointFor(target, spec, session, retryError), this.loadIronRDP()]);
1428
+ const [asked, rdp] = await Promise.all([endpoint ?? this.endpointFor(target, spec, session, retryError), this.loadIronRDP()]);
709
1429
  if (!alive()) {
710
1430
  return;
711
1431
  }
712
- if (!endpoint) {
1432
+ if (!asked) {
713
1433
  session.state = 'ended';
714
1434
  this.changed$.next();
715
1435
  this.cancelReconnect(pane);
716
1436
  session.status('Sign-in cancelled.', [{ label: 'Sign in', run: () => this.reopen(pane, spec) }]);
717
1437
  return;
718
1438
  }
1439
+ endpoint = asked;
719
1440
  session.remote = target;
720
- session.proxy ??= await (0, rdcleanpath_1.startRDCleanPathProxy)(() => target.openTcp(endpoint.host, endpoint.port), m => session.log.push(m));
721
- const outcome = await this.run(pane, target, spec, session, rdp, endpoint);
722
- if (outcome.signInFailed && spec.id !== desktops_1.OWN_DESKTOP && attempt < 5 && alive()) {
1441
+ session.pinnedCertificate = asked.certificate ?? null;
1442
+ 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' });
1443
+ const outcome = await this.run(pane, target, spec, session, rdp, asked);
1444
+ if (outcome.certificate && alive()) {
1445
+ // A changed certificate, now trusted: the same account again, without asking for it again.
1446
+ if (await this.refusedCertificate(pane, target, spec, session, asked, outcome.certificate)) {
1447
+ continue;
1448
+ }
1449
+ return;
1450
+ }
1451
+ if (outcome.signInFailed && this.signsIn(spec) && attempt < 5 && alive()) {
723
1452
  await (0, signin_1.forgetCredentials)(session.key);
724
1453
  retryError = outcome.signInFailed;
1454
+ endpoint = null;
725
1455
  continue;
726
1456
  }
1457
+ if (spec.wake && !outcome.connected && !outcome.signInFailed && alive()) {
1458
+ if (woken && startupRetries < 3) {
1459
+ startupRetries++;
1460
+ session.log.push(`wake: not taking connections yet: ${outcome.error ?? outcome.reason ?? 'unknown error'}`);
1461
+ session.status(`${spec.name} is still starting…`);
1462
+ await this.zone.runOutsideAngular(() => new Promise(resolve => setTimeout(resolve, 5000)));
1463
+ continue;
1464
+ }
1465
+ // It answered before, but not now: it may have been shut down since.
1466
+ if (!woken && !automatic) {
1467
+ const woke = await this.wakeIfDown(pane, target, spec, session, false);
1468
+ if (woke === null) {
1469
+ return;
1470
+ }
1471
+ if (woke) {
1472
+ woken = true;
1473
+ continue;
1474
+ }
1475
+ }
1476
+ }
727
1477
  if (alive()) {
728
1478
  this.afterEnd(pane, target, spec, session, outcome);
729
1479
  }
@@ -736,13 +1486,153 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
736
1486
  }
737
1487
  }
738
1488
  }
1489
+ /**
1490
+ * The proxy's check of the server's certificate, before any credentials go out. The host's own desktop must show
1491
+ * the certificate its setup reported. Others (behind a host, a Windows host's own, a remote desktop tab's) are
1492
+ * trusted on first use: remembered silently the first time (most connections already run inside SSH, and a prompt
1493
+ * on every new desktop would only teach clicking it away), and refused when it changes, until the new one is
1494
+ * trusted (see refusedCertificate). Remembered by session key, like saved accounts.
1495
+ */
1496
+ checkCertificate(session, fingerprint) {
1497
+ session.certificateProblem = null;
1498
+ // The host's own GNOME desktop; its own xrdp makes its certificate itself, so that one is trusted on first use.
1499
+ if (session.spec.id === desktops_1.OWN_DESKTOP && session.spec.kind === 'gnome') {
1500
+ const expected = session.pinnedCertificate ?? '';
1501
+ if (fingerprint !== expected) {
1502
+ session.certificateProblem = { expected, actual: fingerprint, pinned: true };
1503
+ throw new Error(`certificate SHA-256 ${fingerprint} is not the one set up (${expected || 'none'})`);
1504
+ }
1505
+ session.log.push(`certificate: SHA-256 ${fingerprint}, as set up`);
1506
+ return;
1507
+ }
1508
+ const known = this.trustedCertificate(session.key);
1509
+ if (!known) {
1510
+ this.trustCertificate(session.key, fingerprint);
1511
+ session.log.push(`certificate: SHA-256 ${fingerprint}, remembered (first connection)`);
1512
+ return;
1513
+ }
1514
+ if (fingerprint !== known) {
1515
+ session.certificateProblem = { expected: known, actual: fingerprint, pinned: false };
1516
+ throw new Error(`certificate SHA-256 ${fingerprint} is not the one remembered (${known})`);
1517
+ }
1518
+ session.log.push(`certificate: SHA-256 ${fingerprint}, as remembered`);
1519
+ }
1520
+ /**
1521
+ * After the proxy refused a certificate; nothing was sent to that server. The host's own desktop: an error, since
1522
+ * the plugin made that certificate itself. Others: both fingerprints, to trust the new certificate or not. Either
1523
+ * way, automatic reconnecting stops here. Resolves true to connect again (the new certificate is then trusted).
1524
+ */
1525
+ async refusedCertificate(pane, target, spec, session, endpoint, problem) {
1526
+ this.cancelReconnect(pane);
1527
+ const alive = () => this.sessions.get(pane) === session;
1528
+ const stop = (message) => {
1529
+ session.state = 'ended';
1530
+ this.changed$.next();
1531
+ session.status(message, [{ label: 'Try again', run: () => this.reopen(pane, spec) }], true);
1532
+ };
1533
+ const fingerprints = `${problem.pinned ? 'Expected' : 'Remembered'} (SHA-256):\n${showFingerprint(problem.expected)}\n\nNow:\n${showFingerprint(problem.actual)}`;
1534
+ if (problem.pinned) {
1535
+ stop(`The remote desktop on ${target.label} answered with a certificate other than the one set up for it. ` +
1536
+ `The connection was stopped before signing in.\n\n${fingerprints}\n\n` +
1537
+ `Something other than GNOME Remote Desktop may be listening on port ${endpoint.port}.`);
1538
+ return false;
1539
+ }
1540
+ if (!session.visible) {
1541
+ this.notifications.notice(`The certificate of ${spec.name}${target.direct ? '' : ` (via ${target.label})`} has changed. Open that desktop to decide.`);
1542
+ }
1543
+ const trusted = await new Promise(resolve => {
1544
+ session.status(`The certificate of ${spec.name} has changed since it was last used. Nothing has been sent to it.\n\n` +
1545
+ `${fingerprints}\n\nReinstalling the machine or renewing its certificate changes it. If neither happened, ` +
1546
+ `something else may be answering at ${spec.host}:${spec.port}.`, [
1547
+ { label: 'Trust the new certificate', run: () => resolve(true) },
1548
+ { label: 'Cancel', run: () => resolve(false) },
1549
+ ], true);
1550
+ session.disposed.then(() => resolve(false));
1551
+ });
1552
+ if (!alive()) {
1553
+ return false;
1554
+ }
1555
+ if (!trusted) {
1556
+ stop('Not connected: the new certificate was not trusted.');
1557
+ return false;
1558
+ }
1559
+ this.trustCertificate(session.key, problem.actual);
1560
+ session.log.push(`certificate: SHA-256 ${problem.actual}, trusted instead of ${problem.expected}`);
1561
+ return true;
1562
+ }
1563
+ /** The certificate remembered for a desktop behind a host (`remoteDesktop.trustedCertificates`), if any. */
1564
+ trustedCertificate(key) {
1565
+ const list = this.config.store.remoteDesktop?.trustedCertificates;
1566
+ const value = (Array.isArray(list) ? list : []).find((e) => e?.desktop === key)?.sha256;
1567
+ return typeof value === 'string' ? (0, rdcleanpath_1.normalizeFingerprint)(value) || null : null;
1568
+ }
1569
+ trustCertificate(key, fingerprint) {
1570
+ const store = this.config.store.remoteDesktop;
1571
+ const others = (Array.isArray(store.trustedCertificates) ? store.trustedCertificates : []).filter((e) => e?.desktop !== key);
1572
+ store.trustedCertificates = [...others, { desktop: key, sha256: fingerprint }];
1573
+ this.config.save();
1574
+ }
1575
+ /**
1576
+ * Forgets the certificates remembered for a desktop behind any SSH host (keys ending in `#<id>`, not a direct
1577
+ * one's `rdp#<id>`), or with `direct`, for that direct desktop; doesn't save.
1578
+ */
1579
+ forgetCertificatesFor(desktopId, direct = false) {
1580
+ const store = this.config.store.remoteDesktop;
1581
+ const list = Array.isArray(store.trustedCertificates) ? store.trustedCertificates : [];
1582
+ const forget = (key) => direct ? key === `${desktops_1.DIRECT_KEY}#${desktopId}` : key.endsWith(`#${desktopId}`) && !key.startsWith(`${desktops_1.DIRECT_KEY}#`);
1583
+ store.trustedCertificates = list.filter((e) => !forget(String(e?.desktop ?? '')));
1584
+ }
1585
+ /**
1586
+ * For a desktop with `wake`: when its RDP server doesn't answer (probed from the SSH host), starts it and waits
1587
+ * until it does. True when it had to be started, false when it was up, null when the wait was cancelled (the layer
1588
+ * says so). Throws when it couldn't be started, didn't come up in time, or is down during an automatic reconnect.
1589
+ */
1590
+ async wakeIfDown(pane, target, spec, session, automatic) {
1591
+ const alive = () => this.sessions.get(pane) === session;
1592
+ session.status(`Connecting to ${spec.name} via ${target.label}…`);
1593
+ if (await (0, wake_1.rdpAnswers)(target, spec.host, spec.port)) {
1594
+ return false;
1595
+ }
1596
+ if (!alive()) {
1597
+ return null;
1598
+ }
1599
+ if (automatic) {
1600
+ throw new Error(`${spec.name} doesn't answer; it may have been shut down`);
1601
+ }
1602
+ session.status(`Starting ${spec.name}…`);
1603
+ session.log.push(`wake: ${await (0, wake_1.wakeDesktop)(target, spec.wake)}`);
1604
+ let cancelled = false;
1605
+ const cancel = {
1606
+ label: 'Cancel',
1607
+ run: () => {
1608
+ cancelled = true;
1609
+ session.state = 'ended';
1610
+ this.changed$.next();
1611
+ this.cancelReconnect(pane);
1612
+ session.status(`Stopped waiting for ${spec.name}.`, [{ label: 'Try again', run: () => this.reopen(pane, spec) }]);
1613
+ },
1614
+ };
1615
+ const started = Date.now();
1616
+ const progress = () => session.status(`Starting ${spec.name}…\nWaiting for it to answer: ${Math.round((Date.now() - started) / 1000)} s`, [cancel]);
1617
+ progress();
1618
+ const up = await this.zone.runOutsideAngular(() => (0, wake_1.waitForRdp)(target, spec.host, spec.port, WAKE_TIMEOUT_MS, () => cancelled || !alive(), progress));
1619
+ if (up) {
1620
+ session.log.push(`wake: answered after ${Math.round((Date.now() - started) / 1000)} s`);
1621
+ return true;
1622
+ }
1623
+ if (cancelled || !alive()) {
1624
+ return null;
1625
+ }
1626
+ throw new Error(`${spec.name} didn't answer within ${WAKE_TIMEOUT_MS / 60000} minutes of starting it`);
1627
+ }
739
1628
  /** One RDP connection attempt, until it ends. */
740
1629
  async run(pane, target, spec, session, rdp, endpoint) {
741
1630
  const credentials = endpoint.credentials;
742
1631
  const alive = () => this.sessions.get(pane) === session;
1632
+ session.certificateProblem = null;
743
1633
  const el = document.createElement('iron-remote-desktop');
744
1634
  try {
745
- el.setAttribute('scale', 'fit');
1635
+ el.setAttribute('scale', this.screenScale() === 3 ? 'real' : 'fit');
746
1636
  el.setAttribute('flexcenter', 'true');
747
1637
  el.module = rdp.Backend;
748
1638
  const ready = new Promise(resolve => el.addEventListener('ready', (e) => resolve(e.detail.irgUserInteraction), { once: true }));
@@ -767,33 +1657,62 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
767
1657
  const { width, height } = size;
768
1658
  // DOMAIN\user or user@domain go to the server as typed; only the former needs splitting.
769
1659
  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}…` : `Connecting to ${spec.name} via ${target.label}…`);
1660
+ session.status(spec.id === desktops_1.OWN_DESKTOP ? `Connecting to ${target.label}…`
1661
+ : target.direct ? `Connecting to ${spec.name}…` : `Connecting to ${spec.name} via ${target.label}…`);
771
1662
  const config = session.ui.configBuilder()
772
1663
  .withUsername(username)
773
1664
  .withPassword(credentials.password)
774
- .withDestination(`${spec.id === desktops_1.OWN_DESKTOP ? target.label : endpoint.host}:${endpoint.port}`)
1665
+ .withDestination(`${spec.id === desktops_1.OWN_DESKTOP ? target.hostname ?? target.label : endpoint.host}:${endpoint.port}`)
775
1666
  .withProxyAddress(session.proxy.url)
776
1667
  .withServerDomain(domain)
777
1668
  .withAuthToken(session.proxy.token)
778
1669
  .withDesktopSize({ width, height })
779
1670
  // Display control lets the remote monitor follow the pane (live resize, remote scaling).
780
1671
  .withExtension(rdp.displayControl(true));
781
- // GNOME Remote Desktop requires the graphics pipeline; Windows does better with bitmaps (IronRDP decodes
782
- // EGFX without H.264, which Windows would want). A build without the switch decides by itself.
1672
+ // H.264 in the graphics pipeline, decoded by the browser (hardware-accelerated where it can be). A decoder
1673
+ // failure ends the connection, which then reconnects without H.264 (see below). Not for xrdp: it encodes
1674
+ // H.264 only in some builds, and without it bitmaps do better there than the pipeline.
1675
+ session.h264?.close();
1676
+ session.h264 = null;
1677
+ if (spec.kind !== 'xrdp' && typeof rdp.graphicsPipeline === 'function' && await this.useH264(rdp, session, settings)) {
1678
+ session.h264 = new rdp.WebCodecsH264Decoder({ onFailure: (reason) => session.log.push(`h264: failed: ${reason}`) });
1679
+ config.withExtension(rdp.h264Decoder(session.h264));
1680
+ }
1681
+ // GNOME Remote Desktop requires the graphics pipeline. Windows (and xrdp) do better with bitmaps than with
1682
+ // the pipeline without H.264; with H.264, Windows streams what changes a lot (video) as video. A build
1683
+ // without the switch decides by itself.
783
1684
  if (typeof rdp.graphicsPipeline === 'function') {
784
- config.withExtension(rdp.graphicsPipeline(spec.kind === 'gnome'));
1685
+ const pipeline = spec.kind === 'gnome' || !!session.h264;
1686
+ config.withExtension(rdp.graphicsPipeline(pipeline));
1687
+ session.graphics = pipeline ? `graphics pipeline${session.h264 ? ' with H.264' : ''}` : 'bitmaps';
1688
+ }
1689
+ else {
1690
+ session.graphics = 'automatic graphics';
785
1691
  }
1692
+ session.log.push(`graphics: ${session.graphics}`);
786
1693
  if (settings.sound) {
787
1694
  session.audio?.close();
788
1695
  session.audio = new audio_1.AudioPlayer();
789
1696
  config.withExtension(rdp.audioPlayback(session.audio.callback));
790
1697
  }
1698
+ session.mic?.close();
1699
+ session.mic = null;
1700
+ if (settings.microphone && typeof rdp.audioInput === 'function') {
1701
+ const ui = session.ui;
1702
+ 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)));
1703
+ session.mic = mic;
1704
+ config.withExtension(rdp.audioInput(mic.callback));
1705
+ }
791
1706
  const built = config.build();
792
1707
  let info;
793
1708
  try {
794
1709
  info = await session.ui.connect(built);
795
1710
  }
796
1711
  catch (e) {
1712
+ // The proxy refused the server's certificate, before CredSSP (see checkCertificate).
1713
+ if (session.certificateProblem) {
1714
+ return { connected: false, certificate: session.certificateProblem };
1715
+ }
797
1716
  // IronErrorKind: 1 WrongPassword, 2 LogonFailure.
798
1717
  const kind = typeof e?.kind === 'function' ? e.kind() : undefined;
799
1718
  const detail = typeof e?.backtrace === 'function' ? e.backtrace() : (e?.message ?? String(e));
@@ -801,7 +1720,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
801
1720
  session.log.push(`sign-in failed: ${detail.split('\n')[0]}`);
802
1721
  return { connected: false, signInFailed: kind === 1 ? 'Wrong user name or password.' : 'The sign-in was refused.' };
803
1722
  }
804
- throw new Error(detail);
1723
+ throw new Error(session.proxy?.failure ?? detail);
805
1724
  }
806
1725
  if (!alive()) {
807
1726
  return { connected: false };
@@ -815,7 +1734,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
815
1734
  }
816
1735
  this.watchSize(pane, session);
817
1736
  session.state = 'connected';
1737
+ session.log.push(`connected: ${width}x${height}`);
818
1738
  session.status('');
1739
+ this.tip(pane, session);
1740
+ this.label(pane, session);
1741
+ this.syncIndicator(session);
819
1742
  this.changed$.next();
820
1743
  this.reconnects.delete(pane); // connected again: a later drop starts over
821
1744
  if (endpoint.remember) {
@@ -829,8 +1752,21 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
829
1752
  end = await info.run();
830
1753
  }
831
1754
  catch (e) {
1755
+ if (session.h264?.failed) {
1756
+ // Reconnects (automatically, like any dropped connection) without H.264, unless the browser only
1757
+ // took an idle decoder back (a hidden window): then with it, as a new stream starts with a key frame.
1758
+ if (session.h264.reclaimed) {
1759
+ return { connected: true, error: 'the video decoder was reclaimed' };
1760
+ }
1761
+ this.h264Failed.add(session.key);
1762
+ return { connected: true, error: `H.264 decoding failed (${session.h264.failed}); continuing without it` };
1763
+ }
832
1764
  return { connected: true, error: typeof e?.backtrace === 'function' ? e.backtrace().split('\n')[0] : (e?.message ?? String(e)) };
833
1765
  }
1766
+ finally {
1767
+ // The server can't close the microphone once the connection is gone.
1768
+ session.mic?.close();
1769
+ }
834
1770
  return { connected: true, reason: end?.reason?.() };
835
1771
  }
836
1772
  catch (e) {
@@ -846,7 +1782,11 @@ exports.RemoteDesktopService = RemoteDesktopService = RemoteDesktopService_1 = _
846
1782
  targets_1.RemoteTargets,
847
1783
  tabby_core_1.NotificationsService,
848
1784
  tabby_core_1.ConfigService,
849
- core_1.NgZone])
1785
+ core_1.NgZone,
1786
+ tabby_core_1.ProfilesService,
1787
+ help_1.RemoteDesktopHelp,
1788
+ tabby_core_1.SelectorService,
1789
+ tabby_core_1.PlatformService])
850
1790
  ], RemoteDesktopService);
851
1791
  let styleInstalled = false;
852
1792
  function installStyle() {
@@ -855,7 +1795,7 @@ function installStyle() {
855
1795
  }
856
1796
  styleInstalled = true;
857
1797
  const style = document.createElement('style');
858
- style.textContent = STYLE + signin_1.STYLE + fileTransfer_1.STYLE;
1798
+ style.textContent = STYLE + signin_1.STYLE + fileTransfer_1.STYLE + connectionStatus_1.STYLE + osd_1.OSD_STYLE;
859
1799
  document.head.appendChild(style);
860
1800
  }
861
1801
  //# sourceMappingURL=desktop.service.js.map