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.
- package/CHANGELOG.md +580 -0
- package/README.md +252 -34
- package/dist/accountForm.js +0 -1
- package/dist/accounts.js +0 -1
- package/dist/audio.js +0 -1
- package/dist/authorities.js +331 -0
- package/dist/byteQueue.js +102 -0
- package/dist/clipboard.js +106 -0
- package/dist/connectionStatus.js +0 -1
- package/dist/desk.js +16 -47
- package/dist/deskScript.js +196 -6
- package/dist/desktop.service.js +1763 -204
- package/dist/desktopForm.js +72 -13
- package/dist/desktops.js +243 -19
- package/dist/drives.js +525 -0
- package/dist/fileTransfer.js +1064 -72
- package/dist/gateway.js +651 -0
- package/dist/header.js +76 -5
- package/dist/help.js +34 -13
- package/dist/hyperv.js +129 -0
- package/dist/index.js +2 -2
- package/dist/keyboard.js +0 -1
- package/dist/microphone.js +57 -13
- package/dist/ntlm.js +225 -0
- package/dist/osd.js +56 -15
- package/dist/probe.js +15 -3
- package/dist/rdcleanpath.js +468 -52
- package/dist/rdpFile.js +53 -4
- package/dist/rdpProfile.js +75 -22
- package/dist/remoteSetup.js +103 -23
- package/dist/sessionLog.js +53 -0
- package/dist/settingsPage.js +117 -27
- package/dist/signin.js +429 -54
- package/dist/ssh.js +99 -18
- package/dist/sshChannelStream.js +50 -6
- package/dist/targets.js +185 -39
- package/dist/ui.js +98 -20
- package/dist/unshowable.js +41 -0
- package/dist/updates.js +37 -2
- package/dist/vms.js +36 -5
- package/dist/wake.js +31 -12
- package/package.json +4 -3
- package/remote/trd-pty.py +74 -13
- package/vendor/SHA256SUMS +26 -0
- package/vendor/iron-remote-desktop-rdp.js +418 -390
- package/vendor/ironrdp_web_bg.wasm +0 -0
- package/dist/accountForm.js.map +0 -1
- package/dist/accounts.js.map +0 -1
- package/dist/audio.js.map +0 -1
- package/dist/connectionStatus.js.map +0 -1
- package/dist/desk.js.map +0 -1
- package/dist/deskScript.js.map +0 -1
- package/dist/desktop.service.js.map +0 -1
- package/dist/desktopForm.js.map +0 -1
- package/dist/desktops.js.map +0 -1
- package/dist/fileTransfer.js.map +0 -1
- package/dist/header.js.map +0 -1
- package/dist/help.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/keyboard.js.map +0 -1
- package/dist/microphone.js.map +0 -1
- package/dist/osd.js.map +0 -1
- package/dist/probe.js.map +0 -1
- package/dist/rdcleanpath.js.map +0 -1
- package/dist/rdpFile.js.map +0 -1
- package/dist/rdpProfile.js.map +0 -1
- package/dist/remoteSetup.js.map +0 -1
- package/dist/settingsPage.js.map +0 -1
- package/dist/signin.js.map +0 -1
- package/dist/ssh.js.map +0 -1
- package/dist/sshChannelStream.js.map +0 -1
- package/dist/targets.js.map +0 -1
- package/dist/ui.js.map +0 -1
- package/dist/updates.js.map +0 -1
- package/dist/vms.js.map +0 -1
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
39
|
+
title: 'A certificate this computer can\'t verify, or one that has changed',
|
|
40
40
|
match: /certificate/i,
|
|
41
|
-
body: `
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 & 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.
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
package/dist/microphone.js
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
* the microphone is captured only in between. The browser's capture runs at the audio device's
|
|
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.
|
|
12
|
-
*
|
|
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 =>
|
|
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
|