tabby-rdp 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +580 -0
  2. package/README.md +252 -34
  3. package/dist/accountForm.js +0 -1
  4. package/dist/accounts.js +0 -1
  5. package/dist/audio.js +0 -1
  6. package/dist/authorities.js +331 -0
  7. package/dist/byteQueue.js +102 -0
  8. package/dist/clipboard.js +106 -0
  9. package/dist/connectionStatus.js +0 -1
  10. package/dist/desk.js +16 -47
  11. package/dist/deskScript.js +196 -6
  12. package/dist/desktop.service.js +1763 -204
  13. package/dist/desktopForm.js +72 -13
  14. package/dist/desktops.js +243 -19
  15. package/dist/drives.js +525 -0
  16. package/dist/fileTransfer.js +1064 -72
  17. package/dist/gateway.js +651 -0
  18. package/dist/header.js +76 -5
  19. package/dist/help.js +34 -13
  20. package/dist/hyperv.js +129 -0
  21. package/dist/index.js +2 -2
  22. package/dist/keyboard.js +0 -1
  23. package/dist/microphone.js +57 -13
  24. package/dist/ntlm.js +225 -0
  25. package/dist/osd.js +56 -15
  26. package/dist/probe.js +15 -3
  27. package/dist/rdcleanpath.js +468 -52
  28. package/dist/rdpFile.js +53 -4
  29. package/dist/rdpProfile.js +75 -22
  30. package/dist/remoteSetup.js +103 -23
  31. package/dist/sessionLog.js +53 -0
  32. package/dist/settingsPage.js +117 -27
  33. package/dist/signin.js +429 -54
  34. package/dist/ssh.js +99 -18
  35. package/dist/sshChannelStream.js +50 -6
  36. package/dist/targets.js +185 -39
  37. package/dist/ui.js +98 -20
  38. package/dist/unshowable.js +41 -0
  39. package/dist/updates.js +37 -2
  40. package/dist/vms.js +36 -5
  41. package/dist/wake.js +31 -12
  42. package/package.json +4 -3
  43. package/remote/trd-pty.py +74 -13
  44. package/vendor/SHA256SUMS +26 -0
  45. package/vendor/iron-remote-desktop-rdp.js +418 -390
  46. package/vendor/ironrdp_web_bg.wasm +0 -0
  47. package/dist/accountForm.js.map +0 -1
  48. package/dist/accounts.js.map +0 -1
  49. package/dist/audio.js.map +0 -1
  50. package/dist/connectionStatus.js.map +0 -1
  51. package/dist/desk.js.map +0 -1
  52. package/dist/deskScript.js.map +0 -1
  53. package/dist/desktop.service.js.map +0 -1
  54. package/dist/desktopForm.js.map +0 -1
  55. package/dist/desktops.js.map +0 -1
  56. package/dist/fileTransfer.js.map +0 -1
  57. package/dist/header.js.map +0 -1
  58. package/dist/help.js.map +0 -1
  59. package/dist/index.js.map +0 -1
  60. package/dist/keyboard.js.map +0 -1
  61. package/dist/microphone.js.map +0 -1
  62. package/dist/osd.js.map +0 -1
  63. package/dist/probe.js.map +0 -1
  64. package/dist/rdcleanpath.js.map +0 -1
  65. package/dist/rdpFile.js.map +0 -1
  66. package/dist/rdpProfile.js.map +0 -1
  67. package/dist/remoteSetup.js.map +0 -1
  68. package/dist/settingsPage.js.map +0 -1
  69. package/dist/signin.js.map +0 -1
  70. package/dist/ssh.js.map +0 -1
  71. package/dist/sshChannelStream.js.map +0 -1
  72. package/dist/targets.js.map +0 -1
  73. package/dist/ui.js.map +0 -1
  74. package/dist/updates.js.map +0 -1
  75. package/dist/vms.js.map +0 -1
  76. package/dist/wake.js.map +0 -1
@@ -5,10 +5,15 @@ exports.formatAddress = formatAddress;
5
5
  exports.askDesktop = askDesktop;
6
6
  const accounts_1 = require("./accounts");
7
7
  const accountForm_1 = require("./accountForm");
8
+ const clipboard_1 = require("./clipboard");
9
+ const desktops_1 = require("./desktops");
10
+ const gateway_1 = require("./gateway");
11
+ const osd_1 = require("./osd");
8
12
  const wake_1 = require("./wake");
9
13
  /** host:port, [v6]:port, or a bare host (RDP's 3389); null if it doesn't look like an address. */
10
14
  function parseAddress(text) {
11
- const m = /^\[([^\]]+)\](?::(\d+))?$|^([^:\s]+)(?::(\d+))?$/.exec(text.trim());
15
+ // No '#' in a host: session and trust keys are split on it (see keyParts).
16
+ const m = /^\[([^\]#]+)\](?::(\d+))?$|^([^:\s#]+)(?::(\d+))?$/.exec(text.trim());
12
17
  const host = m?.[1] ?? m?.[3];
13
18
  const port = Number(m?.[2] ?? m?.[4] ?? 3389);
14
19
  return host && Number.isInteger(port) && port >= 1 && port <= 65535 ? { host, port } : null;
@@ -19,7 +24,7 @@ function formatAddress(host, port) {
19
24
  }
20
25
  /** An entry's `wake` as the form's field shows it: the VM's name, or the MAC address. */
21
26
  function wakeTextOf(entry) {
22
- return entry.wake?.vm ?? entry.wake?.mac ?? '';
27
+ return (0, desktops_1.configText)(entry.wake?.vm) || (0, desktops_1.configText)(entry.wake?.mac);
23
28
  }
24
29
  /**
25
30
  * "Add a desktop behind <host>" and "Edit": a form over the pane (plain DOM, like the sign-in form; uses its styles).
@@ -47,7 +52,10 @@ function askDesktop(pane, options) {
47
52
  <select class="form-control" name="account" title="Saved accounts are added in Settings › Remote Desktop › Accounts"></select>
48
53
  <input class="form-control" name="username" placeholder="User name (optional; asked when connecting)" spellcheck="false">
49
54
  <input class="form-control" name="domain" placeholder="Domain (optional)" spellcheck="false">
55
+ <input class="form-control" name="gateway" placeholder="RD Gateway (optional), as seen from the host, e.g. rdgw.example.com" title="A Remote Desktop Gateway to reach the desktop through (HTTPS, port 443 unless given); the address above is then as the gateway sees it" spellcheck="false">
56
+ <select class="form-control" name="gatewayAccount" title="A gateway that takes another account than the desktop: a saved account for it"></select>
50
57
  <input class="form-control" name="wake" placeholder="Start it when off (optional): libvirt VM name, or MAC address" title="A libvirt VM on the host, started with virsh; or a MAC address to wake over the network from the host" spellcheck="false">
58
+ <select class="form-control" name="clipboard" title="Which ways text, pictures and files copied on either side go between this computer and the desktop. Applies on the next connection"></select>
51
59
  <div class="trd-signin-buttons">
52
60
  <button type="button" class="btn btn-secondary" name="cancel">Cancel</button>
53
61
  <button type="submit" class="btn btn-primary"></button>
@@ -60,24 +68,36 @@ function askDesktop(pane, options) {
60
68
  form.querySelector('button[type=submit]').textContent = options.action;
61
69
  const error = form.querySelector('.trd-signin-error');
62
70
  const initial = options.entry;
63
- field('name').value = initial.name ?? '';
71
+ // Each field as text whatever the entry holds (see configText): a field makes text of what it is given, and a value
72
+ // that can't be made text (a hand edit's, config sync's) would keep the form from opening, and the entry from being
73
+ // put right here.
74
+ field('name').value = (0, desktops_1.configText)(initial.name);
64
75
  const via = field('via');
65
- via.value = initial.via ?? '';
76
+ via.value = (0, desktops_1.configText)(initial.via);
66
77
  via.hidden = !options.hosts;
67
78
  form.querySelector('datalist').append(...(options.hosts ?? []).map(h => new Option(h)));
68
- field('address').value = formatAddress(initial.host ?? '127.0.0.1', Number(initial.port ?? 3389));
79
+ field('address').value = formatAddress((0, desktops_1.configText)(initial.host ?? '127.0.0.1'), (0, osd_1.configNumber)(initial.port ?? 3389));
69
80
  field('kind').value = initial.kind === 'gnome' || initial.kind === 'xrdp' ? initial.kind : 'windows';
70
- field('username').value = initial.username ?? '';
71
- field('domain').value = initial.domain ?? '';
81
+ // A VM saved from the host's list is xrdp on the host's word (see xrdpFromHost), which is a choice of its own here:
82
+ // kept as long as it is chosen, so that the desktop still asks before signing in without NLA. Any kind picked is
83
+ // the user's, xrdp included.
84
+ if ((0, desktops_1.xrdpFromHost)(initial)) {
85
+ const kind = field('kind');
86
+ kind.prepend(new Option(`xrdp, as ${(0, desktops_1.configText)(initial.via) || 'the host'} reported (asks before signing in without NLA)`, ''));
87
+ kind.value = '';
88
+ }
89
+ field('username').value = (0, desktops_1.configText)(initial.username);
90
+ field('domain').value = (0, desktops_1.configText)(initial.domain);
72
91
  field('wake').value = wakeTextOf(initial);
73
92
  // A saved account in place of a user name and domain of its own. Without any saved, the choice isn't shown.
74
93
  const account = field('account');
75
94
  const accounts = options.accounts ?? [];
76
95
  account.append(new Option('Ask for the account when connecting', ''), ...accounts.map(a => new Option(`Sign in with ${a.name} (${(0, accounts_1.signInName)(a)})`, a.id)));
77
- if (initial.account && !accounts.some(a => a.id === initial.account)) {
78
- account.append(new Option('(a saved account that no longer exists)', initial.account));
96
+ const initialAccount = (0, desktops_1.configText)(initial.account);
97
+ if (initialAccount && !accounts.some(a => a.id === initialAccount)) {
98
+ account.append(new Option('(a saved account that no longer exists)', initialAccount));
79
99
  }
80
- account.value = initial.account ?? '';
100
+ account.value = initialAccount;
81
101
  const ownAccount = () => {
82
102
  field('username').hidden = field('domain').hidden = !!account.value && account.value !== '__new__';
83
103
  };
@@ -89,6 +109,26 @@ function askDesktop(pane, options) {
89
109
  account.hidden = account.options.length < 2;
90
110
  }
91
111
  ownAccount();
112
+ // The gateway's own account: offered once there is a gateway and a saved account to choose.
113
+ const gateway = field('gateway');
114
+ const gatewayAccount = field('gatewayAccount');
115
+ gateway.value = (0, desktops_1.configText)(initial.gateway);
116
+ gatewayAccount.append(new Option('Gateway account: the same as the desktop\'s', ''), ...accounts.map(a => new Option(`Gateway account: ${a.name} (${(0, accounts_1.signInName)(a)})`, a.id)));
117
+ const initialGatewayAccount = (0, desktops_1.configText)(initial.gatewayAccount);
118
+ if (initialGatewayAccount && !accounts.some(a => a.id === initialGatewayAccount)) {
119
+ gatewayAccount.append(new Option('(a saved account that no longer exists)', initialGatewayAccount));
120
+ }
121
+ gatewayAccount.value = initialGatewayAccount;
122
+ const gatewayChanged = () => {
123
+ gatewayAccount.hidden = !gateway.value.trim() || gatewayAccount.options.length < 2;
124
+ };
125
+ gateway.addEventListener('input', gatewayChanged);
126
+ gatewayChanged();
127
+ // Its own clipboard sharing, or the setting's (named, as it is now).
128
+ const clipboard = field('clipboard');
129
+ const lower = (mode) => clipboard_1.CLIPBOARD_LABELS[mode].toLowerCase();
130
+ clipboard.append(new Option(`Clipboard: as in Settings (${lower(options.clipboard)})`, ''), ...Object.keys(clipboard_1.CLIPBOARD_LABELS).map(mode => new Option(`Clipboard: ${lower(mode)}`, mode)));
131
+ clipboard.value = (0, clipboard_1.ownClipboard)(initial.clipboard) ?? '';
92
132
  if (getComputedStyle(pane).position === 'static') {
93
133
  pane.style.position = 'relative';
94
134
  }
@@ -129,6 +169,12 @@ function askDesktop(pane, options) {
129
169
  field('address').focus();
130
170
  return;
131
171
  }
172
+ const gatewayText = gateway.value.trim();
173
+ if (gatewayText && !(0, gateway_1.parseGateway)(gatewayText)) {
174
+ error.textContent = 'The gateway should be a name or address, with :port if it isn\'t 443.';
175
+ gateway.focus();
176
+ return;
177
+ }
132
178
  if (options.hosts && !via.value.trim()) {
133
179
  error.textContent = 'Which SSH host is it behind?';
134
180
  via.focus();
@@ -137,6 +183,8 @@ function askDesktop(pane, options) {
137
183
  const username = field('username').value.trim();
138
184
  const domain = field('domain').value.trim();
139
185
  const kind = field('kind').value;
186
+ // '': the kind the host reported, still its word (see above).
187
+ const picked = kind !== '';
140
188
  const wakeText = field('wake').value.trim();
141
189
  // An unchanged wake keeps what the field doesn't show (a Wake-on-LAN broadcast address and port).
142
190
  const wake = wakeText && wakeText === wakeTextOf(initial) ? initial.wake : (0, wake_1.wakeFromText)(wakeText);
@@ -147,17 +195,29 @@ function askDesktop(pane, options) {
147
195
  name,
148
196
  host: address.host,
149
197
  port: address.port,
150
- kind: kind === 'gnome' || kind === 'xrdp' ? kind : 'windows',
198
+ kind: !picked ? initial.kind : kind === 'gnome' || kind === 'xrdp' ? kind : 'windows',
151
199
  username: username || undefined,
152
200
  domain: domain || undefined,
153
201
  account: account.value && account.value !== '__new__' ? account.value : undefined,
154
202
  wake,
203
+ gateway: gatewayText || undefined,
204
+ gatewayAccount: gatewayText && gatewayAccount.value || undefined,
205
+ clipboard: clipboard.value || undefined,
155
206
  };
156
- for (const key of ['username', 'domain', 'account', 'wake']) {
207
+ for (const key of ['username', 'domain', 'account', 'wake', 'gateway', 'gatewayAccount', 'clipboard']) {
157
208
  if (entry[key] === undefined) {
158
209
  delete entry[key];
159
210
  }
160
211
  }
212
+ // A kind left as the host reported it stays marked so; one picked is the user's, and is marked so only where
213
+ // the entry would otherwise be taken for a VM saved before the mark existed (see xrdpFromHost).
214
+ delete entry.kindFromHost;
215
+ if (!picked) {
216
+ entry.kindFromHost = true;
217
+ }
218
+ else if ((0, desktops_1.xrdpFromHost)(entry)) {
219
+ entry.kindFromHost = false;
220
+ }
161
221
  const refused = options.check?.(entry);
162
222
  if (refused) {
163
223
  error.textContent = refused;
@@ -174,4 +234,3 @@ function askDesktop(pane, options) {
174
234
  });
175
235
  });
176
236
  }
177
- //# sourceMappingURL=desktopForm.js.map
package/dist/desktops.js CHANGED
@@ -2,59 +2,140 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DIRECT_KEY = exports.OWN_DESKTOP = void 0;
4
4
  exports.viaMatches = viaMatches;
5
+ exports.configText = configText;
5
6
  exports.specOf = specOf;
7
+ exports.xrdpFromHost = xrdpFromHost;
8
+ exports.hyperVSpec = hyperVSpec;
6
9
  exports.desktopsFor = desktopsFor;
7
10
  exports.desktopIdOf = desktopIdOf;
11
+ exports.isDesktopEntry = isDesktopEntry;
12
+ exports.entryText = entryText;
8
13
  exports.sessionKey = sessionKey;
14
+ exports.xrdpUnasked = xrdpUnasked;
15
+ exports.keyFromConfig = keyFromConfig;
16
+ exports.isTargetKey = isTargetKey;
17
+ exports.legacyKeyFromConfig = legacyKeyFromConfig;
18
+ exports.keyFields = keyFields;
19
+ exports.nestedKey = nestedKey;
20
+ exports.shownKey = shownKey;
21
+ exports.keyUser = keyUser;
22
+ exports.keyParts = keyParts;
23
+ exports.rekeyed = rekeyed;
24
+ const clipboard_1 = require("./clipboard");
25
+ const hyperv_1 = require("./hyperv");
26
+ const osd_1 = require("./osd");
9
27
  const wake_1 = require("./wake");
10
28
  exports.OWN_DESKTOP = 'own';
11
- /** Whether a `via` value names this SSH target: its alias (label), hostname, user@hostname, or full key. */
29
+ /**
30
+ * Whether a `via` value names this SSH target: its alias (label), hostname, user@hostname, or user@hostname:port, as ssh
31
+ * resolved them (or the key itself). Never a machine reached with ssh typed in a host's console: its name, its user and
32
+ * its hostname there are that host's word, which could name any host the user configured desktops behind (and with them
33
+ * their saved accounts). Only its own desktop opens through that host.
34
+ */
12
35
  function viaMatches(via, target) {
13
- const v = via.trim().toLowerCase();
14
- const key = target.key.toLowerCase(); // user@hostname:port
15
- const userHost = key.replace(/:\d+$/, '');
16
- const hostname = userHost.replace(/^[^@]*@/, '');
17
- return !!v && [key, userHost, hostname, target.label.toLowerCase()].includes(v);
36
+ const v = configText(via).trim().toLowerCase();
37
+ if (!v || target.via !== undefined) {
38
+ return false;
39
+ }
40
+ const { user, host, port } = keyFields(target.key);
41
+ // Also what the add form wrote up to 0.5.0 for a user name with an `@` (alice@corp.example): all after the first.
42
+ const written = `${user}@${host}`.replace(/^[^@]*@/, '');
43
+ return [target.key, `${user}@${host}:${port}`, `${user}@${host}`, host, written, target.label].some(name => name.toLowerCase() === v);
44
+ }
45
+ /**
46
+ * A config value as text: a string as it is, a number written out. Anything else is none: String() of an object (a
47
+ * hand edit, config sync) runs its own conversions, which can throw, and every desktop listed with it would fail, as
48
+ * would every menu or list that shows it.
49
+ */
50
+ function configText(value) {
51
+ return typeof value === 'string' ? value : typeof value === 'number' && Number.isFinite(value) ? String(value) : '';
18
52
  }
19
53
  /** The desktop an entry describes (a `remoteDesktop.desktops` entry, or an RDP profile's options), or null if unusable. */
20
54
  function specOf(extra) {
21
- const port = Number(extra?.port || 3389);
55
+ // Its own clipboard sharing, or none (the setting's).
56
+ const clipboard = (0, clipboard_1.ownClipboard)(extra?.clipboard);
57
+ const own = clipboard ? { clipboard } : {};
58
+ const hyperv = (0, hyperv_1.vmId)(extra?.hyperv);
59
+ if (hyperv) {
60
+ return hyperVSpec(hyperv, configText(extra.name) || hyperv, {
61
+ username: configText(extra.username) || undefined,
62
+ domain: configText(extra.domain) || undefined,
63
+ account: configText(extra.account) || undefined,
64
+ ...own,
65
+ });
66
+ }
67
+ // From a number or text only: Number() of an object from the config runs its conversions, and can throw.
68
+ const port = (0, osd_1.configNumber)(extra?.port || 3389);
22
69
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
23
70
  return null;
24
71
  }
25
- const host = String(extra.host || '127.0.0.1');
72
+ const host = extra.host ? configText(extra.host) : '127.0.0.1';
73
+ // No '#' in a host: it is part of the session and trust keys, which are split on '#' (see keyParts).
74
+ if (!host || host.includes('#')) {
75
+ return null;
76
+ }
77
+ const gateway = typeof extra.gateway === 'string' ? extra.gateway.trim() : '';
26
78
  return {
27
79
  id: `${host}:${port}`,
28
- name: extra.name ? String(extra.name) : `${host}:${port}`,
80
+ name: configText(extra.name) || `${host}:${port}`,
29
81
  kind: extra.kind === 'gnome' || extra.kind === 'xrdp' ? extra.kind : 'windows',
82
+ kindTrusted: !xrdpFromHost(extra),
30
83
  host,
31
84
  port,
32
- username: extra.username ? String(extra.username) : undefined,
33
- domain: extra.domain ? String(extra.domain) : undefined,
34
- account: extra.account ? String(extra.account) : undefined,
85
+ username: configText(extra.username) || undefined,
86
+ domain: configText(extra.domain) || undefined,
87
+ account: configText(extra.account) || undefined,
35
88
  wake: (0, wake_1.parseWake)(extra.wake),
89
+ ...gateway ? { gateway, gatewayAccount: configText(extra.gatewayAccount) || undefined } : {},
90
+ ...own,
36
91
  };
37
92
  }
93
+ /**
94
+ * Whether an entry is of the xrdp kind on a host's word rather than the user's (see DesktopSpec.kindTrusted): a VM
95
+ * saved from the host's list as the host reported it (`kindFromHost`). Up to 0.5.0 those were saved without the mark,
96
+ * and one then looks like this: xrdp, started by its name when it is off (`wake.vm`, which the save always set). Such
97
+ * an entry is taken for the host's word too, unless the user has picked its kind in the form since (`kindFromHost:
98
+ * false`); a desktop of the user's that looks the same asks once before signing in without NLA.
99
+ */
100
+ function xrdpFromHost(extra) {
101
+ return extra.kind === 'xrdp' && (extra.kindFromHost === true || extra.kindFromHost === undefined && !!extra.wake?.vm);
102
+ }
103
+ /** A Hyper-V VM's console as a desktop of its host: the host's VMConnect port, the VM's id, started when it's off. */
104
+ function hyperVSpec(id, name, more = {}) {
105
+ return { id: `hyperv:${id}`, name, kind: 'windows', host: '127.0.0.1', port: hyperv_1.HYPERV_PORT, hyperv: id, wake: { hyperv: id }, ...more };
106
+ }
38
107
  /**
39
108
  * The SSH host's own desktop, then the configured desktops behind it, then `more` (RDP profiles going through this
40
109
  * host). `found`: what the setup found there the last time (none yet: GNOME, as the setup will try first). A host
41
110
  * with only xrdp has it as its own desktop; one with both GNOME and xrdp also offers xrdp, as a desktop at its
42
111
  * loopback port. A Windows host's own desktop is its RDP server, signed in to with the Windows account (the SSH user,
43
- * to start with).
112
+ * to start with). `clipboard`: the setting, which desktops without their own follow.
44
113
  */
45
- function desktopsFor(target, extras, found, more = []) {
114
+ function desktopsFor(target, extras, found, more = [], clipboard = 'both') {
46
115
  const own = found?.kind === 'xrdp'
47
116
  ? { id: exports.OWN_DESKTOP, name: `${target.label} desktop`, kind: 'xrdp', host: '127.0.0.1', port: found.xrdpPort ?? 3389, username: found.xrdpUser }
48
117
  : found?.kind === 'windows'
49
- ? { id: exports.OWN_DESKTOP, name: `${target.label} desktop`, kind: 'windows', host: '127.0.0.1', port: 3389, username: target.key.replace(/@[^@]*$/, '') || undefined }
118
+ ? { id: exports.OWN_DESKTOP, name: `${target.label} desktop`, kind: 'windows', host: '127.0.0.1', port: 3389, username: keyUser(target.key) || undefined }
50
119
  : { id: exports.OWN_DESKTOP, name: `${target.label} desktop`, kind: 'gnome', host: '127.0.0.1', port: 0 };
120
+ // What the setup found, the own desktop's kind and the xrdp besides GNOME below, is the host's word about itself,
121
+ // never trusted (see DesktopSpec.kindTrusted).
51
122
  const specs = [own];
52
123
  const behind = (Array.isArray(extras) ? extras : []).filter(extra => extra?.via && viaMatches(extra.via, target));
53
124
  for (const extra of [...behind, ...more]) {
54
125
  const spec = specOf(extra);
55
- if (spec && !specs.some(s => s.id === spec.id)) {
126
+ const kept = spec && specs.find(s => s.id === spec.id);
127
+ if (spec && !kept) {
56
128
  specs.push(spec);
57
129
  }
130
+ else if (spec && kept) {
131
+ // The same desktop configured twice (an entry and an RDP profile through this host, say): the first is the
132
+ // one that opens, and its clipboard goes no further than the other's would. Left following the setting
133
+ // where the other lets as much through.
134
+ const narrower = (0, clipboard_1.narrowest)(kept.clipboard ?? clipboard, spec.clipboard ?? clipboard);
135
+ if (narrower !== (kept.clipboard ?? clipboard)) {
136
+ kept.clipboard = narrower;
137
+ }
138
+ }
58
139
  }
59
140
  // Configured entries come first: one for the same port keeps its name.
60
141
  if (found?.kind === 'gnome' && found.xrdpPort && !specs.some(s => s.id === `127.0.0.1:${found.xrdpPort}`)) {
@@ -71,9 +152,29 @@ function desktopsFor(target, extras, found, more = []) {
71
152
  }
72
153
  /** The target key of direct connections (remote desktop tabs); their session keys are `rdp#<address>`. */
73
154
  exports.DIRECT_KEY = 'rdp';
74
- /** The id of a configured desktop (`host:port`), as its DesktopSpec and session keys have it. */
155
+ /**
156
+ * The id of a configured desktop (`host:port`), as its DesktopSpec and session keys have it (see specOf), whatever the
157
+ * entry holds: a host or port that is neither text nor a number makes an id that no desktop has, and nothing is kept
158
+ * under it.
159
+ */
75
160
  function desktopIdOf(extra) {
76
- return `${extra.host || '127.0.0.1'}:${extra.port || 3389}`;
161
+ const hyperv = (0, hyperv_1.vmId)(extra?.hyperv);
162
+ return hyperv ? `hyperv:${hyperv}` : `${extra?.host ? configText(extra.host) : '127.0.0.1'}:${(0, osd_1.configNumber)(extra?.port || 3389)}`;
163
+ }
164
+ /** Whether an item of `remoteDesktop.desktops` is an entry (a map) at all: anything else describes no desktop. */
165
+ function isDesktopEntry(value) {
166
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
167
+ }
168
+ /**
169
+ * A configured desktop's entry as menus and lists show it: its name, else its address (see desktopIdOf), and the host
170
+ * it is behind ('' for none), each as text whatever the entry holds. A hand edit or config sync can put anything there,
171
+ * and an entry that can't be shown must not take with it the menus and lists that show the others. `hyperv`: whether
172
+ * it is a Hyper-V VM, told as specOf tells one, by its VM's id: not by its address, which a desktop at a host named
173
+ * hyperv starts the same way.
174
+ */
175
+ function entryText(extra) {
176
+ const address = desktopIdOf(extra);
177
+ return { name: configText(extra?.name) || address, address, via: configText(extra?.via), hyperv: !!(0, hyperv_1.vmId)(extra?.hyperv) };
77
178
  }
78
179
  /**
79
180
  * One desktop per key: the remote account for its own desktop, plus the RDP server for one behind it (`rdp#<address>`
@@ -82,4 +183,127 @@ function desktopIdOf(extra) {
82
183
  function sessionKey(target, spec) {
83
184
  return spec.id === exports.OWN_DESKTOP ? target.key : `${target.key}#${spec.id}`;
84
185
  }
85
- //# sourceMappingURL=desktops.js.map
186
+ /**
187
+ * Whether a desktop signs in without Network Level Authentication without being asked about it first, as xrdp does by
188
+ * design: an xrdp desktop whose kind is trusted (see DesktopSpec.kindTrusted). Every other desktop stops before the
189
+ * password goes as it is to a server that doesn't use it.
190
+ */
191
+ function xrdpUnasked(spec) {
192
+ return spec.kind === 'xrdp' && spec.kindTrusted === true;
193
+ }
194
+ /** What keyField writes as `%` and two hex digits, in a key's user, host name and port. */
195
+ const ESCAPED_USER = /[%#>\x00-\x1f\x7f]/g;
196
+ const ESCAPED_HOST = /[%#>@\x00-\x1f\x7f]/g;
197
+ const ESCAPED_PORT = /[%#>@:\x00-\x1f\x7f]/g;
198
+ /**
199
+ * A field of `ssh -G` output, fit for a key, so that whatever ssh replies a key keeps its shape and no two targets get
200
+ * the same one: `%` (the escape itself), `#` (it joins a target's key and a desktop's address, see sessionKey), `>` (an
201
+ * SSH tab's key and a machine reached from there, see nestedKey) and control characters are written as `%25`, `%23`,
202
+ * `%3E`, …; so are an `@` in a host name and an `@` or `:` in a port, which would move where the user or the host name
203
+ * ends (see keyFields). A user's own `@` (alice@corp.example), spaces and an IPv6 address's colons stay as they are.
204
+ */
205
+ function keyField(value, escaped) {
206
+ return value.replace(escaped, c => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`);
207
+ }
208
+ /** A field of `ssh -G` output, from the line that names it. */
209
+ function configField(out, name) {
210
+ return new RegExp(`^${name} (.+)$`, 'm').exec(out)?.[1]?.trim();
211
+ }
212
+ /** A target's key: `user@hostname:port` from `ssh -G` output (config aliases and defaults resolved; see keyOf). */
213
+ function keyFromConfig(out, destination) {
214
+ return keyOf({ user: configField(out, 'user') ?? '', host: configField(out, 'hostname') ?? destination, port: configField(out, 'port') ?? '22' });
215
+ }
216
+ /** The key of a target with this user, host name and port (see keyField; keyFields takes it apart again). */
217
+ function keyOf(fields) {
218
+ return `${keyField(fields.user, ESCAPED_USER)}@${keyField(fields.host.toLowerCase(), ESCAPED_HOST)}:${keyField(fields.port, ESCAPED_PORT)}`;
219
+ }
220
+ /**
221
+ * Whether some target could have this key now, reached directly or with ssh typed in a tab's console (see nestedKey):
222
+ * it reads the same taken apart and put back together. A key as the plugin made it up to 0.5.0 (see
223
+ * legacyKeyFromConfig) that does could be another target's now (a user name `alice%23ops` then, `alice#ops`'s now).
224
+ */
225
+ function isTargetKey(key) {
226
+ const parts = key.split('>');
227
+ return parts.length <= 2 && parts.every(part => keyOf(keyFields(part)) === part);
228
+ }
229
+ /**
230
+ * A target's key as the plugin made it up to 0.5.0: the fields as ssh printed them. It reads as keyFromConfig's does
231
+ * unless a field has a character keyField now writes as `%xx`; what was kept for the target before is under this one
232
+ * (see RemoteTargets.keyChanged, which moves it).
233
+ */
234
+ function legacyKeyFromConfig(out, destination) {
235
+ return `${configField(out, 'user') ?? ''}@${(configField(out, 'hostname') ?? destination).toLowerCase()}:${configField(out, 'port') ?? '22'}`;
236
+ }
237
+ /**
238
+ * The user, host name and port in a target's key (for a machine reached through a host, see nestedKey, that machine's)
239
+ * as ssh resolved them: the user is what comes before the last `@` and the port what comes after the last `:`, since
240
+ * keyField leaves neither in a host name or a port, and every `%` in a field is one keyField wrote.
241
+ */
242
+ function keyFields(key) {
243
+ const fields = key.slice(key.lastIndexOf('>') + 1);
244
+ const at = fields.lastIndexOf('@');
245
+ const colon = fields.lastIndexOf(':');
246
+ const end = colon > at ? colon : fields.length;
247
+ const unescape = (field) => field.replace(/%([0-9A-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
248
+ return { user: unescape(fields.slice(0, Math.max(at, 0))), host: unescape(fields.slice(at + 1, end)), port: unescape(fields.slice(end + 1)) };
249
+ }
250
+ /**
251
+ * The key of a machine reached with `ssh` typed in an SSH tab's console: the tab's key, `>`, and the machine's own as
252
+ * the host there resolves it (`ssh -G`, run there). Everything about that machine comes from that host, which can make
253
+ * it out to be any machine: under the host's own key, what it names is one of its own desktops, never the key (and with
254
+ * it the saved account, the remembered certificate, or the permission to sign in without NLA) of a desktop reached
255
+ * another way.
256
+ */
257
+ function nestedKey(outerKey, configOut, destination) {
258
+ return `${outerKey}>${keyFromConfig(configOut, destination)}`;
259
+ }
260
+ /**
261
+ * A target's key as it reads, for a list of what is kept by it (Settings › Remote Desktop › Certificates): `%`, `#`,
262
+ * `>`, `@` and `:` as they are rather than as keyField writes them (control characters stay written so), and a machine
263
+ * reached through a host (see nestedKey) named first, through that host.
264
+ */
265
+ function shownKey(key) {
266
+ const fields = (part) => part.replace(/%(25|23|3E|40|3A)/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
267
+ const [outer, inner] = key.split('>');
268
+ return inner === undefined ? fields(outer) : `${fields(inner)}, through ${fields(outer)}`;
269
+ }
270
+ /**
271
+ * The user in a target's key, as ssh resolved it (not as the key writes it, see keyField): for a machine reached through
272
+ * a host (see nestedKey), that machine's.
273
+ */
274
+ function keyUser(key) {
275
+ return keyFields(key).user;
276
+ }
277
+ /**
278
+ * A session, trust or credential key split into the target it belongs to and the desktop id after it, so matching is
279
+ * anchored on whole parts rather than an `endsWith('#'+id)` that a different key could also satisfy. A key is
280
+ * `<target>#<id>`: the target is `rdp` for a direct desktop, `gateway` for a gateway certificate, or `user@host:port`
281
+ * for a desktop behind an SSH host (`<tab's key>><user@host:port>` for a machine reached through one, see nestedKey;
282
+ * no target's key has a `#`, see keyField); the own desktop's key is the bare target, with no id. A credential key
283
+ * may carry a gateway suffix (`…@gateway#<host:port>`), which names where a password is sent rather than the desktop,
284
+ * and is returned separately so the id still compares equal.
285
+ */
286
+ function keyParts(key) {
287
+ const hash = key.indexOf('#');
288
+ if (hash < 0) {
289
+ return { target: key, desktopId: '', gateway: '' };
290
+ }
291
+ const rest = key.slice(hash + 1);
292
+ const at = rest.indexOf('@gateway#');
293
+ return at < 0
294
+ ? { target: key.slice(0, hash), desktopId: rest, gateway: '' }
295
+ : { target: key.slice(0, hash), desktopId: rest.slice(0, at), gateway: rest.slice(at) };
296
+ }
297
+ /**
298
+ * A session, trust or credential key of the target `from`, as the same key of `to` (see RemoteTargets.keyChanged,
299
+ * which moves what was kept under a target's former key): `from` itself (its own desktop), or `from#<id>`, with a
300
+ * gateway suffix on a saved password's key. Null for any other key. Desktop ids have no `#` (see specOf), so a key that
301
+ * has more of them isn't one of `from`'s desktops, whatever it starts with.
302
+ */
303
+ function rekeyed(key, from, to) {
304
+ if (key === from) {
305
+ return to;
306
+ }
307
+ const rest = key.startsWith(`${from}#`) ? key.slice(from.length + 1) : null;
308
+ return rest !== null && /^[^#]*(@gateway#[^#]*)?$/.test(rest) ? `${to}#${rest}` : null;
309
+ }