tabby-rdp 0.4.0 → 0.5.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.
@@ -24,6 +24,7 @@ const deskScript_1 = require("./deskScript");
24
24
  const audio_1 = require("./audio");
25
25
  const connectionStatus_1 = require("./connectionStatus");
26
26
  const microphone_1 = require("./microphone");
27
+ const drives_1 = require("./drives");
27
28
  const desktopForm_1 = require("./desktopForm");
28
29
  const accounts_1 = require("./accounts");
29
30
  const accountForm_1 = require("./accountForm");
@@ -37,6 +38,8 @@ const rdcleanpath_1 = require("./rdcleanpath");
37
38
  const signin_1 = require("./signin");
38
39
  const ssh_1 = require("./ssh");
39
40
  const wake_1 = require("./wake");
41
+ const hyperv_1 = require("./hyperv");
42
+ const gateway_1 = require("./gateway");
40
43
  const vms_1 = require("./vms");
41
44
  /** How long a desktop that was started (`wake`) gets to answer. */
42
45
  const WAKE_TIMEOUT_MS = 3 * 60 * 1000;
@@ -139,6 +142,13 @@ function remoteSizeFor(rect, settings) {
139
142
  };
140
143
  }
141
144
  class DesktopSession {
145
+ /**
146
+ * What a sign-in and a certificate are remembered under: the desktop; for a Hyper-V VM, its host, whose account
147
+ * and certificate they are, the same for all of its VMs.
148
+ */
149
+ get authKey() {
150
+ return this.spec.hyperv ? this.key.replace(/#hyperv:[^#]*$/, '#hyperv') : this.key;
151
+ }
142
152
  /** `key`: see sessionKey(); one desktop per key. */
143
153
  constructor(container, key, spec) {
144
154
  this.key = key;
@@ -157,6 +167,8 @@ class DesktopSession {
157
167
  this.mic = null;
158
168
  /** Files through the clipboard (drop on the desktop, or copy on the remote). */
159
169
  this.files = null;
170
+ /** The shared folders as drives on the remote (see drives.ts), when there were any at connect. */
171
+ this.drives = null;
160
172
  /** Decodes H.264 for the graphics pipeline (IronRDP's WebCodecsH264Decoder), when this connection uses it. */
161
173
  this.h264 = null;
162
174
  /** No keyboard or mouse input goes to the remote (keys: see keyboard.ts; the mouse: a layer over the picture). */
@@ -165,12 +177,18 @@ class DesktopSession {
165
177
  this.pinnedCertificate = null;
166
178
  /** Set when the proxy refused the server's certificate on the current connection attempt. */
167
179
  this.certificateProblem = null;
180
+ /** Set when the desktop's RD Gateway refused the sign-in on the current connection attempt: what it said. */
181
+ this.gatewayRefused = null;
182
+ /** Set when the proxy stopped at a server without Network Level Authentication on the current connection attempt. */
183
+ this.withoutNla = false;
168
184
  /** The connection-status indicator, made the first time it shows. */
169
185
  this.indicator = null;
170
186
  /** How the pictures come (for the indicator): the graphics pipeline, bitmaps, or IronRDP's choice. */
171
187
  this.graphics = '';
172
188
  /** "What does this mean?" under a message (see status()): opens help about it. */
173
189
  this.onHelp = null;
190
+ /** Keys this desktop was sent and not yet let go of (by `code`): see RemoteDesktopService.broadcastKey. */
191
+ this.keysDown = new Map();
174
192
  this.reclaimFocus = (event) => {
175
193
  if (this.visible && !this.overlay.contains(event.target)) {
176
194
  setTimeout(() => this.visible && this.focusDesktop());
@@ -204,6 +222,30 @@ class DesktopSession {
204
222
  this.host.querySelector('iron-remote-desktop')?.shadowRoot?.querySelector('.screen-wrapper')?.scrollBy(wheel.deltaX, wheel.deltaY);
205
223
  }, { passive: true });
206
224
  }
225
+ /** Notes a key sent to this desktop as held, or let go. */
226
+ noteKey(type, init) {
227
+ if (type === 'keydown' && init.code) {
228
+ this.keysDown.set(init.code, init);
229
+ }
230
+ else if (type === 'keyup' && init.code) {
231
+ this.keysDown.delete(init.code);
232
+ }
233
+ }
234
+ /**
235
+ * Lets go of every key still held on this desktop. For when their keyups won't reach it: view only coming on, or
236
+ * typing into all going off, by a shortcut whose modifiers are down at that moment. They would stay down there.
237
+ */
238
+ releaseKeys() {
239
+ for (const [code, init] of [...this.keysDown]) {
240
+ this.keysDown.delete(code);
241
+ try {
242
+ this.ui?.sendKeyboardEvent(new KeyboardEvent('keyup', { key: init.key, code, location: init.location, cancelable: true }));
243
+ }
244
+ catch (e) {
245
+ this.log.push(`keys: ${code} not released: ${e?.message ?? e}`);
246
+ }
247
+ }
248
+ }
207
249
  setViewOnly(viewOnly) {
208
250
  this.viewOnly = viewOnly;
209
251
  this.overlay.classList.toggle('trd-view-only-on', viewOnly);
@@ -291,6 +333,7 @@ class DesktopSession {
291
333
  this.h264?.close();
292
334
  this.mic?.close();
293
335
  this.files?.dispose();
336
+ this.drives?.dispose();
294
337
  this.proxy?.close();
295
338
  this.overlay.remove();
296
339
  }
@@ -329,7 +372,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
329
372
  * with a wrong password it shows its own login window rather than refusing, so the form never comes back.
330
373
  */
331
374
  this.askAgain = new WeakSet();
332
- /** VMs found on SSH hosts (see vms.ts), by target key: when, and as desktops. */
375
+ /** VMs found on SSH hosts (see vms.ts), by target key: when, and as desktops; and what the scans learnt of the host. */
333
376
  this.vms = new Map();
334
377
  this.lastUsed = new Map();
335
378
  /** Automatic reconnection after a desktop dropped, per pane: attempts so far and the pending timer. */
@@ -399,7 +442,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
399
442
  async signInAgain(pane) {
400
443
  const session = this.sessions.get(pane);
401
444
  if (session && this.signsIn(session.spec)) {
402
- await (0, signin_1.forgetCredentials)(session.key);
445
+ await (0, signin_1.forgetCredentials)(session.authKey);
403
446
  // With a saved account, the form asks for its password anew (the saved one stays until then).
404
447
  this.askAgain.add(pane);
405
448
  await this.reopen(pane, session.spec);
@@ -413,9 +456,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
413
456
  if (target.direct) {
414
457
  return [target.direct];
415
458
  }
459
+ // With their group's defaults (an account for the whole group), as Tabby resolves a profile when opening it.
416
460
  const viaProfile = target.profileId
417
461
  ? (this.config.store.profiles ?? []).filter((p) => p?.type === targets_1.RDP_PROFILE_TYPE && p.options?.host && p.options.via === target.profileId)
418
- .map((p) => ({ ...p.options, name: p.name }))
462
+ .map((p) => ({ ...this.profiles.getConfigProxyForProfile(p).options, name: p.name }))
419
463
  : [];
420
464
  const specs = (0, desktops_1.desktopsFor)(target, this.config.store.remoteDesktop?.desktops, this.ownDesktops.get(target.key), viaProfile);
421
465
  // Found VMs come last, and not where a configured desktop has the same address.
@@ -437,13 +481,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
437
481
  if (known && Date.now() - known.at < 60000) {
438
482
  return Promise.resolve();
439
483
  }
440
- const scan = (0, vms_1.scanVMs)(target).then(found => {
441
- this.vms.set(target.key, { at: Date.now(), specs: found.map(vms_1.vmSpec) });
484
+ const host = known?.host ?? { windows: this.ownDesktops.get(target.key)?.kind === 'windows' || undefined };
485
+ const scan = (0, vms_1.scanVMs)(target, host).then(found => {
486
+ this.vms.set(target.key, { at: Date.now(), specs: found.map(vms_1.vmSpec), host });
442
487
  this.changed$.next();
443
488
  }, () => {
444
- this.vms.set(target.key, { at: Date.now(), specs: known?.specs ?? [] });
489
+ // Not asked, rather than nothing found (the connection wasn't up yet, say): the next look asks again.
490
+ this.vms.set(target.key, { at: known?.at ?? 0, specs: known?.specs ?? [], host });
445
491
  });
446
- this.vms.set(target.key, { at: known?.at ?? 0, specs: known?.specs ?? [], scan });
492
+ this.vms.set(target.key, { at: known?.at ?? 0, specs: known?.specs ?? [], scan, host });
447
493
  return scan;
448
494
  }
449
495
  /** Keeps a found VM open in the pane among the host's desktops (`remoteDesktop.desktops`), under its name. */
@@ -453,7 +499,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
453
499
  if (!session?.remote || !spec?.found) {
454
500
  return;
455
501
  }
456
- const entry = { name: spec.name, via: session.remote.label, host: spec.host, port: spec.port, kind: spec.kind, ...spec.wake ? { wake: spec.wake } : {} };
502
+ const entry = spec.hyperv ? { name: spec.name, via: session.remote.label, hyperv: spec.hyperv }
503
+ : { name: spec.name, via: session.remote.label, host: spec.host, port: spec.port, kind: spec.kind, ...spec.wake ? { wake: spec.wake } : {} };
457
504
  this.config.store.remoteDesktop.desktops = [...this.configuredDesktops(), entry];
458
505
  this.config.save();
459
506
  this.changed$.next();
@@ -490,15 +537,16 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
490
537
  return (0, accounts_1.accountsOf)(this.config.store.remoteDesktop);
491
538
  }
492
539
  /**
493
- * The desktops that sign in with a saved account: configured ones behind SSH hosts, and RDP profiles (their own
494
- * choice, or their profile group's default when they make none). Each comes with what opens its editor.
540
+ * The desktops that sign in with a saved account, or sign in to their RD Gateway with it: configured ones behind
541
+ * SSH hosts, and RDP profiles (their own choice, or their profile group's default when they make none). Each comes
542
+ * with what opens its editor.
495
543
  */
496
544
  accountUses(id) {
497
- const desktops = this.configuredDesktops().flatMap((d, i) => d.account === id ? [{ name: String(d.name ?? (0, desktops_1.desktopIdOf)(d)), desktopIndex: i }] : []);
545
+ const desktops = this.configuredDesktops().flatMap((d, i) => d.account === id || d.gatewayAccount === id ? [{ name: String(d.name ?? (0, desktops_1.desktopIdOf)(d)), desktopIndex: i }] : []);
498
546
  const groups = this.config.store.groups ?? [];
499
547
  const inherited = (p) => p.options?.account === undefined && groups.find(g => g.id === p.group)?.defaults?.[targets_1.RDP_PROFILE_TYPE]?.options?.account === id;
500
548
  const profiles = (this.config.store.profiles ?? [])
501
- .filter((p) => p?.type === targets_1.RDP_PROFILE_TYPE && (p.options?.account === id || inherited(p)))
549
+ .filter((p) => p?.type === targets_1.RDP_PROFILE_TYPE && (p.options?.account === id || p.options?.gatewayAccount === id || inherited(p)))
502
550
  .map((p) => ({ name: String(p.name ?? p.options?.host ?? ''), profileId: p.id ? String(p.id) : undefined }));
503
551
  return [...desktops, ...profiles];
504
552
  }
@@ -516,16 +564,32 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
516
564
  username: account.username.trim(),
517
565
  ...account.domain?.trim() ? { domain: account.domain.trim() } : {},
518
566
  };
519
- this.config.store.remoteDesktop.accounts = old ? list.map(a => a === old ? entry : a) : [...list, entry];
520
- this.config.save();
521
- this.changed$.next();
567
+ // The password first: a new user name must not be written while the old password (for the old name) stays, nor
568
+ // a new password reported as saved when the store refused it.
522
569
  let keychainError;
523
570
  if (password !== undefined && password !== '') {
524
- await (0, signin_1.saveCredentials)((0, accounts_1.accountKey)(entry.id), { username: (0, accounts_1.signInName)(entry), password }).catch(e => { keychainError = String(e?.message ?? e); });
571
+ await (0, signin_1.saveCredentials)((0, accounts_1.accountKey)(entry.id), { username: (0, accounts_1.signInName)(entry), password }, `saved account ${entry.name}`).catch(e => { keychainError = String(e?.message ?? e); });
525
572
  }
526
573
  else if (old && (0, accounts_1.signInName)(old) !== (0, accounts_1.signInName)(entry)) {
527
- await (0, signin_1.forgetCredentials)((0, accounts_1.accountKey)(entry.id));
574
+ await (0, signin_1.forgetCredentialsOrFail)((0, accounts_1.accountKey)(entry.id)).catch(e => { keychainError = `the old password couldn't be removed: ${e?.message ?? e}`; });
528
575
  }
576
+ if (keychainError && !old) {
577
+ // Not added at all: a retry would otherwise make a second account.
578
+ return { id: entry.id, keychainError: `${keychainError}. The account wasn't added` };
579
+ }
580
+ if (keychainError && old && (0, accounts_1.signInName)(old) !== (0, accounts_1.signInName)(entry)) {
581
+ // The sign-in name stays as it was; the account's name can still change.
582
+ entry.username = old.username;
583
+ if (old.domain) {
584
+ entry.domain = old.domain;
585
+ }
586
+ else {
587
+ delete entry.domain;
588
+ }
589
+ }
590
+ this.config.store.remoteDesktop.accounts = old ? list.map(a => a === old ? entry : a) : [...list, entry];
591
+ await this.config.save();
592
+ this.changed$.next();
529
593
  return { id: entry.id, keychainError };
530
594
  }
531
595
  /** A desktop form's "New account…": saves it and hands it back for the form's list. */
@@ -567,12 +631,16 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
567
631
  async removeAccount(id) {
568
632
  const store = this.config.store.remoteDesktop;
569
633
  store.accounts = this.accounts().filter(a => a.id !== id);
570
- if (this.configuredDesktops().some(d => d.account === id)) {
634
+ if (this.configuredDesktops().some(d => d.account === id || d.gatewayAccount === id)) {
571
635
  store.desktops = this.configuredDesktops().map(d => {
572
- if (d.account !== id) {
573
- return d;
636
+ const rest = { ...d };
637
+ if (rest.account === id) {
638
+ delete rest.account;
639
+ }
640
+ // A gateway that signed in with it takes the desktop's sign-in from now on.
641
+ if (rest.gatewayAccount === id) {
642
+ delete rest.gatewayAccount;
574
643
  }
575
- const { account: _account, ...rest } = d;
576
644
  return rest;
577
645
  });
578
646
  }
@@ -580,10 +648,20 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
580
648
  if (profile?.type === targets_1.RDP_PROFILE_TYPE && profile.options?.account === id) {
581
649
  profile.options.account = '';
582
650
  }
651
+ if (profile?.type === targets_1.RDP_PROFILE_TYPE && profile.options?.gatewayAccount === id) {
652
+ profile.options.gatewayAccount = '';
653
+ }
583
654
  }
584
- this.config.save();
655
+ // A profile group's default for remote desktop profiles, too.
656
+ for (const group of this.config.store.groups ?? []) {
657
+ const options = group?.defaults?.[targets_1.RDP_PROFILE_TYPE]?.options;
658
+ if (options?.account === id) {
659
+ options.account = '';
660
+ }
661
+ }
662
+ await this.config.save();
585
663
  this.changed$.next();
586
- await (0, signin_1.forgetCredentials)((0, accounts_1.accountKey)(id));
664
+ await (0, signin_1.forgetCredentialsOrFail)((0, accounts_1.accountKey)(id)).catch(e => this.notifications.error(`The account is removed, but its password could not be: ${e?.message ?? e}`));
587
665
  }
588
666
  /** `remoteDesktop.desktops`: the desktops configured behind SSH hosts. */
589
667
  configuredDesktops() {
@@ -704,7 +782,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
704
782
  }
705
783
  const moved = (key) => direct ? key === `${desktops_1.DIRECT_KEY}#${fromId}` : key.endsWith(`#${fromId}`) && !key.startsWith(`${desktops_1.DIRECT_KEY}#`);
706
784
  const store = this.config.store.remoteDesktop;
707
- for (const list of ['desktopSharpness', 'trustedCertificates']) {
785
+ for (const list of ['desktopSharpness', 'trustedCertificates', 'withoutNla']) {
708
786
  if (Array.isArray(store[list])) {
709
787
  store[list] = store[list].map((e) => typeof e?.desktop === 'string' && moved(e.desktop)
710
788
  ? { ...e, desktop: `${e.desktop.slice(0, -fromId.length)}${toId}` }
@@ -760,23 +838,53 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
760
838
  return null;
761
839
  }
762
840
  const existing = (this.config.store.profiles ?? []).find((p) => p?.type === targets_1.RDP_PROFILE_TYPE && !p.options?.via &&
763
- p.options?.host === parsed.host && (p.options.port || 3389) === parsed.port && (p.options.username ?? '') === (parsed.username ?? ''));
841
+ p.options?.host === parsed.host && (p.options.port || 3389) === parsed.port && (p.options.username ?? '') === (parsed.username ?? '') &&
842
+ (p.options.gateway ?? '') === (parsed.gateway ?? ''));
764
843
  if (existing) {
765
844
  this.notifications.info(`Opening "${existing.name}", which is already a profile for ${fileName}`);
766
845
  await this.profiles.openNewTabForProfile(existing);
767
846
  return existing;
768
847
  }
848
+ // A file is someone else's word for where to connect: where it leads is said before anything goes there. The
849
+ // account typed into the sign-in form goes to that address, and to the gateway before it.
850
+ const base = fileName.replace(/^.*[\\/]/, '');
851
+ const { response } = await this.platform.showMessageBox({
852
+ type: 'warning',
853
+ message: `Add a remote desktop from ${base}?`,
854
+ detail: `It connects to ${(0, desktopForm_1.formatAddress)(parsed.host, parsed.port)}${parsed.gateway ? `, through the gateway ${parsed.gateway}` : ''}` +
855
+ `${parsed.username ? `, as ${parsed.username}` : ''}. What you sign in with there goes to that address` +
856
+ `${parsed.gateway ? ' and that gateway' : ''}, and once signed in, the desktop gets the clipboard and the folders you share. ` +
857
+ 'Add it only if you know where it leads, or trust who gave you the file.',
858
+ buttons: ['Add and connect', 'Add only', 'Cancel'],
859
+ defaultId: 0,
860
+ cancelId: 2,
861
+ });
862
+ if (response !== 0 && response !== 1) {
863
+ return null;
864
+ }
769
865
  const profile = {
770
866
  type: targets_1.RDP_PROFILE_TYPE,
771
- name: fileName.replace(/^.*[\\/]/, '').replace(/\.rdp$/i, '') || parsed.host,
867
+ name: base.replace(/\.rdp$/i, '') || parsed.host,
772
868
  icon: 'fas fa-desktop',
773
869
  group: await this.remoteDesktopGroup(),
774
- options: { host: parsed.host, port: parsed.port, kind: 'windows', username: parsed.username ?? '', domain: parsed.domain ?? '', via: '' },
870
+ options: { host: parsed.host, port: parsed.port, kind: 'windows', username: parsed.username ?? '', domain: parsed.domain ?? '', via: '', gateway: parsed.gateway ?? '', gatewayAccount: '' },
775
871
  };
776
872
  await this.profiles.newProfile(profile);
873
+ // The file's display scale, as this desktop's own sharpness (the key a direct desktop's settings are kept by).
874
+ if (parsed.sharpness) {
875
+ const store = this.config.store.remoteDesktop;
876
+ const key = `${desktops_1.DIRECT_KEY}#${(0, desktops_1.desktopIdOf)(profile.options)}`;
877
+ store.desktopSharpness = [...(Array.isArray(store.desktopSharpness) ? store.desktopSharpness : []).filter((e) => e?.desktop !== key), { desktop: key, sharpness: parsed.sharpness }];
878
+ }
777
879
  this.config.save();
778
880
  this.notifications.notice(`Added the remote desktop profile "${profile.name}" (Settings › Profiles & connections)`);
779
- await this.profiles.openNewTabForProfile(profile);
881
+ if (parsed.ignored.length) {
882
+ // What the file asked for beyond that, so nobody wonders why the desktop doesn't behave as in mstsc.
883
+ this.notifications.info(`${base} also asks for ${parsed.ignored.join('; ')}. Not applied.`);
884
+ }
885
+ if (response === 0) {
886
+ await this.profiles.openNewTabForProfile(profile);
887
+ }
780
888
  return profile;
781
889
  }
782
890
  /** Asks, then removes a configured desktop (see removeDesktop). The menus and the settings page use this. */
@@ -1095,6 +1203,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1095
1203
  sharpness: store.sharpness === 'retina' ? 'retina' : 'standard',
1096
1204
  desk: store.desk === true,
1097
1205
  macShortcuts: store.macShortcuts !== false,
1206
+ unicodeKeys: store.unicodeKeys === true,
1098
1207
  sound: store.sound !== false,
1099
1208
  h264: store.h264 !== false,
1100
1209
  zoom: store.zoom === 'actual' ? 'actual' : 'fit',
@@ -1106,6 +1215,16 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1106
1215
  shutDownIdle: [5, 15, 60].includes(Number(store.shutDownIdle)) ? Number(store.shutDownIdle) : 0,
1107
1216
  };
1108
1217
  }
1218
+ /** The folders shared with remote desktops as drives (`remoteDesktop.sharedFolders`), tidied. */
1219
+ sharedFolders() {
1220
+ return (0, drives_1.sharedFolders)(this.config.store.remoteDesktop);
1221
+ }
1222
+ /** Replaces the shared folders; applies to desktops connecting from now on. */
1223
+ setSharedFolders(folders) {
1224
+ this.config.store.remoteDesktop.sharedFolders = folders.map(f => ({ path: f.path, name: f.name, readOnly: f.readOnly }));
1225
+ this.config.save();
1226
+ this.changed$.next();
1227
+ }
1109
1228
  /** The sharpness chosen for the pane's desktop in particular (`remoteDesktop.desktopSharpness`), or null. */
1110
1229
  ownSharpness(pane) {
1111
1230
  const key = this.sessions.get(pane)?.key;
@@ -1152,10 +1271,18 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1152
1271
  this.config.save();
1153
1272
  for (const [pane, session] of this.sessions) {
1154
1273
  this.applyZoom(session);
1274
+ this.applyKeyboardMode(session);
1155
1275
  this.syncIndicator(session);
1156
1276
  this.followPane(pane, session);
1157
1277
  }
1158
1278
  }
1279
+ /** IronRDP's keyboard mode for the session: characters (Unicode) or key positions (scancodes), per the setting. */
1280
+ applyKeyboardMode(session) {
1281
+ try {
1282
+ session.ui?.setKeyboardUnicodeMode(this.settings().unicodeKeys);
1283
+ }
1284
+ catch { }
1285
+ }
1159
1286
  /** IronRDP's ScreenScale for the picture: Real (1:1, scrolling) only with a fixed resolution and zoom 'actual'. */
1160
1287
  screenScale() {
1161
1288
  const settings = this.settings();
@@ -1266,6 +1393,14 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1266
1393
  setViewOnly(pane, viewOnly) {
1267
1394
  const session = this.sessions.get(pane);
1268
1395
  if (session) {
1396
+ if (viewOnly) {
1397
+ // From here on this desktop's keys stop at the plugin, their keyups too: let go of what is down, also
1398
+ // on the desktops typing into all had copy them.
1399
+ session.releaseKeys();
1400
+ if (this.isBroadcast(pane)) {
1401
+ this.othersInTab(pane).forEach(other => other.session.releaseKeys());
1402
+ }
1403
+ }
1269
1404
  session.setViewOnly(viewOnly);
1270
1405
  this.changed$.next();
1271
1406
  }
@@ -1403,9 +1538,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1403
1538
  forgetCertificate(key) {
1404
1539
  const store = this.config.store.remoteDesktop;
1405
1540
  store.trustedCertificates = (Array.isArray(store.trustedCertificates) ? store.trustedCertificates : []).filter((e) => e?.desktop !== key);
1541
+ // What else was decided about that server goes with it: it is asked again.
1542
+ store.withoutNla = (Array.isArray(store.withoutNla) ? store.withoutNla : []).filter((e) => e?.desktop !== key);
1406
1543
  this.config.save();
1407
1544
  this.changed$.next();
1408
1545
  }
1546
+ /** Whether the desktop was allowed to sign in without Network Level Authentication (see refusedWithoutNla). */
1547
+ signsInWithoutNla(key) {
1548
+ return this.allowedWithoutNla(key);
1549
+ }
1409
1550
  /** The split tab holding the pane, if it has more than one pane. */
1410
1551
  splitOf(pane) {
1411
1552
  const split = this.app.tabs.find(t => t instanceof tabby_core_1.SplitTabComponent && t.getAllTabs().includes(pane));
@@ -1432,6 +1573,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1432
1573
  if (!split) {
1433
1574
  return;
1434
1575
  }
1576
+ if (!on && this.broadcast.has(split)) {
1577
+ // The keyups of keys held right now (the shortcut's own modifiers) won't be copied any more.
1578
+ this.othersInTab(pane).forEach(other => other.session.releaseKeys());
1579
+ }
1435
1580
  on ? this.broadcast.add(split) : this.broadcast.delete(split);
1436
1581
  for (const p of split.getAllTabs()) {
1437
1582
  const session = this.sessions.get(p);
@@ -1440,14 +1585,19 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1440
1585
  }
1441
1586
  this.changed$.next();
1442
1587
  }
1443
- /** A key event for the other desktops of a tab typing into all (see DesktopKeyboard). */
1588
+ /**
1589
+ * A key the pane's desktop was sent (see DesktopKeyboard): noted as held there or let go, and copied to the other
1590
+ * desktops of a tab typing into all.
1591
+ */
1444
1592
  broadcastKey(pane, type, init) {
1593
+ this.sessions.get(pane)?.noteKey(type, init);
1445
1594
  if (!this.isBroadcast(pane)) {
1446
1595
  return;
1447
1596
  }
1448
1597
  for (const { session } of this.othersInTab(pane)) {
1449
1598
  try {
1450
1599
  session.ui.sendKeyboardEvent(new KeyboardEvent(type, { ...init, cancelable: true }));
1600
+ session.noteKey(type, init);
1451
1601
  }
1452
1602
  catch (e) {
1453
1603
  session.log.push(`typing into all desktops: ${e?.message ?? e}`);
@@ -1577,6 +1727,20 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1577
1727
  spec.port = endpoint.port;
1578
1728
  spec.username ??= endpoint.username;
1579
1729
  }
1730
+ // A Hyper-V VM's console: which session the guest takes right now (enhanced once its Remote Desktop Services
1731
+ // are up, the basic console before that and for guests that have none). The sign-in is the host's.
1732
+ let hyperv;
1733
+ if (spec.hyperv) {
1734
+ session.status(`Connecting to ${spec.name} via ${target.label}…`);
1735
+ const state = await (0, hyperv_1.hyperVState)(target, spec.hyperv);
1736
+ if (!state.running) {
1737
+ throw new Error(`${spec.name} isn't running`);
1738
+ }
1739
+ hyperv = state.enhanced ? 'enhanced' : 'basic';
1740
+ session.log.push(`hyper-v: ${hyperv === 'enhanced' ? 'an enhanced session' : 'the basic console (the guest takes no enhanced session now)'}`);
1741
+ // The SSH user is who to try first: it got to list the VMs.
1742
+ spec.username ??= target.key.replace(/@[^@]*$/, '') || undefined;
1743
+ }
1580
1744
  // A saved account (Settings): its user name, and its password from the keychain. One that has none yet, or that
1581
1745
  // this desktop just refused, is asked for here and saved for every desktop that uses the account.
1582
1746
  const account = spec.account ? this.accounts().find(a => a.id === spec.account) : undefined;
@@ -1584,17 +1748,24 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1584
1748
  session.log.push('sign-in: its saved account no longer exists; asking');
1585
1749
  }
1586
1750
  const again = this.askAgain.delete(pane);
1587
- const saved = retryError || again ? null : await (0, signin_1.loadCredentials)(account ? (0, accounts_1.accountKey)(account.id) : session.key);
1751
+ // A desktop whose account is gone asks, rather than falling back to a login of its own saved earlier.
1752
+ let saved = retryError || again || spec.account && !account ? null : await (0, signin_1.loadCredentials)(account ? (0, accounts_1.accountKey)(account.id) : session.authKey);
1753
+ if (saved && account && saved.username !== (0, accounts_1.signInName)(account)) {
1754
+ // Kept for an earlier user name of the account (its change didn't reach the store): not this one's.
1755
+ session.log.push(`sign-in: the account's saved password is for ${saved.username}, not ${(0, accounts_1.signInName)(account)}; asking`);
1756
+ saved = null;
1757
+ }
1588
1758
  if (saved) {
1589
1759
  if (account) {
1590
1760
  session.log.push(`sign-in: the saved account "${account.name}"`);
1591
1761
  }
1592
- return { host: spec.host, port: spec.port, credentials: account ? { username: (0, accounts_1.signInName)(account), password: saved.password } : saved, remember: false };
1762
+ return { host: spec.host, port: spec.port, credentials: account ? { username: (0, accounts_1.signInName)(account), password: saved.password } : saved, remember: false, domain: account ? account.domain ?? '' : undefined, hyperv };
1593
1763
  }
1594
1764
  session.status('');
1595
1765
  const asked = (0, signin_1.askCredentials)(session.overlay, {
1596
1766
  // The host's own desktops (its own, or its xrdp besides GNOME) and direct ones need no "via".
1597
- 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})`,
1767
+ title: spec.hyperv ? `Sign in to ${target.label}, to open ${spec.name}`
1768
+ : spec.id === desktops_1.OWN_DESKTOP || target.direct || spec.kind === 'xrdp' && spec.host === '127.0.0.1' ? `Sign in to ${spec.name}` : `Sign in to ${spec.name} (via ${target.label})`,
1598
1769
  username: account ? (0, accounts_1.signInName)(account) : spec.username,
1599
1770
  error: retryError,
1600
1771
  canRemember: true,
@@ -1604,7 +1775,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1604
1775
  setTimeout(() => session.visible && session.focusDesktop());
1605
1776
  }
1606
1777
  const entered = await asked;
1607
- return entered && { host: spec.host, port: spec.port, credentials: entered, remember: entered.remember, saveKey: account && (0, accounts_1.accountKey)(account.id) };
1778
+ return entered && {
1779
+ host: spec.host, port: spec.port, credentials: entered, remember: entered.remember, domain: account ? account.domain ?? '' : undefined,
1780
+ saveKey: account && (0, accounts_1.accountKey)(account.id), saveLabel: account && `saved account ${account.name}`, saveFor: account && (0, accounts_1.signInName)(account),
1781
+ hyperv,
1782
+ };
1608
1783
  }
1609
1784
  async connect(pane, target, spec, session) {
1610
1785
  const alive = () => this.sessions.get(pane) === session;
@@ -1615,7 +1790,12 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1615
1790
  let woken = false;
1616
1791
  let startupRetries = 0;
1617
1792
  let endpoint = null;
1793
+ let gatewayError;
1618
1794
  try {
1795
+ const gateway = spec.gateway ? (0, gateway_1.parseGateway)(spec.gateway) : null;
1796
+ if (spec.gateway && !gateway) {
1797
+ throw new Error(`"${spec.gateway}" isn't a gateway's address (a name or address, or name:port)`);
1798
+ }
1619
1799
  if (spec.wake) {
1620
1800
  const woke = await this.wakeIfDown(pane, target, spec, session, automatic);
1621
1801
  if (woke === null) {
@@ -1646,7 +1826,23 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1646
1826
  endpoint = asked;
1647
1827
  session.remote = target;
1648
1828
  session.pinnedCertificate = asked.certificate ?? null;
1649
- 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' });
1829
+ // A gateway with a saved account of its own: its password, asked for when there is none or it was refused.
1830
+ if (gateway && spec.gatewayAccount && !asked.gatewayAccount) {
1831
+ const signIn = await this.gatewayAccountFor(spec, session, gateway, gatewayError);
1832
+ if (!alive()) {
1833
+ return;
1834
+ }
1835
+ if (signIn === null) {
1836
+ session.state = 'ended';
1837
+ this.changed$.next();
1838
+ this.cancelReconnect(pane);
1839
+ session.status('Sign-in cancelled.', [{ label: 'Sign in', run: () => this.reopen(pane, spec) }]);
1840
+ return;
1841
+ }
1842
+ asked.gatewayAccount = signIn;
1843
+ }
1844
+ // The proxy outlives an attempt: it reads the sign-in of the attempt it serves.
1845
+ session.proxy ??= await (0, rdcleanpath_1.startRDCleanPathProxy)(() => gateway ? this.throughGateway(target, spec, session, gateway, endpoint) : target.openTcp(asked.host, asked.port), fingerprint => this.checkCertificate(session, fingerprint), m => session.log.push(m), { autologon: spec.kind === 'xrdp', withoutNla: () => this.checkWithoutNla(session) });
1650
1846
  const outcome = await this.run(pane, target, spec, session, rdp, asked);
1651
1847
  if (outcome.certificate && alive()) {
1652
1848
  // A changed certificate, now trusted: the same account again, without asking for it again.
@@ -1655,10 +1851,23 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1655
1851
  }
1656
1852
  return;
1657
1853
  }
1854
+ if (outcome.withoutNla && alive()) {
1855
+ // Allowed now: the same account again, without asking for it again.
1856
+ if (await this.refusedWithoutNla(pane, target, spec, session)) {
1857
+ continue;
1858
+ }
1859
+ return;
1860
+ }
1861
+ if (outcome.gatewayRefused && asked.gatewayAccount && attempt < 5 && alive()) {
1862
+ // The gateway's own account was refused: asked for again, the desktop's sign-in kept.
1863
+ gatewayError = outcome.signInFailed;
1864
+ asked.gatewayAccount = undefined;
1865
+ continue;
1866
+ }
1658
1867
  if (outcome.signInFailed && this.signsIn(spec) && attempt < 5 && alive()) {
1659
1868
  // The desktop's own saved account goes. A shared one stays until a new password is entered: this
1660
1869
  // desktop refusing it doesn't make it wrong for the others.
1661
- await (0, signin_1.forgetCredentials)(session.key);
1870
+ await (0, signin_1.forgetCredentials)(session.authKey);
1662
1871
  retryError = outcome.signInFailed;
1663
1872
  endpoint = null;
1664
1873
  continue;
@@ -1695,6 +1904,147 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1695
1904
  }
1696
1905
  }
1697
1906
  }
1907
+ /**
1908
+ * The sign-in to a desktop's RD Gateway with the saved account it names: the account's password from the keychain,
1909
+ * or asked for (when none is saved, or `retryError`: the gateway just refused it). Undefined when that account no
1910
+ * longer exists (the desktop's own sign-in is then tried); null when the form was cancelled.
1911
+ */
1912
+ async gatewayAccountFor(spec, session, gateway, retryError) {
1913
+ const account = this.accounts().find(a => a.id === spec.gatewayAccount);
1914
+ if (!account) {
1915
+ session.log.push('gateway: its saved account no longer exists; signing in with the desktop\'s');
1916
+ return undefined;
1917
+ }
1918
+ const signIn = { domain: account.domain, saveKey: (0, accounts_1.accountKey)(account.id), saveLabel: `saved account ${account.name}`, saveFor: (0, accounts_1.signInName)(account) };
1919
+ const saved = retryError ? null : await (0, signin_1.loadCredentials)(signIn.saveKey);
1920
+ if (saved && saved.username === (0, accounts_1.signInName)(account)) {
1921
+ session.log.push(`gateway: the saved account "${account.name}"`);
1922
+ return { ...signIn, credentials: { username: (0, accounts_1.signInName)(account), password: saved.password }, remember: false };
1923
+ }
1924
+ session.status('');
1925
+ const asked = (0, signin_1.askCredentials)(session.overlay, {
1926
+ title: `Sign in to the gateway ${gateway.host}, to reach ${spec.name}`,
1927
+ username: (0, accounts_1.signInName)(account),
1928
+ error: retryError,
1929
+ canRemember: true,
1930
+ account: account.name,
1931
+ }, session.disposed);
1932
+ if (session.visible) {
1933
+ setTimeout(() => session.visible && session.focusDesktop());
1934
+ }
1935
+ const entered = await asked;
1936
+ return entered && { ...signIn, credentials: entered, remember: entered.remember };
1937
+ }
1938
+ /**
1939
+ * The proxy's stream to a desktop behind an RD Gateway (see gateway.ts): to the gateway as the SSH host (or this
1940
+ * computer) reaches it, signed in with the gateway's own account or else the desktop's, then on to the desktop.
1941
+ */
1942
+ async throughGateway(target, spec, session, gateway, endpoint) {
1943
+ session.gatewayRefused = null;
1944
+ const own = endpoint.gatewayAccount;
1945
+ const credentials = own?.credentials ?? endpoint.credentials;
1946
+ session.log.push(`gateway: ${gateway.host}:${gateway.port}, signing in with ${own ? 'its saved account' : 'the desktop\'s sign-in'}`);
1947
+ try {
1948
+ const stream = await (0, gateway_1.openThroughGateway)(() => target.openTcp(gateway.host, gateway.port), gateway, { username: credentials.username, password: credentials.password, domain: (own ? own.domain : endpoint.domain ?? spec.domain) || undefined }, { host: endpoint.host, port: endpoint.port }, (fingerprint, valid) => this.checkGatewayCertificate(session, gateway, fingerprint, valid), { log: m => session.log.push(m), clientName: os.hostname() });
1949
+ // Entered a moment ago and taken by the gateway: kept, while the account is still what it was signed in as.
1950
+ if (own?.remember) {
1951
+ own.remember = false;
1952
+ const accountNow = this.accounts().find(a => (0, accounts_1.accountKey)(a.id) === own.saveKey);
1953
+ if (accountNow && (0, accounts_1.signInName)(accountNow) === own.saveFor) {
1954
+ (0, signin_1.saveCredentials)(own.saveKey, own.credentials, own.saveLabel).then(() => session.log.push(`gateway: saved as the account's password in ${(0, signin_1.storeName)()}`), e => session.log.push(`gateway: ${(0, signin_1.storeName)()}: ${e?.message ?? e}`));
1955
+ }
1956
+ }
1957
+ return stream;
1958
+ }
1959
+ catch (e) {
1960
+ if (e instanceof gateway_1.GatewaySignInError) {
1961
+ session.gatewayRefused = e.message;
1962
+ }
1963
+ throw e;
1964
+ }
1965
+ }
1966
+ /**
1967
+ * A gateway's certificate, before the sign-in goes to it. One that is valid for the gateway's name by this
1968
+ * computer's certificate authorities is accepted (a gateway on the internet has one); any other is trusted on
1969
+ * first use, like a desktop's. Either way it is remembered, by the gateway's address: after a valid one, an invalid
1970
+ * one is a change to decide on, not a first use.
1971
+ */
1972
+ checkGatewayCertificate(session, gateway, raw, valid) {
1973
+ const fingerprint = (0, rdcleanpath_1.normalizeFingerprint)(raw);
1974
+ const address = `${gateway.host}:${gateway.port}`;
1975
+ const key = `gateway#${address}`;
1976
+ const known = this.trustedCertificate(key);
1977
+ if (valid || !known) {
1978
+ if (known !== fingerprint) {
1979
+ this.trustCertificate(key, fingerprint);
1980
+ }
1981
+ session.log.push(`gateway certificate: SHA-256 ${fingerprint}, ${valid ? `valid for ${gateway.host}` : 'remembered (first connection)'}`);
1982
+ return;
1983
+ }
1984
+ if (fingerprint !== known) {
1985
+ session.certificateProblem = { expected: known, actual: fingerprint, pinned: false, gateway: { address, key } };
1986
+ throw new Error(`the gateway's certificate SHA-256 ${fingerprint} is not the one remembered (${known})`);
1987
+ }
1988
+ session.log.push(`gateway certificate: SHA-256 ${fingerprint}, as remembered`);
1989
+ }
1990
+ /** The desktops allowed to sign in without Network Level Authentication (`remoteDesktop.withoutNla`), by session key. */
1991
+ allowedWithoutNla(key) {
1992
+ const list = this.config.store.remoteDesktop?.withoutNla;
1993
+ return (Array.isArray(list) ? list : []).some((e) => e?.desktop === key);
1994
+ }
1995
+ /**
1996
+ * The proxy found a server that doesn't use Network Level Authentication: the password would go to it as it is,
1997
+ * before the server has shown anything but a certificate. That is how xrdp signs in, so an xrdp desktop goes on.
1998
+ * A Windows machine or GNOME Remote Desktop always uses it unless it was turned off, and something posing as one
1999
+ * (the address in a file someone sent) doesn't: those stop here, until the desktop is allowed (refusedWithoutNla).
2000
+ */
2001
+ checkWithoutNla(session) {
2002
+ if (session.spec.kind === 'xrdp') {
2003
+ return;
2004
+ }
2005
+ if (this.allowedWithoutNla(session.authKey)) {
2006
+ session.log.push('sign-in: without Network Level Authentication, as allowed for this desktop');
2007
+ return;
2008
+ }
2009
+ session.withoutNla = true;
2010
+ throw new Error('the server doesn\'t use Network Level Authentication');
2011
+ }
2012
+ /**
2013
+ * After the proxy stopped at a server without Network Level Authentication; nothing but the connection request
2014
+ * was sent to it. Says what going on would mean and asks; automatic reconnecting stops here. Resolves true to
2015
+ * connect again (the desktop is then allowed, and remembered as such).
2016
+ */
2017
+ async refusedWithoutNla(pane, target, spec, session) {
2018
+ this.cancelReconnect(pane);
2019
+ const alive = () => this.sessions.get(pane) === session;
2020
+ session.log.push('sign-in: the server doesn\'t use Network Level Authentication; asking before the password goes to it');
2021
+ if (!session.visible) {
2022
+ this.notifications.notice(`${spec.name}${target.direct ? '' : ` (via ${target.label})`} would get your password as it is. Open that desktop to decide.`);
2023
+ }
2024
+ const allowed = await new Promise(resolve => {
2025
+ session.status(`${spec.name} doesn't use Network Level Authentication, so your password would be sent to it as it is: ` +
2026
+ 'encrypted on the way, but readable to whatever answered there, before it has proved anything. Nothing has been sent.\n\n' +
2027
+ 'A Windows machine asks for Network Level Authentication unless that was turned off on it. A server posing as one doesn\'t, ' +
2028
+ `and neither does xrdp: if ${spec.name} is an xrdp server, set its kind to xrdp instead.`, [
2029
+ { label: 'Send the password anyway', run: () => resolve(true) },
2030
+ { label: 'Cancel', run: () => resolve(false) },
2031
+ ], true);
2032
+ session.disposed.then(() => resolve(false));
2033
+ });
2034
+ if (!alive()) {
2035
+ return false;
2036
+ }
2037
+ if (!allowed) {
2038
+ session.state = 'ended';
2039
+ this.changed$.next();
2040
+ session.status('Not connected: the password was not sent.', [{ label: 'Try again', run: () => this.reopen(pane, spec) }], true);
2041
+ return false;
2042
+ }
2043
+ const store = this.config.store.remoteDesktop;
2044
+ store.withoutNla = [...(Array.isArray(store.withoutNla) ? store.withoutNla : []).filter((e) => e?.desktop !== session.authKey), { desktop: session.authKey }];
2045
+ this.config.save();
2046
+ return true;
2047
+ }
1698
2048
  /**
1699
2049
  * The proxy's check of the server's certificate, before any credentials go out. The host's own desktop must show
1700
2050
  * the certificate its setup reported. Others (behind a host, a Windows host's own, a remote desktop tab's) are
@@ -1714,9 +2064,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1714
2064
  session.log.push(`certificate: SHA-256 ${fingerprint}, as set up`);
1715
2065
  return;
1716
2066
  }
1717
- const known = this.trustedCertificate(session.key);
2067
+ const known = this.trustedCertificate(session.authKey);
1718
2068
  if (!known) {
1719
- this.trustCertificate(session.key, fingerprint);
2069
+ this.trustCertificate(session.authKey, fingerprint);
1720
2070
  session.log.push(`certificate: SHA-256 ${fingerprint}, remembered (first connection)`);
1721
2071
  return;
1722
2072
  }
@@ -1789,13 +2139,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1789
2139
  `Something other than GNOME Remote Desktop may be listening on port ${endpoint.port}.`);
1790
2140
  return false;
1791
2141
  }
2142
+ // The desktop's, or its gateway's.
2143
+ const whose = problem.gateway ? `${spec.name}'s gateway ${problem.gateway.address}` : spec.name;
1792
2144
  if (!session.visible) {
1793
- this.notifications.notice(`The certificate of ${spec.name}${target.direct ? '' : ` (via ${target.label})`} has changed. Open that desktop to decide.`);
2145
+ this.notifications.notice(`The certificate of ${whose}${target.direct ? '' : ` (via ${target.label})`} has changed. Open that desktop to decide.`);
1794
2146
  }
1795
2147
  const trusted = await new Promise(resolve => {
1796
- session.status(`The certificate of ${spec.name} has changed since it was last used. Nothing has been sent to it.\n\n` +
2148
+ session.status(`The certificate of ${whose} has changed since it was last used. Nothing has been sent to it.\n\n` +
1797
2149
  `${fingerprints}\n\nReinstalling the machine or renewing its certificate changes it. If neither happened, ` +
1798
- `something else may be answering at ${spec.host}:${spec.port}.`, [
2150
+ `something else may be answering at ${problem.gateway?.address ?? `${spec.host}:${spec.port}`}.`, [
1799
2151
  { label: 'Trust the new certificate', run: () => resolve(true) },
1800
2152
  { label: 'Cancel', run: () => resolve(false) },
1801
2153
  ], true);
@@ -1808,8 +2160,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1808
2160
  stop('Not connected: the new certificate was not trusted.');
1809
2161
  return false;
1810
2162
  }
1811
- this.trustCertificate(session.key, problem.actual);
1812
- session.log.push(`certificate: SHA-256 ${problem.actual}, trusted instead of ${problem.expected}`);
2163
+ this.trustCertificate(problem.gateway?.key ?? session.authKey, problem.actual);
2164
+ session.log.push(`${problem.gateway ? 'gateway ' : ''}certificate: SHA-256 ${problem.actual}, trusted instead of ${problem.expected}`);
1813
2165
  return true;
1814
2166
  }
1815
2167
  /** The certificate remembered for a desktop behind a host (`remoteDesktop.trustedCertificates`), if any. */
@@ -1833,6 +2185,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1833
2185
  const list = Array.isArray(store.trustedCertificates) ? store.trustedCertificates : [];
1834
2186
  const forget = (key) => direct ? key === `${desktops_1.DIRECT_KEY}#${desktopId}` : key.endsWith(`#${desktopId}`) && !key.startsWith(`${desktops_1.DIRECT_KEY}#`);
1835
2187
  store.trustedCertificates = list.filter((e) => !forget(String(e?.desktop ?? '')));
2188
+ store.withoutNla = (Array.isArray(store.withoutNla) ? store.withoutNla : []).filter((e) => !forget(String(e?.desktop ?? '')));
1836
2189
  }
1837
2190
  /**
1838
2191
  * For a desktop with `wake`: when its RDP server doesn't answer (probed from the SSH host), starts it and waits
@@ -1842,7 +2195,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1842
2195
  async wakeIfDown(pane, target, spec, session, automatic) {
1843
2196
  const alive = () => this.sessions.get(pane) === session;
1844
2197
  session.status(`Connecting to ${spec.name} via ${target.label}…`);
1845
- if (await (0, wake_1.rdpAnswers)(target, spec.host, spec.port)) {
2198
+ if (await (0, wake_1.desktopUp)(target, spec.host, spec.port, spec.wake)) {
1846
2199
  return false;
1847
2200
  }
1848
2201
  if (!alive()) {
@@ -1867,7 +2220,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1867
2220
  const started = Date.now();
1868
2221
  const progress = () => session.status(`Starting ${spec.name}…\nWaiting for it to answer: ${Math.round((Date.now() - started) / 1000)} s`, [cancel]);
1869
2222
  progress();
1870
- const up = await this.zone.runOutsideAngular(() => (0, wake_1.waitForRdp)(target, spec.host, spec.port, WAKE_TIMEOUT_MS, () => cancelled || !alive(), progress));
2223
+ const up = await this.zone.runOutsideAngular(() => (0, wake_1.waitForRdp)(target, spec.host, spec.port, WAKE_TIMEOUT_MS, () => cancelled || !alive(), progress, spec.wake));
1871
2224
  if (up) {
1872
2225
  session.log.push(`wake: answered after ${Math.round((Date.now() - started) / 1000)} s`);
1873
2226
  // Started here, so it can go back off when it's no longer used, if the settings say so (a VM only: a machine
@@ -1889,6 +2242,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1889
2242
  const credentials = endpoint.credentials;
1890
2243
  const alive = () => this.sessions.get(pane) === session;
1891
2244
  session.certificateProblem = null;
2245
+ session.gatewayRefused = null;
2246
+ session.withoutNla = false;
1892
2247
  const el = document.createElement('iron-remote-desktop');
1893
2248
  try {
1894
2249
  el.setAttribute('scale', this.screenScale() === 3 ? 'real' : 'fit');
@@ -1915,13 +2270,14 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1915
2270
  const size = remoteSizeFor(session.overlay.getBoundingClientRect(), settings);
1916
2271
  const { width, height } = size;
1917
2272
  // DOMAIN\user or user@domain go to the server as typed; only the former needs splitting.
1918
- const [domain, username] = /^([^\\]+)\\(.+)$/.exec(credentials.username)?.slice(1) ?? [spec.domain ?? '', credentials.username];
2273
+ const [domain, username] = /^([^\\]+)\\(.+)$/.exec(credentials.username)?.slice(1) ?? [endpoint.domain ?? spec.domain ?? '', credentials.username];
1919
2274
  session.status(spec.id === desktops_1.OWN_DESKTOP ? `Connecting to ${target.label}…`
1920
2275
  : target.direct ? `Connecting to ${spec.name}…` : `Connecting to ${spec.name} via ${target.label}…`);
1921
2276
  const config = session.ui.configBuilder()
1922
2277
  .withUsername(username)
1923
2278
  .withPassword(credentials.password)
1924
- .withDestination(`${spec.id === desktops_1.OWN_DESKTOP ? target.hostname ?? target.label : endpoint.host}:${endpoint.port}`)
2279
+ // The name the server is signed in to as: the SSH host's for its own desktop, and for its VMs' consoles.
2280
+ .withDestination(`${spec.id === desktops_1.OWN_DESKTOP || spec.hyperv ? target.hostname ?? target.label : endpoint.host}:${endpoint.port}`)
1925
2281
  .withProxyAddress(session.proxy.url)
1926
2282
  .withServerDomain(domain)
1927
2283
  .withAuthToken(session.proxy.token)
@@ -1931,9 +2287,18 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1931
2287
  // H.264 in the graphics pipeline, decoded by the browser (hardware-accelerated where it can be). A decoder
1932
2288
  // failure ends the connection, which then reconnects without H.264 (see below). Not for xrdp: it encodes
1933
2289
  // H.264 only in some builds, and without it bitmaps do better there than the pipeline.
2290
+ // A Hyper-V VM's console: the VM's id goes ahead of everything, and the sign-in (the host's) before the RDP
2291
+ // handshake. Its basic console is a picture, keyboard and mouse: no channels for sound, files or resizing.
2292
+ const basic = endpoint.hyperv === 'basic';
2293
+ if (spec.hyperv) {
2294
+ if (typeof rdp.vmConnect !== 'function') {
2295
+ throw new Error('this build of IronRDP has no Hyper-V console support');
2296
+ }
2297
+ config.withExtension(rdp.vmConnect(spec.hyperv, endpoint.hyperv ?? 'basic'));
2298
+ }
1934
2299
  session.h264?.close();
1935
2300
  session.h264 = null;
1936
- if (spec.kind !== 'xrdp' && typeof rdp.graphicsPipeline === 'function' && await this.useH264(rdp, session, settings)) {
2301
+ if (spec.kind !== 'xrdp' && !basic && typeof rdp.graphicsPipeline === 'function' && await this.useH264(rdp, session, settings)) {
1937
2302
  session.h264 = new rdp.WebCodecsH264Decoder({ onFailure: (reason) => session.log.push(`h264: failed: ${reason}`) });
1938
2303
  config.withExtension(rdp.h264Decoder(session.h264));
1939
2304
  }
@@ -1949,19 +2314,29 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1949
2314
  session.graphics = 'automatic graphics';
1950
2315
  }
1951
2316
  session.log.push(`graphics: ${session.graphics}`);
1952
- if (settings.sound) {
2317
+ if (settings.sound && !basic) {
1953
2318
  session.audio?.close();
1954
2319
  session.audio = new audio_1.AudioPlayer();
1955
2320
  config.withExtension(rdp.audioPlayback(session.audio.callback));
1956
2321
  }
1957
2322
  session.mic?.close();
1958
2323
  session.mic = null;
1959
- if (settings.microphone && typeof rdp.audioInput === 'function') {
2324
+ if (settings.microphone && !basic && typeof rdp.audioInput === 'function') {
1960
2325
  const ui = session.ui;
1961
2326
  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)));
1962
2327
  session.mic = mic;
1963
2328
  config.withExtension(rdp.audioInput(mic.callback));
1964
2329
  }
2330
+ // Shared folders as drives (\\tsclient\<name>). GNOME Remote Desktop doesn't serve drives; Windows and xrdp
2331
+ // do. Also skipped with no folder shared: the server then sees no drive device at all.
2332
+ session.drives?.dispose();
2333
+ session.drives = null;
2334
+ const folders = this.sharedFolders();
2335
+ if (folders.length && spec.kind !== 'gnome' && !basic && typeof rdp.driveRedirection === 'function') {
2336
+ session.drives = new drives_1.SharedDrives(folders, m => session.log.push(m));
2337
+ config.withExtension(rdp.driveRedirection(session.drives));
2338
+ session.log.push(`drives: ${folders.map(f => `${f.name}${f.readOnly ? ' (read-only)' : ''}`).join(', ')}`);
2339
+ }
1965
2340
  const built = config.build();
1966
2341
  let info;
1967
2342
  try {
@@ -1972,6 +2347,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1972
2347
  if (session.certificateProblem) {
1973
2348
  return { connected: false, certificate: session.certificateProblem };
1974
2349
  }
2350
+ // The proxy stopped at a server that would get the password as it is (see checkWithoutNla).
2351
+ if (session.withoutNla) {
2352
+ return { connected: false, withoutNla: true };
2353
+ }
2354
+ // Its gateway refused the sign-in, before anything went to the desktop.
2355
+ if (session.gatewayRefused) {
2356
+ session.log.push(`gateway: ${session.gatewayRefused}`);
2357
+ return { connected: false, signInFailed: session.gatewayRefused, gatewayRefused: true };
2358
+ }
1975
2359
  // IronErrorKind: 1 WrongPassword, 2 LogonFailure.
1976
2360
  const kind = typeof e?.kind === 'function' ? e.kind() : undefined;
1977
2361
  const detail = typeof e?.backtrace === 'function' ? e.backtrace() : (e?.message ?? String(e));
@@ -1985,8 +2369,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1985
2369
  return { connected: false };
1986
2370
  }
1987
2371
  session.ui.setVisibility(true);
2372
+ this.applyKeyboardMode(session);
1988
2373
  session.remoteSize = { width, height, scale: 100 };
1989
- if (size.scale !== 100 || spec.kind === 'windows') {
2374
+ if (!basic && (size.scale !== 100 || spec.kind === 'windows')) {
1990
2375
  // Retina: the connection starts unscaled; ask for the matching remote scale right away. Windows also
1991
2376
  // keeps a signed-in session's scale from the last connection: set it either way.
1992
2377
  this.applySize(session, size);
@@ -2000,12 +2385,19 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2000
2385
  this.syncIndicator(session);
2001
2386
  this.changed$.next();
2002
2387
  this.reconnects.delete(pane); // connected again: a later drop starts over
2003
- if (endpoint.remember) {
2004
- (0, signin_1.saveCredentials)(endpoint.saveKey ?? session.key, credentials, endpoint.saveLabel).then(() => session.log.push(`sign-in: saved ${endpoint.saveKey ? 'as the account\'s password ' : ''}in ${(0, signin_1.storeName)()}`), e => session.log.push(`sign-in: ${(0, signin_1.storeName)()}: ${e?.message ?? e}`));
2388
+ // Under a saved account only while that account is still there with the same sign-in name: the prompt may
2389
+ // have been open while it was removed or changed in Settings.
2390
+ const accountNow = endpoint.saveKey ? this.accounts().find(a => (0, accounts_1.accountKey)(a.id) === endpoint.saveKey) : undefined;
2391
+ if (endpoint.remember && endpoint.saveKey && (!accountNow || (0, accounts_1.signInName)(accountNow) !== endpoint.saveFor)) {
2392
+ session.log.push(`sign-in: not saved: the account ${accountNow ? 'changed' : 'was removed'} meanwhile`);
2393
+ }
2394
+ else if (endpoint.remember) {
2395
+ (0, signin_1.saveCredentials)(endpoint.saveKey ?? session.authKey, credentials, endpoint.saveLabel).then(() => session.log.push(`sign-in: saved ${endpoint.saveKey ? 'as the account\'s password ' : ''}in ${(0, signin_1.storeName)()}`), e => session.log.push(`sign-in: ${(0, signin_1.storeName)()}: ${e?.message ?? e}`));
2005
2396
  }
2006
2397
  if (session.visible) {
2007
2398
  session.focusDesktop();
2008
2399
  }
2400
+ const connectedAt = Date.now();
2009
2401
  let end;
2010
2402
  try {
2011
2403
  end = await info.run();
@@ -2020,11 +2412,19 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2020
2412
  this.h264Failed.add(session.key);
2021
2413
  return { connected: true, error: `H.264 decoding failed (${session.h264.failed}); continuing without it` };
2022
2414
  }
2023
- return { connected: true, error: typeof e?.backtrace === 'function' ? e.backtrace().split('\n')[0] : (e?.message ?? String(e)) };
2415
+ const error = typeof e?.backtrace === 'function' ? e.backtrace().split('\n')[0] : (e?.message ?? String(e));
2416
+ // A Hyper-V host signs anyone in that it knows, and ends the connection at once when that account may not
2417
+ // open the VM's console: a refused sign-in in effect, so the form comes back, saying so.
2418
+ if (spec.hyperv && Date.now() - connectedAt < 5000 && /disconnect provider ultimatum/i.test(error)) {
2419
+ session.log.push(`hyper-v: disconnected at once (${error}): the account may not open this VM's console`);
2420
+ return { connected: false, signInFailed: `${target.label} ended the connection at once: this account may not open ${spec.name}'s console. Use an administrator of the host, a member of its Hyper-V Administrators, or an account given access with Grant-VMConnectAccess.` };
2421
+ }
2422
+ return { connected: true, error };
2024
2423
  }
2025
2424
  finally {
2026
- // The server can't close the microphone once the connection is gone.
2425
+ // The server can't close the microphone, or its files, once the connection is gone.
2027
2426
  session.mic?.close();
2427
+ session.drives?.dispose();
2028
2428
  }
2029
2429
  return { connected: true, reason: end?.reason?.() };
2030
2430
  }