tabby-rdp 0.5.0 → 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 (79) hide show
  1. package/CHANGELOG.md +490 -0
  2. package/README.md +177 -48
  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 +1442 -244
  13. package/dist/desktopForm.js +51 -17
  14. package/dist/desktops.js +232 -24
  15. package/dist/drives.js +271 -30
  16. package/dist/fileTransfer.js +958 -83
  17. package/dist/gateway.js +216 -57
  18. package/dist/header.js +76 -5
  19. package/dist/help.js +34 -13
  20. package/dist/hyperv.js +65 -15
  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 +0 -1
  25. package/dist/osd.js +56 -15
  26. package/dist/probe.js +15 -3
  27. package/dist/rdcleanpath.js +319 -44
  28. package/dist/rdpFile.js +22 -7
  29. package/dist/rdpProfile.js +39 -21
  30. package/dist/remoteSetup.js +103 -23
  31. package/dist/sessionLog.js +53 -0
  32. package/dist/settingsPage.js +65 -29
  33. package/dist/signin.js +407 -40
  34. package/dist/ssh.js +99 -18
  35. package/dist/sshChannelStream.js +40 -6
  36. package/dist/targets.js +185 -39
  37. package/dist/ui.js +88 -20
  38. package/dist/unshowable.js +41 -0
  39. package/dist/updates.js +37 -2
  40. package/dist/vms.js +17 -3
  41. package/dist/wake.js +15 -8
  42. package/package.json +4 -3
  43. package/remote/trd-pty.py +74 -13
  44. package/vendor/SHA256SUMS +26 -0
  45. package/vendor/ironrdp_web_bg.wasm +0 -0
  46. package/dist/accountForm.js.map +0 -1
  47. package/dist/accounts.js.map +0 -1
  48. package/dist/audio.js.map +0 -1
  49. package/dist/connectionStatus.js.map +0 -1
  50. package/dist/desk.js.map +0 -1
  51. package/dist/deskScript.js.map +0 -1
  52. package/dist/desktop.service.js.map +0 -1
  53. package/dist/desktopForm.js.map +0 -1
  54. package/dist/desktops.js.map +0 -1
  55. package/dist/drives.js.map +0 -1
  56. package/dist/fileTransfer.js.map +0 -1
  57. package/dist/gateway.js.map +0 -1
  58. package/dist/header.js.map +0 -1
  59. package/dist/help.js.map +0 -1
  60. package/dist/hyperv.js.map +0 -1
  61. package/dist/index.js.map +0 -1
  62. package/dist/keyboard.js.map +0 -1
  63. package/dist/microphone.js.map +0 -1
  64. package/dist/ntlm.js.map +0 -1
  65. package/dist/osd.js.map +0 -1
  66. package/dist/probe.js.map +0 -1
  67. package/dist/rdcleanpath.js.map +0 -1
  68. package/dist/rdpFile.js.map +0 -1
  69. package/dist/rdpProfile.js.map +0 -1
  70. package/dist/remoteSetup.js.map +0 -1
  71. package/dist/settingsPage.js.map +0 -1
  72. package/dist/signin.js.map +0 -1
  73. package/dist/ssh.js.map +0 -1
  74. package/dist/sshChannelStream.js.map +0 -1
  75. package/dist/targets.js.map +0 -1
  76. package/dist/ui.js.map +0 -1
  77. package/dist/updates.js.map +0 -1
  78. package/dist/vms.js.map +0 -1
  79. package/dist/wake.js.map +0 -1
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 CHANGED
@@ -1,16 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HYPERV_PORT = void 0;
3
+ exports.MAX_VMS = exports.HYPERV_PORT = void 0;
4
4
  exports.vmId = vmId;
5
5
  exports.scanHyperV = scanHyperV;
6
6
  exports.hyperVState = hyperVState;
7
7
  exports.startHyperV = startHyperV;
8
8
  /** VMConnect's port on a Hyper-V host. */
9
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;
10
16
  const GUID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
11
17
  /** A VM id as Hyper-V prints it, or '' if this isn't one. */
12
18
  function vmId(value) {
13
- const id = String(value ?? '').trim().replace(/^\{|\}$/g, '');
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, '');
14
21
  return GUID.test(id) ? id.toLowerCase() : '';
15
22
  }
16
23
  /**
@@ -27,19 +34,63 @@ const SCAN = String.raw `
27
34
  $ErrorActionPreference = 'SilentlyContinue'
28
35
  if (Get-Command Get-VM) { Get-VM | ForEach-Object { 'TRD_HV ' + $_.Id + '|' + $_.State + '|' + $_.Name } }
29
36
  `;
30
- /** The host's Hyper-V VMs, running or not (one that is off is started when opened). Read-only. */
31
- async function scanHyperV(target) {
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 = {}) {
32
67
  const out = await powershell(target, SCAN);
33
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();
34
72
  for (const line of out.split('\n')) {
35
- const m = /^TRD_HV ([^|]+)\|([^|]*)\|(.+?)\s*$/.exec(line.trim());
36
- const id = vmId(m?.[1]);
37
- if (m && id && !found.some(f => f.id === id)) {
38
- found.push({ id, name: m[3], state: m[2] === 'Running' ? 'running' : 'off' });
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);
39
82
  }
40
83
  }
41
84
  return found;
42
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
+ }
43
94
  // TRD_HV_STATE <state>|<enhanced session mode state>, or TRD_HV_ERR <why>. The enhanced state is
44
95
  // Msvm_ComputerSystem's: 2 when the guest takes enhanced sessions right now (the host allows them, and the guest's
45
96
  // Remote Desktop Services answer on its bus), anything else when only the basic console is there.
@@ -53,9 +104,9 @@ try {
53
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. */
54
105
  async function hyperVState(target, id) {
55
106
  const out = await powershell(target, STATE.replace('@@ID@@', vmId(id)));
56
- const state = /^TRD_HV_STATE ([^|]*)\|(\d*)\s*$/m.exec(out);
107
+ const state = linesAfter(out, 'TRD_HV_STATE ').map(line => /^([^|]*)\|(\d*)$/.exec(line)).find(Boolean);
57
108
  if (!state) {
58
- throw new Error(/^TRD_HV_ERR (.*)$/m.exec(out)?.[1].trim() || 'the host didn\'t answer about the VM (is PowerShell\'s Hyper-V module there, and may this account use it?)');
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?)');
59
110
  }
60
111
  return { running: state[1] === 'Running', enhanced: state[2] === '2' };
61
112
  }
@@ -70,10 +121,9 @@ try {
70
121
  /** Starts (or resumes) the VM. Resolves with what was done; throws with the host's reason when it couldn't. */
71
122
  async function startHyperV(target, id) {
72
123
  const out = await powershell(target, START.replace('@@ID@@', vmId(id)));
73
- const ok = /^TRD_HV_OK (.*)$/m.exec(out);
74
- if (!ok) {
75
- throw new Error(/^TRD_HV_ERR (.*)$/m.exec(out)?.[1].trim() || 'the host didn\'t answer');
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');
76
127
  }
77
- return ok[1].trim();
128
+ return ok[0];
78
129
  }
79
- //# sourceMappingURL=hyperv.js.map
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
package/dist/ntlm.js CHANGED
@@ -223,4 +223,3 @@ function authenticateMessage(negotiate, challenge, account) {
223
223
  }
224
224
  return message;
225
225
  }
226
- //# sourceMappingURL=ntlm.js.map
package/dist/osd.js CHANGED
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OSD_STYLE = exports.OSD_DEFAULTS = exports.OSD_POSITIONS = exports.OSD_SIZES = exports.OSD_FONTS = void 0;
4
+ exports.osdColor = osdColor;
5
+ exports.configNumber = configNumber;
4
6
  exports.osdSettings = osdSettings;
5
7
  exports.renderOsd = renderOsd;
6
8
  /** Font stacks: macOS's, then Windows' (Bahnschrift) and common Linux ones, then any sans-serif. */
@@ -15,16 +17,35 @@ exports.OSD_FONTS = {
15
17
  exports.OSD_SIZES = { small: 28, medium: 44, large: 60, huge: 80 };
16
18
  exports.OSD_POSITIONS = ['top-left', 'top-center', 'top-right', 'middle', 'bottom-left', 'bottom-center', 'bottom-right'];
17
19
  exports.OSD_DEFAULTS = { show: 'auto', font: 'condensed', size: 'medium', position: 'top-right', color: '', seconds: 2.5 };
20
+ /** Whether `key` is one of the table's own entries: 'constructor' or '__proto__' from the config are no font or size. */
21
+ function isOwn(table, key) {
22
+ return typeof key === 'string' && Object.prototype.hasOwnProperty.call(table, key);
23
+ }
24
+ /**
25
+ * A color from the config, or '' for the default. Only plain colors: a name, #hex, or a color function of numbers.
26
+ * CSS.supports alone isn't enough: a value with var() passes it whatever follows (`var(--x, red)"><img …>`).
27
+ */
28
+ function osdColor(value) {
29
+ const plain = typeof value === 'string' && /^(#[0-9a-f]{3,8}|[a-z]+|(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([\w\s.,%/+-]*\))$/i.test(value);
30
+ return plain && CSS.supports('color', value) ? value : '';
31
+ }
32
+ /**
33
+ * A number from the config, or NaN: only from a number or a string. Number() of an object calls its own conversions,
34
+ * and a config can hold an object whose `valueOf` and `toString` aren't functions, which makes it throw.
35
+ */
36
+ function configNumber(value) {
37
+ return typeof value === 'number' || typeof value === 'string' ? Number(value) : NaN;
38
+ }
18
39
  /** The config value, with anything missing or unknown replaced by the default. */
19
40
  function osdSettings(value) {
20
41
  const v = value && typeof value === 'object' ? value : {};
21
- const seconds = Number(v.seconds);
42
+ const seconds = configNumber(v.seconds);
22
43
  return {
23
44
  show: ['auto', 'always', 'off'].includes(v.show) ? v.show : exports.OSD_DEFAULTS.show,
24
- font: v.font in exports.OSD_FONTS ? v.font : exports.OSD_DEFAULTS.font,
25
- size: v.size in exports.OSD_SIZES ? v.size : exports.OSD_DEFAULTS.size,
45
+ font: isOwn(exports.OSD_FONTS, v.font) ? v.font : exports.OSD_DEFAULTS.font,
46
+ size: isOwn(exports.OSD_SIZES, v.size) ? v.size : exports.OSD_DEFAULTS.size,
26
47
  position: exports.OSD_POSITIONS.includes(v.position) ? v.position : exports.OSD_DEFAULTS.position,
27
- color: typeof v.color === 'string' && CSS.supports('color', v.color) ? v.color : '',
48
+ color: osdColor(v.color),
28
49
  seconds: Number.isFinite(seconds) && seconds >= 0.5 && seconds <= 30 ? seconds : exports.OSD_DEFAULTS.seconds,
29
50
  };
30
51
  }
@@ -57,23 +78,43 @@ function place(position) {
57
78
  ].join('; ');
58
79
  return { box, glow, align: h === 'left' ? 'flex-start' : h === 'right' ? 'flex-end' : 'center' };
59
80
  }
60
- /** Fills `el` (a .trd-osd) with `name` and an optional second line, as the settings say. */
81
+ /**
82
+ * Fills `el` (a .trd-osd) with `name` and an optional second line, as the settings say. The settings are checked
83
+ * again, whoever passes them, and the overlay is built node by node with its styles set through the CSSOM: nothing
84
+ * from the config is ever parsed as markup, which in Tabby's window (Node integration) would run as code.
85
+ */
61
86
  function renderOsd(el, name, sub, settings) {
62
- const font = exports.OSD_FONTS[settings.font];
63
- const { box, glow, align } = place(settings.position);
87
+ const checked = osdSettings(settings);
88
+ const font = exports.OSD_FONTS[checked.font];
89
+ const { box, glow, align } = place(checked.position);
64
90
  // Smaller in a narrow pane: a name should fit in about two thirds of it.
65
91
  const width = el.offsetWidth || 800; // layout width: the settings preview is a scaled-down desktop
66
- const size = Math.max(18, Math.min(exports.OSD_SIZES[settings.size], width / Math.max(4, name.length) * 1.1));
67
- el.innerHTML = `<div class="trd-osd-glow" style="${glow}"></div>
68
- <div class="trd-osd-box" style="${box}; align-items: ${align}; font-family: ${font.family.replace(/"/g, '&quot;')};
69
- font-weight: ${font.weight}; font-stretch: ${font.stretch}; font-size: ${size}px${settings.color ? `; color: ${settings.color}` : ''}">
70
- <div class="trd-osd-name"></div><div class="trd-osd-sub"></div></div>`;
71
- el.querySelector('.trd-osd-name').textContent = name;
72
- const subEl = el.querySelector('.trd-osd-sub');
92
+ const size = Math.max(18, Math.min(exports.OSD_SIZES[checked.size], width / Math.max(4, name.length) * 1.1));
93
+ const part = (className) => {
94
+ const div = document.createElement('div');
95
+ div.className = className;
96
+ return div;
97
+ };
98
+ const glowEl = part('trd-osd-glow');
99
+ glowEl.style.cssText = glow;
100
+ const boxEl = part('trd-osd-box');
101
+ boxEl.style.cssText = box;
102
+ boxEl.style.setProperty('align-items', align);
103
+ boxEl.style.setProperty('font-family', font.family);
104
+ boxEl.style.setProperty('font-weight', String(font.weight));
105
+ boxEl.style.setProperty('font-stretch', font.stretch);
106
+ boxEl.style.setProperty('font-size', `${size}px`);
107
+ if (checked.color) {
108
+ boxEl.style.setProperty('color', checked.color);
109
+ }
110
+ const nameEl = part('trd-osd-name');
111
+ nameEl.textContent = name;
112
+ const subEl = part('trd-osd-sub');
73
113
  if (sub) {
74
114
  const bar = document.createElement('span');
75
115
  bar.className = 'trd-osd-bar';
76
116
  subEl.append(bar, document.createTextNode(sub));
77
117
  }
118
+ boxEl.append(nameEl, subEl);
119
+ el.replaceChildren(glowEl, boxEl);
78
120
  }
79
- //# sourceMappingURL=osd.js.map
package/dist/probe.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_PROBES = void 0;
3
4
  exports.probeArrived = probeArrived;
4
5
  exports.probesIn = probesIn;
5
6
  /**
@@ -8,15 +9,27 @@ exports.probesIn = probesIn;
8
9
  * in, so nothing shows). Exact where a split's panes share a host, and even their size.
9
10
  */
10
11
  const listeners = new Set();
12
+ /**
13
+ * How many probes probesIn keeps at most. A pane gets one for each `ssh` in the foreground of its terminal there (two
14
+ * with a jump host's); the host, which is given the prefix, could print any number more while its command runs (up to
15
+ * a minute), each one kept until then.
16
+ */
17
+ exports.MAX_PROBES = 64;
18
+ /** What follows a probe's prefix and its dash: the number the script gives each `ssh` it finds, from 1. */
19
+ const PROBE_NUMBER = /^[1-9][0-9]{0,2}$/;
11
20
  /** Called by desk.ts for each probe in a pane's output. */
12
21
  function probeArrived(id, pane) {
13
22
  listeners.forEach(f => f(id, pane));
14
23
  }
15
- /** Collects the probes (ids starting with `prefix`) that arrive in `pane` while `during` runs, and briefly after. */
24
+ /**
25
+ * Collects the probes that arrive in `pane` while `during` runs, and briefly after: ids of the form `<prefix>-<n>`, n
26
+ * from 1 to 999, the first MAX_PROBES of them.
27
+ */
16
28
  async function probesIn(pane, prefix, during, settleMs = 400) {
17
29
  const ids = new Set();
30
+ const start = `${prefix}-`;
18
31
  const listener = (id, where) => {
19
- if (where === pane && id.startsWith(prefix)) {
32
+ if (where === pane && ids.size < exports.MAX_PROBES && id.startsWith(start) && PROBE_NUMBER.test(id.slice(start.length))) {
20
33
  ids.add(id);
21
34
  }
22
35
  };
@@ -30,4 +43,3 @@ async function probesIn(pane, prefix, during, settleMs = 400) {
30
43
  listeners.delete(listener);
31
44
  }
32
45
  }
33
- //# sourceMappingURL=probe.js.map