tabby-rdp 0.4.1 → 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);
@@ -438,13 +481,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
438
481
  if (known && Date.now() - known.at < 60000) {
439
482
  return Promise.resolve();
440
483
  }
441
- const scan = (0, vms_1.scanVMs)(target).then(found => {
442
- 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 });
443
487
  this.changed$.next();
444
488
  }, () => {
445
- 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 });
446
491
  });
447
- 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 });
448
493
  return scan;
449
494
  }
450
495
  /** Keeps a found VM open in the pane among the host's desktops (`remoteDesktop.desktops`), under its name. */
@@ -454,7 +499,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
454
499
  if (!session?.remote || !spec?.found) {
455
500
  return;
456
501
  }
457
- 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 } : {} };
458
504
  this.config.store.remoteDesktop.desktops = [...this.configuredDesktops(), entry];
459
505
  this.config.save();
460
506
  this.changed$.next();
@@ -491,15 +537,16 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
491
537
  return (0, accounts_1.accountsOf)(this.config.store.remoteDesktop);
492
538
  }
493
539
  /**
494
- * The desktops that sign in with a saved account: configured ones behind SSH hosts, and RDP profiles (their own
495
- * 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.
496
543
  */
497
544
  accountUses(id) {
498
- 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 }] : []);
499
546
  const groups = this.config.store.groups ?? [];
500
547
  const inherited = (p) => p.options?.account === undefined && groups.find(g => g.id === p.group)?.defaults?.[targets_1.RDP_PROFILE_TYPE]?.options?.account === id;
501
548
  const profiles = (this.config.store.profiles ?? [])
502
- .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)))
503
550
  .map((p) => ({ name: String(p.name ?? p.options?.host ?? ''), profileId: p.id ? String(p.id) : undefined }));
504
551
  return [...desktops, ...profiles];
505
552
  }
@@ -584,12 +631,16 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
584
631
  async removeAccount(id) {
585
632
  const store = this.config.store.remoteDesktop;
586
633
  store.accounts = this.accounts().filter(a => a.id !== id);
587
- if (this.configuredDesktops().some(d => d.account === id)) {
634
+ if (this.configuredDesktops().some(d => d.account === id || d.gatewayAccount === id)) {
588
635
  store.desktops = this.configuredDesktops().map(d => {
589
- if (d.account !== id) {
590
- 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;
591
643
  }
592
- const { account: _account, ...rest } = d;
593
644
  return rest;
594
645
  });
595
646
  }
@@ -597,6 +648,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
597
648
  if (profile?.type === targets_1.RDP_PROFILE_TYPE && profile.options?.account === id) {
598
649
  profile.options.account = '';
599
650
  }
651
+ if (profile?.type === targets_1.RDP_PROFILE_TYPE && profile.options?.gatewayAccount === id) {
652
+ profile.options.gatewayAccount = '';
653
+ }
600
654
  }
601
655
  // A profile group's default for remote desktop profiles, too.
602
656
  for (const group of this.config.store.groups ?? []) {
@@ -728,7 +782,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
728
782
  }
729
783
  const moved = (key) => direct ? key === `${desktops_1.DIRECT_KEY}#${fromId}` : key.endsWith(`#${fromId}`) && !key.startsWith(`${desktops_1.DIRECT_KEY}#`);
730
784
  const store = this.config.store.remoteDesktop;
731
- for (const list of ['desktopSharpness', 'trustedCertificates']) {
785
+ for (const list of ['desktopSharpness', 'trustedCertificates', 'withoutNla']) {
732
786
  if (Array.isArray(store[list])) {
733
787
  store[list] = store[list].map((e) => typeof e?.desktop === 'string' && moved(e.desktop)
734
788
  ? { ...e, desktop: `${e.desktop.slice(0, -fromId.length)}${toId}` }
@@ -784,23 +838,53 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
784
838
  return null;
785
839
  }
786
840
  const existing = (this.config.store.profiles ?? []).find((p) => p?.type === targets_1.RDP_PROFILE_TYPE && !p.options?.via &&
787
- 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 ?? ''));
788
843
  if (existing) {
789
844
  this.notifications.info(`Opening "${existing.name}", which is already a profile for ${fileName}`);
790
845
  await this.profiles.openNewTabForProfile(existing);
791
846
  return existing;
792
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
+ }
793
865
  const profile = {
794
866
  type: targets_1.RDP_PROFILE_TYPE,
795
- name: fileName.replace(/^.*[\\/]/, '').replace(/\.rdp$/i, '') || parsed.host,
867
+ name: base.replace(/\.rdp$/i, '') || parsed.host,
796
868
  icon: 'fas fa-desktop',
797
869
  group: await this.remoteDesktopGroup(),
798
- 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: '' },
799
871
  };
800
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
+ }
801
879
  this.config.save();
802
880
  this.notifications.notice(`Added the remote desktop profile "${profile.name}" (Settings › Profiles & connections)`);
803
- 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
+ }
804
888
  return profile;
805
889
  }
806
890
  /** Asks, then removes a configured desktop (see removeDesktop). The menus and the settings page use this. */
@@ -1119,6 +1203,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1119
1203
  sharpness: store.sharpness === 'retina' ? 'retina' : 'standard',
1120
1204
  desk: store.desk === true,
1121
1205
  macShortcuts: store.macShortcuts !== false,
1206
+ unicodeKeys: store.unicodeKeys === true,
1122
1207
  sound: store.sound !== false,
1123
1208
  h264: store.h264 !== false,
1124
1209
  zoom: store.zoom === 'actual' ? 'actual' : 'fit',
@@ -1130,6 +1215,16 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1130
1215
  shutDownIdle: [5, 15, 60].includes(Number(store.shutDownIdle)) ? Number(store.shutDownIdle) : 0,
1131
1216
  };
1132
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
+ }
1133
1228
  /** The sharpness chosen for the pane's desktop in particular (`remoteDesktop.desktopSharpness`), or null. */
1134
1229
  ownSharpness(pane) {
1135
1230
  const key = this.sessions.get(pane)?.key;
@@ -1176,10 +1271,18 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1176
1271
  this.config.save();
1177
1272
  for (const [pane, session] of this.sessions) {
1178
1273
  this.applyZoom(session);
1274
+ this.applyKeyboardMode(session);
1179
1275
  this.syncIndicator(session);
1180
1276
  this.followPane(pane, session);
1181
1277
  }
1182
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
+ }
1183
1286
  /** IronRDP's ScreenScale for the picture: Real (1:1, scrolling) only with a fixed resolution and zoom 'actual'. */
1184
1287
  screenScale() {
1185
1288
  const settings = this.settings();
@@ -1290,6 +1393,14 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1290
1393
  setViewOnly(pane, viewOnly) {
1291
1394
  const session = this.sessions.get(pane);
1292
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
+ }
1293
1404
  session.setViewOnly(viewOnly);
1294
1405
  this.changed$.next();
1295
1406
  }
@@ -1427,9 +1538,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1427
1538
  forgetCertificate(key) {
1428
1539
  const store = this.config.store.remoteDesktop;
1429
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);
1430
1543
  this.config.save();
1431
1544
  this.changed$.next();
1432
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
+ }
1433
1550
  /** The split tab holding the pane, if it has more than one pane. */
1434
1551
  splitOf(pane) {
1435
1552
  const split = this.app.tabs.find(t => t instanceof tabby_core_1.SplitTabComponent && t.getAllTabs().includes(pane));
@@ -1456,6 +1573,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1456
1573
  if (!split) {
1457
1574
  return;
1458
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
+ }
1459
1580
  on ? this.broadcast.add(split) : this.broadcast.delete(split);
1460
1581
  for (const p of split.getAllTabs()) {
1461
1582
  const session = this.sessions.get(p);
@@ -1464,14 +1585,19 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1464
1585
  }
1465
1586
  this.changed$.next();
1466
1587
  }
1467
- /** 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
+ */
1468
1592
  broadcastKey(pane, type, init) {
1593
+ this.sessions.get(pane)?.noteKey(type, init);
1469
1594
  if (!this.isBroadcast(pane)) {
1470
1595
  return;
1471
1596
  }
1472
1597
  for (const { session } of this.othersInTab(pane)) {
1473
1598
  try {
1474
1599
  session.ui.sendKeyboardEvent(new KeyboardEvent(type, { ...init, cancelable: true }));
1600
+ session.noteKey(type, init);
1475
1601
  }
1476
1602
  catch (e) {
1477
1603
  session.log.push(`typing into all desktops: ${e?.message ?? e}`);
@@ -1601,6 +1727,20 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1601
1727
  spec.port = endpoint.port;
1602
1728
  spec.username ??= endpoint.username;
1603
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
+ }
1604
1744
  // A saved account (Settings): its user name, and its password from the keychain. One that has none yet, or that
1605
1745
  // this desktop just refused, is asked for here and saved for every desktop that uses the account.
1606
1746
  const account = spec.account ? this.accounts().find(a => a.id === spec.account) : undefined;
@@ -1609,7 +1749,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1609
1749
  }
1610
1750
  const again = this.askAgain.delete(pane);
1611
1751
  // A desktop whose account is gone asks, rather than falling back to a login of its own saved earlier.
1612
- let saved = retryError || again || spec.account && !account ? null : await (0, signin_1.loadCredentials)(account ? (0, accounts_1.accountKey)(account.id) : session.key);
1752
+ let saved = retryError || again || spec.account && !account ? null : await (0, signin_1.loadCredentials)(account ? (0, accounts_1.accountKey)(account.id) : session.authKey);
1613
1753
  if (saved && account && saved.username !== (0, accounts_1.signInName)(account)) {
1614
1754
  // Kept for an earlier user name of the account (its change didn't reach the store): not this one's.
1615
1755
  session.log.push(`sign-in: the account's saved password is for ${saved.username}, not ${(0, accounts_1.signInName)(account)}; asking`);
@@ -1619,12 +1759,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1619
1759
  if (account) {
1620
1760
  session.log.push(`sign-in: the saved account "${account.name}"`);
1621
1761
  }
1622
- return { host: spec.host, port: spec.port, credentials: account ? { username: (0, accounts_1.signInName)(account), password: saved.password } : saved, remember: false, domain: account ? account.domain ?? '' : undefined };
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 };
1623
1763
  }
1624
1764
  session.status('');
1625
1765
  const asked = (0, signin_1.askCredentials)(session.overlay, {
1626
1766
  // The host's own desktops (its own, or its xrdp besides GNOME) and direct ones need no "via".
1627
- 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})`,
1628
1769
  username: account ? (0, accounts_1.signInName)(account) : spec.username,
1629
1770
  error: retryError,
1630
1771
  canRemember: true,
@@ -1637,6 +1778,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1637
1778
  return entered && {
1638
1779
  host: spec.host, port: spec.port, credentials: entered, remember: entered.remember, domain: account ? account.domain ?? '' : undefined,
1639
1780
  saveKey: account && (0, accounts_1.accountKey)(account.id), saveLabel: account && `saved account ${account.name}`, saveFor: account && (0, accounts_1.signInName)(account),
1781
+ hyperv,
1640
1782
  };
1641
1783
  }
1642
1784
  async connect(pane, target, spec, session) {
@@ -1648,7 +1790,12 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1648
1790
  let woken = false;
1649
1791
  let startupRetries = 0;
1650
1792
  let endpoint = null;
1793
+ let gatewayError;
1651
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
+ }
1652
1799
  if (spec.wake) {
1653
1800
  const woke = await this.wakeIfDown(pane, target, spec, session, automatic);
1654
1801
  if (woke === null) {
@@ -1679,7 +1826,23 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1679
1826
  endpoint = asked;
1680
1827
  session.remote = target;
1681
1828
  session.pinnedCertificate = asked.certificate ?? null;
1682
- 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) });
1683
1846
  const outcome = await this.run(pane, target, spec, session, rdp, asked);
1684
1847
  if (outcome.certificate && alive()) {
1685
1848
  // A changed certificate, now trusted: the same account again, without asking for it again.
@@ -1688,10 +1851,23 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1688
1851
  }
1689
1852
  return;
1690
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
+ }
1691
1867
  if (outcome.signInFailed && this.signsIn(spec) && attempt < 5 && alive()) {
1692
1868
  // The desktop's own saved account goes. A shared one stays until a new password is entered: this
1693
1869
  // desktop refusing it doesn't make it wrong for the others.
1694
- await (0, signin_1.forgetCredentials)(session.key);
1870
+ await (0, signin_1.forgetCredentials)(session.authKey);
1695
1871
  retryError = outcome.signInFailed;
1696
1872
  endpoint = null;
1697
1873
  continue;
@@ -1728,6 +1904,147 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1728
1904
  }
1729
1905
  }
1730
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
+ }
1731
2048
  /**
1732
2049
  * The proxy's check of the server's certificate, before any credentials go out. The host's own desktop must show
1733
2050
  * the certificate its setup reported. Others (behind a host, a Windows host's own, a remote desktop tab's) are
@@ -1747,9 +2064,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1747
2064
  session.log.push(`certificate: SHA-256 ${fingerprint}, as set up`);
1748
2065
  return;
1749
2066
  }
1750
- const known = this.trustedCertificate(session.key);
2067
+ const known = this.trustedCertificate(session.authKey);
1751
2068
  if (!known) {
1752
- this.trustCertificate(session.key, fingerprint);
2069
+ this.trustCertificate(session.authKey, fingerprint);
1753
2070
  session.log.push(`certificate: SHA-256 ${fingerprint}, remembered (first connection)`);
1754
2071
  return;
1755
2072
  }
@@ -1822,13 +2139,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1822
2139
  `Something other than GNOME Remote Desktop may be listening on port ${endpoint.port}.`);
1823
2140
  return false;
1824
2141
  }
2142
+ // The desktop's, or its gateway's.
2143
+ const whose = problem.gateway ? `${spec.name}'s gateway ${problem.gateway.address}` : spec.name;
1825
2144
  if (!session.visible) {
1826
- 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.`);
1827
2146
  }
1828
2147
  const trusted = await new Promise(resolve => {
1829
- 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` +
1830
2149
  `${fingerprints}\n\nReinstalling the machine or renewing its certificate changes it. If neither happened, ` +
1831
- `something else may be answering at ${spec.host}:${spec.port}.`, [
2150
+ `something else may be answering at ${problem.gateway?.address ?? `${spec.host}:${spec.port}`}.`, [
1832
2151
  { label: 'Trust the new certificate', run: () => resolve(true) },
1833
2152
  { label: 'Cancel', run: () => resolve(false) },
1834
2153
  ], true);
@@ -1841,8 +2160,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1841
2160
  stop('Not connected: the new certificate was not trusted.');
1842
2161
  return false;
1843
2162
  }
1844
- this.trustCertificate(session.key, problem.actual);
1845
- 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}`);
1846
2165
  return true;
1847
2166
  }
1848
2167
  /** The certificate remembered for a desktop behind a host (`remoteDesktop.trustedCertificates`), if any. */
@@ -1866,6 +2185,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1866
2185
  const list = Array.isArray(store.trustedCertificates) ? store.trustedCertificates : [];
1867
2186
  const forget = (key) => direct ? key === `${desktops_1.DIRECT_KEY}#${desktopId}` : key.endsWith(`#${desktopId}`) && !key.startsWith(`${desktops_1.DIRECT_KEY}#`);
1868
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 ?? '')));
1869
2189
  }
1870
2190
  /**
1871
2191
  * For a desktop with `wake`: when its RDP server doesn't answer (probed from the SSH host), starts it and waits
@@ -1875,7 +2195,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1875
2195
  async wakeIfDown(pane, target, spec, session, automatic) {
1876
2196
  const alive = () => this.sessions.get(pane) === session;
1877
2197
  session.status(`Connecting to ${spec.name} via ${target.label}…`);
1878
- 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)) {
1879
2199
  return false;
1880
2200
  }
1881
2201
  if (!alive()) {
@@ -1900,7 +2220,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1900
2220
  const started = Date.now();
1901
2221
  const progress = () => session.status(`Starting ${spec.name}…\nWaiting for it to answer: ${Math.round((Date.now() - started) / 1000)} s`, [cancel]);
1902
2222
  progress();
1903
- 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));
1904
2224
  if (up) {
1905
2225
  session.log.push(`wake: answered after ${Math.round((Date.now() - started) / 1000)} s`);
1906
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
@@ -1922,6 +2242,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1922
2242
  const credentials = endpoint.credentials;
1923
2243
  const alive = () => this.sessions.get(pane) === session;
1924
2244
  session.certificateProblem = null;
2245
+ session.gatewayRefused = null;
2246
+ session.withoutNla = false;
1925
2247
  const el = document.createElement('iron-remote-desktop');
1926
2248
  try {
1927
2249
  el.setAttribute('scale', this.screenScale() === 3 ? 'real' : 'fit');
@@ -1954,7 +2276,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1954
2276
  const config = session.ui.configBuilder()
1955
2277
  .withUsername(username)
1956
2278
  .withPassword(credentials.password)
1957
- .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}`)
1958
2281
  .withProxyAddress(session.proxy.url)
1959
2282
  .withServerDomain(domain)
1960
2283
  .withAuthToken(session.proxy.token)
@@ -1964,9 +2287,18 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1964
2287
  // H.264 in the graphics pipeline, decoded by the browser (hardware-accelerated where it can be). A decoder
1965
2288
  // failure ends the connection, which then reconnects without H.264 (see below). Not for xrdp: it encodes
1966
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
+ }
1967
2299
  session.h264?.close();
1968
2300
  session.h264 = null;
1969
- 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)) {
1970
2302
  session.h264 = new rdp.WebCodecsH264Decoder({ onFailure: (reason) => session.log.push(`h264: failed: ${reason}`) });
1971
2303
  config.withExtension(rdp.h264Decoder(session.h264));
1972
2304
  }
@@ -1982,19 +2314,29 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1982
2314
  session.graphics = 'automatic graphics';
1983
2315
  }
1984
2316
  session.log.push(`graphics: ${session.graphics}`);
1985
- if (settings.sound) {
2317
+ if (settings.sound && !basic) {
1986
2318
  session.audio?.close();
1987
2319
  session.audio = new audio_1.AudioPlayer();
1988
2320
  config.withExtension(rdp.audioPlayback(session.audio.callback));
1989
2321
  }
1990
2322
  session.mic?.close();
1991
2323
  session.mic = null;
1992
- if (settings.microphone && typeof rdp.audioInput === 'function') {
2324
+ if (settings.microphone && !basic && typeof rdp.audioInput === 'function') {
1993
2325
  const ui = session.ui;
1994
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)));
1995
2327
  session.mic = mic;
1996
2328
  config.withExtension(rdp.audioInput(mic.callback));
1997
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
+ }
1998
2340
  const built = config.build();
1999
2341
  let info;
2000
2342
  try {
@@ -2005,6 +2347,15 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2005
2347
  if (session.certificateProblem) {
2006
2348
  return { connected: false, certificate: session.certificateProblem };
2007
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
+ }
2008
2359
  // IronErrorKind: 1 WrongPassword, 2 LogonFailure.
2009
2360
  const kind = typeof e?.kind === 'function' ? e.kind() : undefined;
2010
2361
  const detail = typeof e?.backtrace === 'function' ? e.backtrace() : (e?.message ?? String(e));
@@ -2018,8 +2369,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2018
2369
  return { connected: false };
2019
2370
  }
2020
2371
  session.ui.setVisibility(true);
2372
+ this.applyKeyboardMode(session);
2021
2373
  session.remoteSize = { width, height, scale: 100 };
2022
- if (size.scale !== 100 || spec.kind === 'windows') {
2374
+ if (!basic && (size.scale !== 100 || spec.kind === 'windows')) {
2023
2375
  // Retina: the connection starts unscaled; ask for the matching remote scale right away. Windows also
2024
2376
  // keeps a signed-in session's scale from the last connection: set it either way.
2025
2377
  this.applySize(session, size);
@@ -2040,11 +2392,12 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2040
2392
  session.log.push(`sign-in: not saved: the account ${accountNow ? 'changed' : 'was removed'} meanwhile`);
2041
2393
  }
2042
2394
  else if (endpoint.remember) {
2043
- (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}`));
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}`));
2044
2396
  }
2045
2397
  if (session.visible) {
2046
2398
  session.focusDesktop();
2047
2399
  }
2400
+ const connectedAt = Date.now();
2048
2401
  let end;
2049
2402
  try {
2050
2403
  end = await info.run();
@@ -2059,11 +2412,19 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
2059
2412
  this.h264Failed.add(session.key);
2060
2413
  return { connected: true, error: `H.264 decoding failed (${session.h264.failed}); continuing without it` };
2061
2414
  }
2062
- 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 };
2063
2423
  }
2064
2424
  finally {
2065
- // 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.
2066
2426
  session.mic?.close();
2427
+ session.drives?.dispose();
2067
2428
  }
2068
2429
  return { connected: true, reason: end?.reason?.() };
2069
2430
  }