tabby-rdp 0.5.0 → 0.5.2

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 (87) hide show
  1. package/CHANGELOG.md +516 -0
  2. package/README.md +216 -58
  3. package/TROUBLESHOOTING.md +77 -0
  4. package/dist/accountForm.js +0 -1
  5. package/dist/accountRevisions.js +46 -0
  6. package/dist/accounts.js +2 -2
  7. package/dist/audio.js +0 -1
  8. package/dist/authorities.js +331 -0
  9. package/dist/byteQueue.js +102 -0
  10. package/dist/clipboard.js +106 -0
  11. package/dist/connectionStatus.js +0 -1
  12. package/dist/desk.js +16 -47
  13. package/dist/deskScript.js +196 -6
  14. package/dist/desktop.service.js +1499 -269
  15. package/dist/desktopForm.js +53 -18
  16. package/dist/desktops.js +232 -24
  17. package/dist/diskStorage.js +212 -0
  18. package/dist/drives.js +271 -30
  19. package/dist/fileTransfer.js +960 -132
  20. package/dist/gateway.js +216 -57
  21. package/dist/header.js +76 -5
  22. package/dist/help.js +16 -47
  23. package/dist/hostCompat.js +86 -0
  24. package/dist/hyperv.js +65 -15
  25. package/dist/index.js +7 -4
  26. package/dist/keyboard.js +0 -1
  27. package/dist/microphone.js +57 -13
  28. package/dist/ntlm.js +0 -1
  29. package/dist/osd.js +56 -15
  30. package/dist/packageChangeDialog.js +186 -0
  31. package/dist/probe.js +15 -3
  32. package/dist/rdcleanpath.js +319 -44
  33. package/dist/rdpFile.js +22 -7
  34. package/dist/rdpProfile.js +39 -21
  35. package/dist/releaseSettings.js +72 -0
  36. package/dist/releases.js +408 -0
  37. package/dist/remoteSetup.js +103 -23
  38. package/dist/sessionLog.js +53 -0
  39. package/dist/settingsPage.js +148 -122
  40. package/dist/signin.js +478 -48
  41. package/dist/ssh.js +99 -18
  42. package/dist/sshChannelStream.js +40 -6
  43. package/dist/targets.js +194 -40
  44. package/dist/ui.js +88 -20
  45. package/dist/unshowable.js +41 -0
  46. package/dist/updates.js +79 -56
  47. package/dist/vms.js +17 -3
  48. package/dist/wake.js +15 -8
  49. package/docs/desk.md +157 -0
  50. package/package.json +14 -4
  51. package/remote/trd-pty.py +74 -13
  52. package/vendor/SHA256SUMS +26 -0
  53. package/vendor/ironrdp_web_bg.wasm +0 -0
  54. package/dist/accountForm.js.map +0 -1
  55. package/dist/accounts.js.map +0 -1
  56. package/dist/audio.js.map +0 -1
  57. package/dist/connectionStatus.js.map +0 -1
  58. package/dist/desk.js.map +0 -1
  59. package/dist/deskScript.js.map +0 -1
  60. package/dist/desktop.service.js.map +0 -1
  61. package/dist/desktopForm.js.map +0 -1
  62. package/dist/desktops.js.map +0 -1
  63. package/dist/drives.js.map +0 -1
  64. package/dist/fileTransfer.js.map +0 -1
  65. package/dist/gateway.js.map +0 -1
  66. package/dist/header.js.map +0 -1
  67. package/dist/help.js.map +0 -1
  68. package/dist/hyperv.js.map +0 -1
  69. package/dist/index.js.map +0 -1
  70. package/dist/keyboard.js.map +0 -1
  71. package/dist/microphone.js.map +0 -1
  72. package/dist/ntlm.js.map +0 -1
  73. package/dist/osd.js.map +0 -1
  74. package/dist/probe.js.map +0 -1
  75. package/dist/rdcleanpath.js.map +0 -1
  76. package/dist/rdpFile.js.map +0 -1
  77. package/dist/rdpProfile.js.map +0 -1
  78. package/dist/remoteSetup.js.map +0 -1
  79. package/dist/settingsPage.js.map +0 -1
  80. package/dist/signin.js.map +0 -1
  81. package/dist/ssh.js.map +0 -1
  82. package/dist/sshChannelStream.js.map +0 -1
  83. package/dist/targets.js.map +0 -1
  84. package/dist/ui.js.map +0 -1
  85. package/dist/updates.js.map +0 -1
  86. package/dist/vms.js.map +0 -1
  87. package/dist/wake.js.map +0 -1
package/dist/help.js CHANGED
@@ -9,7 +9,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.RemoteDesktopHelp = exports.esc = exports.TROUBLESHOOTING = exports.SETTINGS_TAB_ID = exports.HOTKEYS = exports.TOGGLE_HOTKEY = void 0;
12
+ exports.RemoteDesktopHelp = exports.esc = exports.TROUBLESHOOTING = exports.TROUBLESHOOTING_URL = exports.SETTINGS_TAB_ID = exports.HOTKEYS = exports.TOGGLE_HOTKEY = void 0;
13
13
  exports.entryFor = entryFor;
14
14
  exports.hotkeyLabel = hotkeyLabel;
15
15
  const core_1 = require("@angular/core");
@@ -33,88 +33,50 @@ exports.HOTKEYS = [
33
33
  ];
34
34
  /** The plugin's page in Tabby's settings (see settingsPage.ts). */
35
35
  exports.SETTINGS_TAB_ID = 'remote-desktop';
36
+ exports.TROUBLESHOOTING_URL = 'https://github.com/meanaverage/tabby-rdp/blob/main/TROUBLESHOOTING.md';
36
37
  exports.TROUBLESHOOTING = [
37
38
  {
38
39
  id: 'certificate',
39
- title: 'The certificate has changed',
40
+ title: 'A certificate this computer can\'t verify, or one that has changed',
40
41
  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
45
- different one there means another program listens on its port. Remembered certificates are listed above.`,
46
42
  },
47
43
  {
48
44
  id: 'sign-in',
49
45
  title: 'Wrong user name or password',
50
46
  match: /sign-in|sign in|password|user name/i,
51
- body: `Windows and xrdp desktops sign in with an account on that machine (<code>DOMAIN\\user</code> works). A saved
52
- password that no longer works is asked for again; <b>Sign in again…</b> in the tab's menu replaces it on
53
- purpose. xrdp doesn't refuse a wrong password: it shows its own login window instead. GNOME desktops never
54
- ask: the plugin manages their sign-in.`,
55
47
  },
56
48
  {
57
49
  id: 'no-desktop',
58
50
  title: 'No desktop found on a Linux host',
59
51
  match: /GNOME|xrdp|grdctl|headless/i,
60
- body: `The plugin uses GNOME Remote Desktop 46 or newer (Ubuntu 24.04, for example), with no root and no login
61
- screen. For KDE, XFCE, MATE and others, install xrdp and a desktop for its sessions: on Debian and Ubuntu,
62
- <code>sudo apt install xrdp xfce4</code>. A GNOME session already shared from the machine's own screen (GNOME's
63
- Desktop Sharing) has to be turned off first.`,
64
52
  },
65
53
  {
66
54
  id: 'unreachable',
67
55
  title: 'Can\'t reach a Windows desktop',
68
56
  match: /refused|timed out|timeout|unreachable|ECONN|EHOST|no route|could not connect|couldn't connect/i,
69
- body: `Windows needs Remote Desktop turned on (Settings › System › Remote Desktop; Pro, Enterprise or Server).
70
- Behind an SSH host, the address is as that host sees it: from there, <code>nc -z &lt;address&gt; 3389</code>
71
- should succeed. For a VM or machine that may be off, give the desktop a VM name or MAC address in its edit form
72
- and it is started when opened.`,
73
57
  },
74
58
  {
75
59
  id: 'reconnecting',
76
60
  title: 'It keeps reconnecting',
77
61
  match: /reconnect|connection lost/i,
78
- body: `After sleep or a network change the desktop reconnects by itself, with growing pauses, and waits while the
79
- SSH connection is down (<b>Reconnect SSH</b> hurries that along). It stops after a few tries or on
80
- <b>Stop</b>. If it drops again right after connecting, check the host's free memory and that the desktop's
81
- session is still running there.`,
82
62
  },
83
63
  {
84
64
  id: 'picture',
85
65
  title: 'The picture is blurry or slow',
86
66
  match: /H\.264|decod|video/i,
87
- body: `<b>Sharpness › Retina</b> renders at your screen's device pixels, with the remote's scale set to match. Video
88
- (H.264) is used when the remote sends it: Windows does, GNOME only with a hardware encoder (VA-API or NVENC).
89
- <b>Show connection status</b> shows the frame rate, the round trip and how the picture comes.`,
90
67
  },
91
68
  {
92
69
  id: 'sound',
93
70
  title: 'No sound, or the microphone isn\'t heard',
94
- body: `Sound and microphone changes apply on the next connection. xrdp needs <code>pipewire-module-xrdp</code> or
95
- <code>pulseaudio-module-xrdp</code>. The microphone needs the system's permission here (macOS: System Settings ›
96
- Privacy &amp; Security › Microphone, then restart Tabby). On GNOME, apps record from "Remoteaudio Source"; on
97
- Windows, the machine must allow audio recording redirection.`,
98
71
  },
99
72
  {
100
73
  id: 'nested-ssh',
101
74
  title: 'I typed ssh to another machine in a terminal',
102
- 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.
106
- To have both desktops side by side, open the first machine's desktop in one pane, and ssh on in the other.`,
107
75
  },
108
76
  {
109
77
  id: 'extra-monitor',
110
78
  title: 'The desktop is open somewhere else',
111
79
  match: /open somewhere else|opened somewhere else/,
112
- body: `GNOME Remote Desktop's headless mode gives each connection a screen of its own: a second computer, or another
113
- Tabby window, connected to the same account gets an empty extra screen rather than the same one. So when the
114
- desktop is open somewhere else already, you choose. <b>Take it over</b> disconnects the other connection, as
115
- Windows does; the session and its apps carry on, on this screen. <b>Open a second screen</b> keeps both, each
116
- with a screen of its own. A desktop taken over from here doesn't take itself back: it says so, and offers
117
- the same two choices.`,
118
80
  },
119
81
  ];
120
82
  /** The troubleshooting entry that explains a message, if one does. */
@@ -127,13 +89,15 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)
127
89
  exports.esc = esc;
128
90
  function hotkeyLabel(binding) {
129
91
  const first = Array.isArray(binding) ? binding[0] : null;
130
- const strokes = Array.isArray(first) ? first : typeof first === 'string' ? [first] : [];
92
+ // Text only: the config may hold anything, and this runs for the settings page and the first-connect tip.
93
+ const strokes = (Array.isArray(first) ? first : [first]).filter((s) => typeof s === 'string');
131
94
  if (!strokes.length) {
132
95
  return null;
133
96
  }
134
97
  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(', ');
98
+ // A Map, not an object literal: a part named 'constructor' or 'toString' would find what objects inherit.
99
+ const MAC = new Map([['Shift', '⇧'], ['Alt', '⌥'], ['Ctrl', '⌃'], ['Meta', '⌘'], ['Cmd', '⌘']]);
100
+ return strokes.map(s => s.split('-').map(k => mac ? MAC.get(k) ?? k : k).join(mac ? '' : '+')).join(', ');
137
101
  }
138
102
  const TIP_STYLE = `
139
103
  .trd-tip { position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 2; max-width: min(560px, 90%);
@@ -151,10 +115,11 @@ const TIP_STYLE = `
151
115
  * connects. Nothing else pops up.
152
116
  */
153
117
  let RemoteDesktopHelp = class RemoteDesktopHelp {
154
- constructor(app, config, zone) {
118
+ constructor(app, config, zone, platform) {
155
119
  this.app = app;
156
120
  this.config = config;
157
121
  this.zone = zone;
122
+ this.platform = platform;
158
123
  /** A section to show; the open settings page follows it (see settingsPage.ts). */
159
124
  this.show$ = new rxjs_1.Subject();
160
125
  /** For a settings page not yet made: where to scroll once it is. */
@@ -165,6 +130,11 @@ let RemoteDesktopHelp = class RemoteDesktopHelp {
165
130
  }
166
131
  /** Opens Tabby's settings at the plugin's page, at `topic` (and a troubleshooting entry). */
167
132
  open(topic = 'start', entry) {
133
+ if (topic === 'troubleshooting') {
134
+ const anchor = exports.TROUBLESHOOTING.some(e => e.id === entry) ? `#${entry}` : '';
135
+ this.platform.openExternal(exports.TROUBLESHOOTING_URL + anchor);
136
+ return;
137
+ }
168
138
  this.zone.run(() => {
169
139
  this.pending = { topic, entry };
170
140
  this.openSettings(exports.SETTINGS_TAB_ID);
@@ -258,6 +228,5 @@ let RemoteDesktopHelp = class RemoteDesktopHelp {
258
228
  exports.RemoteDesktopHelp = RemoteDesktopHelp;
259
229
  exports.RemoteDesktopHelp = RemoteDesktopHelp = __decorate([
260
230
  (0, core_1.Injectable)({ providedIn: 'root' }),
261
- __metadata("design:paramtypes", [tabby_core_1.AppService, tabby_core_1.ConfigService, core_1.NgZone])
231
+ __metadata("design:paramtypes", [tabby_core_1.AppService, tabby_core_1.ConfigService, core_1.NgZone, tabby_core_1.PlatformService])
262
232
  ], RemoteDesktopHelp);
263
- //# sourceMappingURL=help.js.map
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HostCompatibility = void 0;
4
+ exports.terminalInputs = terminalInputs;
5
+ exports.createHostCompatibility = createHostCompatibility;
6
+ function text(value) {
7
+ return typeof value === 'string' && value.trim() ? value.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 256) || null : null;
8
+ }
9
+ function read(source) {
10
+ try {
11
+ return source?.();
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ /** Ranges spanning multiple majors, URLs and workspace aliases cannot establish a single major. */
18
+ function declaredMajor(declaration) {
19
+ const match = declaration?.match(/^[~^]?(\d+)(?:\.(?:\d+|x|\*)(?:\.(?:\d+|x|\*))?)?(?:-[\da-z.-]+)?(?:\+[\da-z.-]+)?$/i);
20
+ const major = match ? Number(match[1]) : NaN;
21
+ return Number.isSafeInteger(major) ? major : null;
22
+ }
23
+ /** Shared terminal DOM adapter; keep desktop layers and forms in agreement about the input they cover. */
24
+ function terminalInputs(container) {
25
+ return Array.from(container.querySelectorAll('textarea.xterm-helper-textarea'));
26
+ }
27
+ /** An Angular factory provides one instance per plugin window; this module itself needs no Angular runtime. */
28
+ class HostCompatibility {
29
+ constructor(sources) {
30
+ this.nativeModalDragRegion = null;
31
+ const xtermDeclaration = text(read(() => {
32
+ const terminal = sources.terminalPackage?.();
33
+ return text(terminal?.dependencies?.['@xterm/xterm']) ?? text(terminal?.devDependencies?.['@xterm/xterm']);
34
+ }));
35
+ this.info = Object.freeze({
36
+ name: text(read(sources.name)), version: text(read(sources.version)), platform: text(sources.platform),
37
+ electron: text(sources.electron), node: text(sources.node), xtermDeclaration,
38
+ xtermMajor: declaredMajor(xtermDeclaration),
39
+ });
40
+ this.modalStyle = sources.modalStyle ?? ((element, pseudo) => getComputedStyle(element, pseudo));
41
+ }
42
+ /** Probe each modal; themes and UI scale can change while the application is running. */
43
+ hasNativeModalDragRegion(modal) {
44
+ let supported = false;
45
+ try {
46
+ const style = this.modalStyle(modal, '::before');
47
+ supported = style.content !== 'none' && style.content !== 'normal' && style.display !== 'none' &&
48
+ style.getPropertyValue('-webkit-app-region') === 'drag' && parseFloat(style.height) > 0;
49
+ }
50
+ catch { }
51
+ this.nativeModalDragRegion = supported;
52
+ return supported;
53
+ }
54
+ snapshot(pane) {
55
+ return {
56
+ ...this.info,
57
+ capabilities: {
58
+ nativeModalDragRegion: this.nativeModalDragRegion,
59
+ terminalInput: pane ? terminalInputs(pane).length > 0 : null,
60
+ },
61
+ };
62
+ }
63
+ /** Local build information and observed capabilities only; no config, accounts, paths or connection details. */
64
+ diagnosticLines(pane) {
65
+ const info = this.snapshot(pane);
66
+ const { nativeModalDragRegion, terminalInput } = info.capabilities;
67
+ const drag = nativeModalDragRegion === null ? 'unobserved' : nativeModalDragRegion ? 'host' : 'plugin fallback';
68
+ const input = terminalInput === null ? 'unobserved' : terminalInput ? 'available' : 'unavailable';
69
+ return [
70
+ `Host: ${info.name ?? 'unknown'} ${info.version ?? 'unknown'} on ${info.platform ?? 'unknown'}`,
71
+ `Runtime: Electron ${info.electron ?? 'unknown'}, Node ${info.node ?? 'unknown'}`,
72
+ `Terminal: xterm ${info.xtermDeclaration ?? 'unknown'} (host declaration)`,
73
+ `Compatibility: modal drag region ${drag}; terminal input ${input}`,
74
+ ];
75
+ }
76
+ }
77
+ exports.HostCompatibility = HostCompatibility;
78
+ /** Prefer Tabby's public version API; optional metadata reads never become requirements for operating a desktop. */
79
+ function createHostCompatibility(platform) {
80
+ return new HostCompatibility({
81
+ name: () => require('@electron/remote').app.getName(),
82
+ version: () => platform.getAppVersion(),
83
+ platform: process.platform, electron: process.versions.electron, node: process.versions.node,
84
+ terminalPackage: () => require('tabby-terminal/package.json'),
85
+ });
86
+ }
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
@@ -18,6 +18,7 @@ const desk_1 = require("./desk");
18
18
  const header_1 = require("./header");
19
19
  const keyboard_1 = require("./keyboard");
20
20
  const help_1 = require("./help");
21
+ const hostCompat_1 = require("./hostCompat");
21
22
  const signin_1 = require("./signin");
22
23
  const rdpFile_1 = require("./rdpFile");
23
24
  const rdpProfile_1 = require("./rdpProfile");
@@ -28,7 +29,8 @@ const targets_1 = require("./targets");
28
29
  const ui_1 = require("./ui");
29
30
  let RemoteDesktopModule = class RemoteDesktopModule {
30
31
  constructor(app, hotkeys, desktop, header, targets, keyboard, injector) {
31
- (0, desktop_service_1.installStyle)();
32
+ const compat = injector.get(hostCompat_1.HostCompatibility);
33
+ (0, desktop_service_1.installStyle)(compat);
32
34
  header.install();
33
35
  // Passwords go to Tabby's Vault while it is enabled (as Tabby's SSH passwords do), else to the system keychain.
34
36
  try {
@@ -49,7 +51,8 @@ let RemoteDesktopModule = class RemoteDesktopModule {
49
51
  'remote-desktop-disconnect': pane => desktop.disconnect(pane),
50
52
  };
51
53
  hotkeys.hotkey$.subscribe((id) => {
52
- const action = actions[id];
54
+ // Tabby fires any key bound under `hotkeys` in the config ('constructor' too): the table's own entries only.
55
+ const action = Object.prototype.hasOwnProperty.call(actions, id) ? actions[id] : undefined;
53
56
  const pane = action && (0, targets_1.desktopPaneOf)(app.activeTab);
54
57
  if (!action || !pane) {
55
58
  return;
@@ -61,12 +64,13 @@ let RemoteDesktopModule = class RemoteDesktopModule {
61
64
  });
62
65
  // Handle for tests and troubleshooting from DevTools.
63
66
  const w = window;
64
- w.__remoteDesktop = { app, desktop, targets, desktopPaneOf: targets_1.desktopPaneOf, injector, execRemote: ssh_1.execRemote, parseRdpFile: rdpFile_1.parseRdpFile, help: injector.get(help_1.RemoteDesktopHelp), updates: injector.get(updates_1.UpdateCheck) };
67
+ w.__remoteDesktop = { app, desktop, targets, desktopPaneOf: targets_1.desktopPaneOf, injector, compat, execRemote: ssh_1.execRemote, parseRdpFile: rdpFile_1.parseRdpFile, help: injector.get(help_1.RemoteDesktopHelp), updates: injector.get(updates_1.UpdateCheck) };
65
68
  }
66
69
  };
67
70
  RemoteDesktopModule = __decorate([
68
71
  (0, core_1.NgModule)({
69
72
  providers: [
73
+ { provide: hostCompat_1.HostCompatibility, useFactory: hostCompat_1.createHostCompatibility, deps: [tabby_core_1.PlatformService] },
70
74
  { provide: tabby_core_1.HotkeyProvider, useClass: ui_1.RemoteDesktopHotkeys, multi: true },
71
75
  { provide: tabby_core_1.ConfigProvider, useClass: ui_1.RemoteDesktopConfig, multi: true },
72
76
  { provide: tabby_core_1.TabContextMenuItemProvider, useClass: ui_1.RemoteDesktopContextMenu, multi: true },
@@ -85,4 +89,3 @@ RemoteDesktopModule = __decorate([
85
89
  __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
90
  ], RemoteDesktopModule);
87
91
  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