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
package/dist/header.js CHANGED
@@ -23,10 +23,28 @@ const ui_1 = require("./ui");
23
23
  const ICON_DESKTOP = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 352c0 35.3 28.7 64 64 64l176 0-10.7 32L160 448c-17.7 0-32 14.3-32 32s14.3 32 32 32l256 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-69.3 0L336 416l176 0c35.3 0 64-28.7 64-64l0-288c0-35.3-28.7-64-64-64L64 0zM512 64l0 224L64 288 64 64l448 0z"/></svg>';
24
24
  const ICON_TERMINAL = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M9.4 86.6C-3.1 74.1-3.1 53.9 9.4 41.4s32.8-12.5 45.3 0l192 192c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L178.7 256 9.4 86.6zM256 416l288 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-288 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z"/></svg>';
25
25
  const ICON_DISCONNECT = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M64 80c-8.8 0-16 7.2-16 16l0 320c0 8.8 7.2 16 16 16l384 0c8.8 0 16-7.2 16-16l0-320c0-8.8-7.2-16-16-16L64 80zM0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm175 79c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z"/></svg>';
26
+ /** What the microphone indicator says about the desktops sent the microphone. */
27
+ function microphoneTitle(users) {
28
+ const names = users.map(u => u.name);
29
+ const which = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]} are` : `${names[0]} is`;
30
+ return `Microphone in use: ${which} receiving it. Click to show the desktop, or to stop sending the microphone there.`;
31
+ }
32
+ /**
33
+ * The microphone over the window (see HeaderControls.float): like the dot on a desktop, but one that can be clicked, so
34
+ * it takes the clicks on what it covers of the desktop in view. At the top in the middle, where a maximized window's
35
+ * title bar has its title (GNOME) or only room to drag it by (Windows), and GNOME's top bar its clock, all wider than
36
+ * it, rather than in the corner, where they have their close button and system menu. Windows' own Remote Desktop
37
+ * client puts its bar there in full screen too.
38
+ */
39
+ const FLOATING_MICROPHONE_STYLE = 'position: fixed; top: 8px; left: 50%; transform: translateX(-50%); z-index: 1040; width: 26px; height: 26px; padding: 0; ' +
40
+ 'border: none; border-radius: 50%; display: flex; align-items: center; justify-content: center; ' +
41
+ 'background: rgba(220, 38, 38, 0.9); color: #fff; font-size: 12px; cursor: pointer; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);';
26
42
  /**
27
43
  * Desktop/console switch and Disconnect in Tabby's header, left of the settings gear, for the active
28
44
  * tab's SSH pane. Tabby builds its own header buttons once at startup, so these are plain DOM kept in
29
- * sync with the active tab and the desktop state.
45
+ * sync with the active tab and the desktop state. Also a red microphone while any desktop is sent the
46
+ * microphone, whatever tab is in front: the dot on a desktop is out of sight while it is hidden. Without
47
+ * a header (full screen hides Tabby's tab bar), that one shows over the window instead, at its top in the middle.
30
48
  */
31
49
  let HeaderControls = class HeaderControls {
32
50
  constructor(app, desktop, targets, platform, keyboard, zone, help) {
@@ -38,6 +56,8 @@ let HeaderControls = class HeaderControls {
38
56
  this.zone = zone;
39
57
  this.help = help;
40
58
  this.group = null;
59
+ /** The microphone over the window, while it shows (see float). */
60
+ this.floatingMicrophone = null;
41
61
  this.scheduled = false;
42
62
  }
43
63
  // All watching and DOM syncing runs outside Angular's zone: inside it, every observer callback and
@@ -137,21 +157,33 @@ let HeaderControls = class HeaderControls {
137
157
  });
138
158
  this.disconnectButton.title = 'Disconnect remote desktop';
139
159
  this.disconnectButton.innerHTML = ICON_DISCONNECT;
140
- this.group.append(this.toggleButton, this.disconnectButton);
160
+ this.microphoneButton = button('trd-header-mic', () => this.microphoneMenu());
161
+ this.microphoneButton.innerHTML = '<i class="fas fa-microphone"></i>';
162
+ // Inline: Tabby's header button color is scoped to its component and would win over a plugin style.
163
+ this.microphoneButton.style.color = 'rgb(220, 38, 38)';
164
+ this.group.append(this.microphoneButton, this.toggleButton, this.disconnectButton);
141
165
  right.insertBefore(this.group, right.firstChild);
142
166
  return true;
143
167
  }
144
168
  refresh() {
145
- if (!this.ensureGroup()) {
169
+ const users = this.desktop.microphoneUsers();
170
+ const inHeader = this.ensureGroup();
171
+ this.float(inHeader ? [] : users.filter(u => !u.inView));
172
+ if (!inHeader) {
146
173
  return;
147
174
  }
175
+ this.microphoneButton.style.display = users.length ? '' : 'none';
176
+ this.microphoneButton.title = users.length ? microphoneTitle(users) : '';
148
177
  let pane = (0, targets_1.desktopPaneOf)(this.app.activeTab);
149
178
  // SSH tabs always qualify; local terminals only while they run ssh (see detect()).
150
179
  if (pane && !(0, ssh_1.isSSHTab)(pane) && !this.desktop.has(pane) && !this.targets.cached(pane)) {
151
180
  pane = null;
152
181
  }
153
- this.group.style.display = pane ? 'flex' : 'none';
182
+ // The microphone shows from any tab, also one with nothing to switch.
183
+ this.group.style.display = pane || users.length ? 'flex' : 'none';
154
184
  if (!pane) {
185
+ this.toggleButton.style.display = 'none';
186
+ this.disconnectButton.style.display = 'none';
155
187
  return;
156
188
  }
157
189
  const visible = this.desktop.isVisible(pane);
@@ -164,6 +196,46 @@ let HeaderControls = class HeaderControls {
164
196
  }
165
197
  this.disconnectButton.style.display = this.desktop.has(pane) ? '' : 'none';
166
198
  }
199
+ /**
200
+ * The microphone over the window, for desktops sent it that aren't in view (`users`), while there is no header to
201
+ * show it in: Tabby leaves its tab bar out in full screen unless Appearance › Show tabs in fullscreen mode is on. A
202
+ * desktop in view has its dot. Gone again once the header is back or nothing out of view gets the microphone.
203
+ */
204
+ float(users) {
205
+ // The note on desktops that typing goes into all of them, at their top in the middle, moves clear of it meanwhile
206
+ // (see the service's STYLE).
207
+ document.body.classList.toggle('trd-floating-mic-on', users.length > 0);
208
+ if (!users.length) {
209
+ this.floatingMicrophone?.remove();
210
+ this.floatingMicrophone = null;
211
+ return;
212
+ }
213
+ if (!this.floatingMicrophone?.isConnected) {
214
+ const button = document.createElement('button');
215
+ button.className = 'trd-floating-mic';
216
+ button.style.cssText = FLOATING_MICROPHONE_STYLE;
217
+ button.innerHTML = '<i class="fas fa-microphone"></i>';
218
+ button.addEventListener('click', () => this.zone.run(() => this.microphoneMenu()));
219
+ document.body.appendChild(button);
220
+ this.floatingMicrophone = button;
221
+ }
222
+ const title = microphoneTitle(users);
223
+ if (this.floatingMicrophone.title !== title) {
224
+ this.floatingMicrophone.title = title;
225
+ }
226
+ }
227
+ /** The microphone's menu: for each desktop sent it, show that desktop, or stop sending the microphone there. */
228
+ microphoneMenu() {
229
+ const users = this.desktop.microphoneUsers();
230
+ if (!users.length) {
231
+ return; // stopped meanwhile
232
+ }
233
+ const items = [{ label: 'Microphone in use', enabled: false }];
234
+ for (const { pane, name } of users) {
235
+ items.push({ type: 'separator' }, { label: `Show ${name}`, click: () => this.desktop.bringForward(pane) }, { label: `Stop sending the microphone to ${name}`, click: () => this.desktop.stopMicrophone(pane) });
236
+ }
237
+ this.platform.popupContextMenu(items);
238
+ }
167
239
  };
168
240
  exports.HeaderControls = HeaderControls;
169
241
  exports.HeaderControls = HeaderControls = __decorate([
@@ -176,4 +248,3 @@ exports.HeaderControls = HeaderControls = __decorate([
176
248
  core_1.NgZone,
177
249
  help_1.RemoteDesktopHelp])
178
250
  ], HeaderControls);
179
- //# sourceMappingURL=header.js.map
package/dist/help.js CHANGED
@@ -36,12 +36,26 @@ exports.SETTINGS_TAB_ID = 'remote-desktop';
36
36
  exports.TROUBLESHOOTING = [
37
37
  {
38
38
  id: 'certificate',
39
- title: 'The certificate has changed',
39
+ title: 'A certificate this computer can\'t verify, or one that has changed',
40
40
  match: /certificate/i,
41
- body: `The first connection to a Windows or xrdp desktop remembers its certificate. When a later one differs, the
42
- plugin stops before signing in, so nothing (no password) has been sent. Reinstalling the machine or renewing its
43
- certificate changes it: then choose <b>Trust the new certificate</b>. If neither happened, something else is
44
- answering at that address. A GNOME desktop only ever accepts the certificate the plugin made for it; a
41
+ body: `A desktop you connect to directly (a remote desktop profile, an imported file) and an RD Gateway connect
42
+ without asking when their certificate is valid for their name by this computer's certificate authorities, as
43
+ an organisation's own desktops and gateways often are. Any other is shown the first time, with why it can't
44
+ be verified (most often: it is the server's own, self-signed) and its fingerprint, before anything of your
45
+ sign-in is sent: check the fingerprint with whoever runs it, some other way than this connection (it is in the
46
+ desktop's log too, which <b>Copy log</b> under Open desktops below copies), then choose <b>Trust the
47
+ certificate</b>. The question also says whom the certificate says it was issued to and by, and when it is
48
+ valid: its own word, which nothing has checked, unless its chain leads to an authority this computer trusts
49
+ (another machine's certificate then shows that machine's name) and the TLS library didn't refuse it for a
50
+ reason of its own (a limit set on the authority, say). An issuer you wouldn't expect there (one
51
+ that inspects your network's connections, say) is worth asking about before you trust it, but the one you
52
+ would expect proves nothing: anyone can make a certificate that names it. Only the fingerprint, checked some
53
+ other way, does. A desktop behind an SSH host remembers its certificate on first use without asking, since
54
+ the way there runs inside SSH. When a remembered certificate differs later, the plugin stops before signing
55
+ in, so nothing of your sign-in has gone to that server; only a certificate an authority vouched for is
56
+ replaced without a question, by another it vouches for too. Reinstalling the machine or renewing its
57
+ certificate changes it: then choose <b>Trust the new certificate</b>. If neither happened, something else
58
+ is answering at that address. A GNOME desktop only ever accepts the certificate the plugin made for it; a
45
59
  different one there means another program listens on its port. Remembered certificates are listed above.`,
46
60
  },
47
61
  {
@@ -94,15 +108,21 @@ exports.TROUBLESHOOTING = [
94
108
  body: `Sound and microphone changes apply on the next connection. xrdp needs <code>pipewire-module-xrdp</code> or
95
109
  <code>pulseaudio-module-xrdp</code>. The microphone needs the system's permission here (macOS: System Settings ›
96
110
  Privacy &amp; Security › Microphone, then restart Tabby). On GNOME, apps record from "Remoteaudio Source"; on
97
- Windows, the machine must allow audio recording redirection.`,
111
+ Windows, the machine must allow audio recording redirection. A desktop whose microphone was stopped (<b>Stop</b>
112
+ in the microphone's menu in Tabby's header, or <b>Send the microphone</b> unchecked in the desktop's menu) gets
113
+ none, its reconnects included, until <b>Send the microphone</b> is checked again in its menu or that Tabby window is closed.`,
98
114
  },
99
115
  {
100
116
  id: 'nested-ssh',
101
117
  title: 'I typed ssh to another machine in a terminal',
102
118
  body: `In an SSH tab (or a split pane) where you typed <code>ssh</code> on to another machine, <b>Desktop</b> opens that
103
- machine's desktop, going through the first one, and <code>desk</code> works there too. The first machine has to
104
- log in to the other by itself for this: with a key there, or agent forwarding, as <code>ssh -o BatchMode=yes</code>
105
- would. Tabby's <b>Reconnect</b> belongs to the tab: it reconnects the first machine and ends the ssh typed in it.
119
+ machine's desktop, going through the first one, and <code>desk</code> works there too. Desktop asks which desktop
120
+ you mean, since which <code>ssh</code> runs there is the first machine's to say; pick the other machine's
121
+ "from now on" to have it open at once the next times. Desktops you set up behind a host open in a tab connected
122
+ to that host, not through another one.
123
+ The first machine has to log in to the other by itself for this: with a key there, or agent forwarding, as
124
+ <code>ssh -o BatchMode=yes</code> would. Tabby's <b>Reconnect</b> belongs to the tab: it reconnects the first
125
+ machine and ends the ssh typed in it.
106
126
  To have both desktops side by side, open the first machine's desktop in one pane, and ssh on in the other.`,
107
127
  },
108
128
  {
@@ -127,13 +147,15 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)
127
147
  exports.esc = esc;
128
148
  function hotkeyLabel(binding) {
129
149
  const first = Array.isArray(binding) ? binding[0] : null;
130
- const strokes = Array.isArray(first) ? first : typeof first === 'string' ? [first] : [];
150
+ // Text only: the config may hold anything, and this runs for the settings page and the first-connect tip.
151
+ const strokes = (Array.isArray(first) ? first : [first]).filter((s) => typeof s === 'string');
131
152
  if (!strokes.length) {
132
153
  return null;
133
154
  }
134
155
  const mac = process.platform === 'darwin';
135
- const MAC = { Shift: '⇧', Alt: '⌥', Ctrl: '⌃', Meta: '⌘', Cmd: '⌘' };
136
- return strokes.map(s => s.split('-').map(k => mac ? MAC[k] ?? k : k).join(mac ? '' : '+')).join(', ');
156
+ // A Map, not an object literal: a part named 'constructor' or 'toString' would find what objects inherit.
157
+ const MAC = new Map([['Shift', '⇧'], ['Alt', '⌥'], ['Ctrl', '⌃'], ['Meta', '⌘'], ['Cmd', '⌘']]);
158
+ return strokes.map(s => s.split('-').map(k => mac ? MAC.get(k) ?? k : k).join(mac ? '' : '+')).join(', ');
137
159
  }
138
160
  const TIP_STYLE = `
139
161
  .trd-tip { position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 2; max-width: min(560px, 90%);
@@ -260,4 +282,3 @@ exports.RemoteDesktopHelp = RemoteDesktopHelp = __decorate([
260
282
  (0, core_1.Injectable)({ providedIn: 'root' }),
261
283
  __metadata("design:paramtypes", [tabby_core_1.AppService, tabby_core_1.ConfigService, core_1.NgZone])
262
284
  ], RemoteDesktopHelp);
263
- //# sourceMappingURL=help.js.map
package/dist/hyperv.js ADDED
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_VMS = exports.HYPERV_PORT = void 0;
4
+ exports.vmId = vmId;
5
+ exports.scanHyperV = scanHyperV;
6
+ exports.hyperVState = hyperVState;
7
+ exports.startHyperV = startHyperV;
8
+ /** VMConnect's port on a Hyper-V host. */
9
+ exports.HYPERV_PORT = 2179;
10
+ /**
11
+ * How many VMs a host's scan lists at most (see vms.ts too). Each is a menu item, and an answer of the host's making
12
+ * could otherwise make thousands of them. A host can have more (a Hyper-V server can run 1,024): the first ones are
13
+ * listed, and the menu says that only so many are (see RemoteDesktopService.vmsCut).
14
+ */
15
+ exports.MAX_VMS = 256;
16
+ const GUID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
17
+ /** A VM id as Hyper-V prints it, or '' if this isn't one. */
18
+ function vmId(value) {
19
+ // Text only: String() of an object from the config runs its own conversions, which can throw.
20
+ const id = (typeof value === 'string' ? value : '').trim().replace(/^\{|\}$/g, '');
21
+ return GUID.test(id) ? id.toLowerCase() : '';
22
+ }
23
+ /**
24
+ * Runs a PowerShell script on the host and returns what it printed. As -EncodedCommand, which reads the same whether
25
+ * the host's SSH shell is cmd.exe or PowerShell, and needs no quoting. Windows PowerShell 5.1 is on every Windows.
26
+ */
27
+ async function powershell(target, script) {
28
+ const encoded = Buffer.from(`$ProgressPreference = 'SilentlyContinue'\n${script}`, 'utf16le').toString('base64');
29
+ return target.exec(`powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`, '');
30
+ }
31
+ // One line per VM: TRD_HV <id>|<state>|<name>. Nothing where Hyper-V isn't installed or the account may not list
32
+ // VMs (which takes an administrator of the host, in practice).
33
+ const SCAN = String.raw `
34
+ $ErrorActionPreference = 'SilentlyContinue'
35
+ if (Get-Command Get-VM) { Get-VM | ForEach-Object { 'TRD_HV ' + $_.Id + '|' + $_.State + '|' + $_.Name } }
36
+ `;
37
+ /**
38
+ * A line of the scan's answer longer than this is none of its own: its id and state take some 50 characters, and
39
+ * Hyper-V keeps a VM's name to 100.
40
+ */
41
+ const MAX_LINE = 1024;
42
+ /**
43
+ * A line of the scan's answer, `TRD_HV <id>|<state>|<name>` (the name may hold bars), as a VM; null for any other line.
44
+ * Read by where its bars are, not with a pattern whose parts can take the same characters: a long line of the host's
45
+ * making could make that cost the square of its length.
46
+ */
47
+ function scannedVM(raw) {
48
+ const line = raw.length > MAX_LINE ? '' : raw.trim();
49
+ const first = line.indexOf('|');
50
+ const second = first < 0 ? -1 : line.indexOf('|', first + 1);
51
+ if (!line.startsWith('TRD_HV ') || second < 0) {
52
+ return null;
53
+ }
54
+ const id = vmId(line.slice('TRD_HV '.length, first));
55
+ const name = line.slice(second + 1);
56
+ // A name on one line, as PowerShell prints it.
57
+ if (!id || !name || /[\r\u2028\u2029]/.test(name)) {
58
+ return null;
59
+ }
60
+ return { id, name, state: line.slice(first + 1, second) === 'Running' ? 'running' : 'off' };
61
+ }
62
+ /**
63
+ * The host's Hyper-V VMs, running or not (one that is off is started when opened), MAX_VMS at most; `listed.cut` says
64
+ * whether the host has more. Read-only.
65
+ */
66
+ async function scanHyperV(target, listed = {}) {
67
+ const out = await powershell(target, SCAN);
68
+ const found = [];
69
+ listed.cut = false;
70
+ // Ids seen so far: looked up, not searched for, so that the time taken grows with the lines, not their square.
71
+ const ids = new Set();
72
+ for (const line of out.split('\n')) {
73
+ const vm = scannedVM(line);
74
+ if (vm && !ids.has(vm.id)) {
75
+ // One more than are listed: the list is cut short.
76
+ if (found.length === exports.MAX_VMS) {
77
+ listed.cut = true;
78
+ break;
79
+ }
80
+ ids.add(vm.id);
81
+ found.push(vm);
82
+ }
83
+ }
84
+ return found;
85
+ }
86
+ /**
87
+ * What follows `prefix` on each line of the host's answer that starts with it, trimmed, and MAX_LINE characters of it
88
+ * at most (the host's reason for an error is shown as it is). Line by line: a pattern run over the whole answer can
89
+ * match across its lines, which a long answer of the host's making can make cost the square of its length.
90
+ */
91
+ function linesAfter(out, prefix) {
92
+ return out.split('\n').map(line => line.trim()).filter(line => line.startsWith(prefix)).map(line => line.slice(prefix.length).trim().slice(0, MAX_LINE));
93
+ }
94
+ // TRD_HV_STATE <state>|<enhanced session mode state>, or TRD_HV_ERR <why>. The enhanced state is
95
+ // Msvm_ComputerSystem's: 2 when the guest takes enhanced sessions right now (the host allows them, and the guest's
96
+ // Remote Desktop Services answer on its bus), anything else when only the basic console is there.
97
+ const STATE = String.raw `
98
+ try {
99
+ $vm = Get-VM -Id '@@ID@@' -ErrorAction Stop
100
+ $enhanced = (Get-CimInstance -Namespace root\virtualization\v2 -ClassName Msvm_ComputerSystem -Filter "Name='$($vm.Id.ToString().ToUpper())'" -ErrorAction SilentlyContinue).EnhancedSessionModeState
101
+ 'TRD_HV_STATE ' + $vm.State + '|' + $enhanced
102
+ } catch { 'TRD_HV_ERR ' + ($_.Exception.Message -replace '\s+', ' ') }
103
+ `;
104
+ /** How a VM is now: running or not, and whether it takes an enhanced session. Throws with the host's reason when it can't say. */
105
+ async function hyperVState(target, id) {
106
+ const out = await powershell(target, STATE.replace('@@ID@@', vmId(id)));
107
+ const state = linesAfter(out, 'TRD_HV_STATE ').map(line => /^([^|]*)\|(\d*)$/.exec(line)).find(Boolean);
108
+ if (!state) {
109
+ throw new Error(linesAfter(out, 'TRD_HV_ERR ')[0] || 'the host didn\'t answer about the VM (is PowerShell\'s Hyper-V module there, and may this account use it?)');
110
+ }
111
+ return { running: state[1] === 'Running', enhanced: state[2] === '2' };
112
+ }
113
+ const START = String.raw `
114
+ try {
115
+ $vm = Get-VM -Id '@@ID@@' -ErrorAction Stop
116
+ if ($vm.State -eq 'Running') { 'TRD_HV_OK ' + $vm.Name + ' is already running' }
117
+ elseif ($vm.State -eq 'Paused') { Resume-VM -VM $vm -ErrorAction Stop; 'TRD_HV_OK ' + $vm.Name + ' resumed' }
118
+ else { Start-VM -VM $vm -ErrorAction Stop; 'TRD_HV_OK ' + $vm.Name + ' started' }
119
+ } catch { 'TRD_HV_ERR ' + ($_.Exception.Message -replace '\s+', ' ') }
120
+ `;
121
+ /** Starts (or resumes) the VM. Resolves with what was done; throws with the host's reason when it couldn't. */
122
+ async function startHyperV(target, id) {
123
+ const out = await powershell(target, START.replace('@@ID@@', vmId(id)));
124
+ const ok = linesAfter(out, 'TRD_HV_OK ');
125
+ if (!ok.length) {
126
+ throw new Error(linesAfter(out, 'TRD_HV_ERR ')[0] || 'the host didn\'t answer');
127
+ }
128
+ return ok[0];
129
+ }
package/dist/index.js CHANGED
@@ -49,7 +49,8 @@ let RemoteDesktopModule = class RemoteDesktopModule {
49
49
  'remote-desktop-disconnect': pane => desktop.disconnect(pane),
50
50
  };
51
51
  hotkeys.hotkey$.subscribe((id) => {
52
- const action = actions[id];
52
+ // Tabby fires any key bound under `hotkeys` in the config ('constructor' too): the table's own entries only.
53
+ const action = Object.prototype.hasOwnProperty.call(actions, id) ? actions[id] : undefined;
53
54
  const pane = action && (0, targets_1.desktopPaneOf)(app.activeTab);
54
55
  if (!action || !pane) {
55
56
  return;
@@ -85,4 +86,3 @@ RemoteDesktopModule = __decorate([
85
86
  __metadata("design:paramtypes", [tabby_core_1.AppService, tabby_core_1.HotkeysService, desktop_service_1.RemoteDesktopService, header_1.HeaderControls, targets_1.RemoteTargets, keyboard_1.DesktopKeyboard, core_1.Injector])
86
87
  ], RemoteDesktopModule);
87
88
  exports.default = RemoteDesktopModule;
88
- //# sourceMappingURL=index.js.map
package/dist/keyboard.js CHANGED
@@ -276,4 +276,3 @@ exports.DesktopKeyboard = DesktopKeyboard = __decorate([
276
276
  desktop_service_1.RemoteDesktopService,
277
277
  core_1.NgZone])
278
278
  ], DesktopKeyboard);
279
- //# sourceMappingURL=keyboard.js.map
@@ -2,14 +2,16 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Resampler = exports.Microphone = void 0;
4
4
  /**
5
- * Redirects the local microphone to a remote desktop (IronRDP's `audioInput` extension). The server asks for it
6
- * only while an application there records: IronRDP says `open` (with the format the server chose) and `close`, and
7
- * the microphone is captured only in between. The browser's capture runs at the audio device's rate; it is resampled
8
- * and converted to interleaved 16-bit PCM here, in blocks of about 20 ms.
5
+ * Redirects the local microphone to a remote desktop (IronRDP's `audioInput` extension). The server asks for it when it
6
+ * likes (by its own account, while an application there records): IronRDP says `open` (with the format the server
7
+ * chose) and `close`, and the microphone is captured only in between. The browser's capture runs at the audio device's
8
+ * rate; it is resampled and converted to interleaved 16-bit PCM here, in blocks of about 20 ms.
9
9
  *
10
10
  * It keeps capturing while the desktop layer is hidden: the remote application is still recording (a call, say), and
11
- * the console being in front doesn't mean the user stopped talking to it. The system shows its own microphone
12
- * indicator meanwhile.
11
+ * the console being in front doesn't mean the user stopped talking to it. What the remote does with it is out of
12
+ * sight then, so it shows elsewhere (see RemoteDesktopService.microphoneChanged): a red microphone in Tabby's header
13
+ * for as long as it lasts, and a note when a capture starts on a desktop that isn't showing. turnOff() stops it here,
14
+ * and turnOn() takes the remote's requests again.
13
15
  */
14
16
  class Microphone {
15
17
  /**
@@ -37,6 +39,8 @@ class Microphone {
37
39
  this.starting = false;
38
40
  this.resampler = null;
39
41
  this.reportedError = false;
42
+ /** Turned off here (see turnOff): the remote's requests are refused for the rest of the connection. */
43
+ this.off = false;
40
44
  /** The callback for IronRDP's audioInput extension. */
41
45
  this.callback = (message) => {
42
46
  try {
@@ -49,6 +53,10 @@ class Microphone {
49
53
  }
50
54
  this.format = { sampleRate, channels };
51
55
  this.log(`microphone: the remote opened it (${sampleRate} Hz, ${channels === 1 ? 'mono' : 'stereo'})`);
56
+ if (this.off) {
57
+ this.log('microphone: not sent: turned off here');
58
+ return;
59
+ }
52
60
  this.start().catch(e => this.failed(e));
53
61
  }
54
62
  else if (message?.type === 'close') {
@@ -74,8 +82,8 @@ class Microphone {
74
82
  finally {
75
83
  this.starting = false;
76
84
  }
77
- // Closed and opened again while starting: that start was dropped, so start over.
78
- if (!this.stream && this.format) {
85
+ // Closed and opened again while starting: that start was dropped, so start over (unless turned off meanwhile).
86
+ if (!this.stream && this.format && !this.off) {
79
87
  await this.start();
80
88
  }
81
89
  }
@@ -100,28 +108,42 @@ class Microphone {
100
108
  let capture;
101
109
  try {
102
110
  await context.audioWorklet.addModule(workletURL());
111
+ if (generation !== this.generation) {
112
+ return; // closed meanwhile: stop() released the stream and closed the context, which takes no new nodes
113
+ }
103
114
  const node = new AudioWorkletNode(context, 'trd-microphone', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] });
104
- node.port.onmessage = event => this.captured(event.data, context.sampleRate);
115
+ node.port.onmessage = event => {
116
+ // A block the worklet posted before the capture stopped can still arrive after it: dropped, not sent.
117
+ if (generation === this.generation) {
118
+ this.captured(event.data, context.sampleRate);
119
+ }
120
+ };
105
121
  capture = node;
106
122
  }
107
123
  catch (e) {
124
+ if (generation !== this.generation) {
125
+ return; // closed meanwhile, which may be why the module was refused: as above, and not an error
126
+ }
108
127
  // No AudioWorklet (or its module was refused): the deprecated ScriptProcessor does the same on the main thread.
109
128
  this.log(`microphone: AudioWorklet unavailable (${e?.message ?? e}), using ScriptProcessor`);
110
129
  const node = context.createScriptProcessor(1024, 2, 1);
111
130
  node.onaudioprocess = event => {
131
+ if (generation !== this.generation) {
132
+ return; // stopped: as above
133
+ }
112
134
  const input = event.inputBuffer;
113
135
  this.captured(Array.from({ length: input.numberOfChannels }, (_, i) => input.getChannelData(i).slice()), context.sampleRate);
114
136
  };
115
137
  capture = node;
116
138
  }
117
- if (generation !== this.generation) {
118
- return; // closed meanwhile: stop() released the stream and the context
119
- }
120
139
  source.connect(capture);
121
140
  capture.connect(mute);
122
141
  this.nodes = [source, capture, mute];
123
142
  if (context.state === 'suspended') {
124
143
  await context.resume().catch(() => null);
144
+ if (generation !== this.generation) {
145
+ return; // closed or turned off while resuming: stop() released everything, and it isn't capturing
146
+ }
125
147
  }
126
148
  const track = stream.getAudioTracks()[0];
127
149
  this.log(`microphone: capturing from ${track?.label || 'the default input'} at ${context.sampleRate} Hz`);
@@ -188,6 +210,29 @@ class Microphone {
188
210
  this.changed();
189
211
  }
190
212
  }
213
+ /**
214
+ * Turned off here (the setting, or Stop for this desktop): stops capturing at once, also a capture still starting,
215
+ * and refuses the remote's later requests on this connection. Whether the next connection gets the microphone is
216
+ * the service's to say (see RemoteDesktopService.stopMicrophone).
217
+ */
218
+ turnOff() {
219
+ if (!this.off) {
220
+ this.off = true;
221
+ this.log('microphone: turned off here');
222
+ }
223
+ this.stop();
224
+ }
225
+ /** Turned on again here (the desktop's menu): the remote's requests are taken again, one open now included. */
226
+ turnOn() {
227
+ if (!this.off) {
228
+ return;
229
+ }
230
+ this.off = false;
231
+ this.log('microphone: turned on again here');
232
+ if (this.format) {
233
+ this.start().catch(e => this.failed(e));
234
+ }
235
+ }
191
236
  /** The connection ended. */
192
237
  close() {
193
238
  this.format = null;
@@ -292,4 +337,3 @@ function workletURL() {
292
337
  workletBlobURL ??= URL.createObjectURL(new Blob([WORKLET], { type: 'application/javascript' }));
293
338
  return workletBlobURL;
294
339
  }
295
- //# sourceMappingURL=microphone.js.map