tabby-rdp 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +580 -0
  2. package/README.md +252 -34
  3. package/dist/accountForm.js +0 -1
  4. package/dist/accounts.js +0 -1
  5. package/dist/audio.js +0 -1
  6. package/dist/authorities.js +331 -0
  7. package/dist/byteQueue.js +102 -0
  8. package/dist/clipboard.js +106 -0
  9. package/dist/connectionStatus.js +0 -1
  10. package/dist/desk.js +16 -47
  11. package/dist/deskScript.js +196 -6
  12. package/dist/desktop.service.js +1763 -204
  13. package/dist/desktopForm.js +72 -13
  14. package/dist/desktops.js +243 -19
  15. package/dist/drives.js +525 -0
  16. package/dist/fileTransfer.js +1064 -72
  17. package/dist/gateway.js +651 -0
  18. package/dist/header.js +76 -5
  19. package/dist/help.js +34 -13
  20. package/dist/hyperv.js +129 -0
  21. package/dist/index.js +2 -2
  22. package/dist/keyboard.js +0 -1
  23. package/dist/microphone.js +57 -13
  24. package/dist/ntlm.js +225 -0
  25. package/dist/osd.js +56 -15
  26. package/dist/probe.js +15 -3
  27. package/dist/rdcleanpath.js +468 -52
  28. package/dist/rdpFile.js +53 -4
  29. package/dist/rdpProfile.js +75 -22
  30. package/dist/remoteSetup.js +103 -23
  31. package/dist/sessionLog.js +53 -0
  32. package/dist/settingsPage.js +117 -27
  33. package/dist/signin.js +429 -54
  34. package/dist/ssh.js +99 -18
  35. package/dist/sshChannelStream.js +50 -6
  36. package/dist/targets.js +185 -39
  37. package/dist/ui.js +98 -20
  38. package/dist/unshowable.js +41 -0
  39. package/dist/updates.js +37 -2
  40. package/dist/vms.js +36 -5
  41. package/dist/wake.js +31 -12
  42. package/package.json +4 -3
  43. package/remote/trd-pty.py +74 -13
  44. package/vendor/SHA256SUMS +26 -0
  45. package/vendor/iron-remote-desktop-rdp.js +418 -390
  46. package/vendor/ironrdp_web_bg.wasm +0 -0
  47. package/dist/accountForm.js.map +0 -1
  48. package/dist/accounts.js.map +0 -1
  49. package/dist/audio.js.map +0 -1
  50. package/dist/connectionStatus.js.map +0 -1
  51. package/dist/desk.js.map +0 -1
  52. package/dist/deskScript.js.map +0 -1
  53. package/dist/desktop.service.js.map +0 -1
  54. package/dist/desktopForm.js.map +0 -1
  55. package/dist/desktops.js.map +0 -1
  56. package/dist/fileTransfer.js.map +0 -1
  57. package/dist/header.js.map +0 -1
  58. package/dist/help.js.map +0 -1
  59. package/dist/index.js.map +0 -1
  60. package/dist/keyboard.js.map +0 -1
  61. package/dist/microphone.js.map +0 -1
  62. package/dist/osd.js.map +0 -1
  63. package/dist/probe.js.map +0 -1
  64. package/dist/rdcleanpath.js.map +0 -1
  65. package/dist/rdpFile.js.map +0 -1
  66. package/dist/rdpProfile.js.map +0 -1
  67. package/dist/remoteSetup.js.map +0 -1
  68. package/dist/settingsPage.js.map +0 -1
  69. package/dist/signin.js.map +0 -1
  70. package/dist/ssh.js.map +0 -1
  71. package/dist/sshChannelStream.js.map +0 -1
  72. package/dist/targets.js.map +0 -1
  73. package/dist/ui.js.map +0 -1
  74. package/dist/updates.js.map +0 -1
  75. package/dist/vms.js.map +0 -1
  76. package/dist/wake.js.map +0 -1
package/dist/ui.js CHANGED
@@ -17,9 +17,12 @@ exports.settingsMenu = settingsMenu;
17
17
  const core_1 = require("@angular/core");
18
18
  const tabby_core_1 = require("tabby-core");
19
19
  const tabby_terminal_1 = require("tabby-terminal");
20
+ const clipboard_1 = require("./clipboard");
20
21
  const desktop_service_1 = require("./desktop.service");
22
+ const desktops_1 = require("./desktops");
21
23
  const help_1 = require("./help");
22
24
  Object.defineProperty(exports, "TOGGLE_HOTKEY", { enumerable: true, get: function () { return help_1.TOGGLE_HOTKEY; } });
25
+ const hyperv_1 = require("./hyperv");
23
26
  const keyboard_1 = require("./keyboard");
24
27
  const updates_1 = require("./updates");
25
28
  const ssh_1 = require("./ssh");
@@ -48,8 +51,13 @@ let RemoteDesktopConfig = class RemoteDesktopConfig extends tabby_core_1.ConfigP
48
51
  sharpness: 'standard',
49
52
  // `desk`: installs a login hook on remotes (applied the next time a desktop opens there).
50
53
  desk: false,
54
+ // The keys `desk` sends on each host, as that host's setup reported them: [{ desktop: <host account>,
55
+ // sha256 }] (a digest, not the key). Requests without the host's key are ignored (see openConsole).
56
+ deskKeys: [],
51
57
  // macOS: ⌘ acts as Ctrl on the remote desktop (false: ⌘ is the Windows/Super key).
52
58
  macShortcuts: true,
59
+ // Send the characters typed (Unicode) rather than key positions: dead keys, layouts the remote lacks.
60
+ unicodeKeys: false,
53
61
  // Play the remote desktop's sound (applies on the next connect).
54
62
  sound: true,
55
63
  // H.264 in the graphics pipeline, decoded by the browser, where it can (applies on the next connect).
@@ -58,8 +66,12 @@ let RemoteDesktopConfig = class RemoteDesktopConfig extends tabby_core_1.ConfigP
58
66
  zoom: 'fit',
59
67
  // A small indicator on the desktop: throughput, frames per second, SSH round trip, connection path.
60
68
  connectionStatus: false,
61
- // Send the microphone to the remote desktop while an application there records (applies on the next connect).
69
+ // Send the microphone to a remote desktop that asks for it, as one does while an app there records
70
+ // (applies on the next connect).
62
71
  microphone: false,
72
+ // Which ways the clipboard (text, pictures, files) goes, unless a desktop has its own: 'both' | 'fromRemote'
73
+ // (only from the remote desktop to this computer) | 'off' (applies on the next connect; see clipboard.ts).
74
+ clipboard: 'both',
63
75
  // More desktops behind SSH hosts (e.g. a Windows VM whose RDP port the host forwards); see desktops.ts.
64
76
  desktops: [],
65
77
  // Saved accounts desktops can sign in with: [{ id, name, username, domain }]; passwords are in the keychain
@@ -67,8 +79,19 @@ let RemoteDesktopConfig = class RemoteDesktopConfig extends tabby_core_1.ConfigP
67
79
  accounts: [],
68
80
  // Sharpness for particular desktops, overriding `sharpness`: [{ desktop: <session key>, sharpness }].
69
81
  desktopSharpness: [],
70
- // Certificates of desktops behind hosts, trusted on first use: [{ desktop: <session key>, sha256 }].
82
+ // Folders shared with remote desktops as drives (\\tsclient\<name>): [{ path, name, readOnly }]; see drives.ts.
83
+ sharedFolders: [],
84
+ // Certificates remembered for desktops and RD Gateways: [{ desktop: <session key, or gateway#host:port>, sha256,
85
+ // authority }]; `authority` true where a certificate authority vouched for it, false where the user trusted
86
+ // it, none where it was trusted on first use (desktops behind hosts). See RemoteDesktopService.checkTrusted.
71
87
  trustedCertificates: [],
88
+ // Desktops allowed to sign in without Network Level Authentication ("Send the password anyway"), each with
89
+ // the certificate it was allowed with: [{ desktop: <session key>, sha256 }]. Tabby saves only keys named
90
+ // here (test/unit/config-defaults.ts).
91
+ withoutNla: [],
92
+ // Machines reached with ssh typed in an SSH tab whose desktop was chosen there "from now on" (see remoteFor):
93
+ // [{ via: <that tab's key>, destination }]. Desktop opens such a one at once; others are asked about first.
94
+ nestedSSH: [],
72
95
  // The on-screen display naming a desktop for a moment (see osd.ts).
73
96
  osd: { show: 'auto', font: 'condensed', size: 'medium', position: 'top-right', color: '', seconds: 2.5 },
74
97
  // Look for VMs with a desktop on SSH hosts (libvirt) and offer them in the menus (see vms.ts).
@@ -81,6 +104,10 @@ let RemoteDesktopConfig = class RemoteDesktopConfig extends tabby_core_1.ConfigP
81
104
  updateNoted: '',
82
105
  // The tip shown the first time a desktop connects (see help.ts) has been shown.
83
106
  tipShown: false,
107
+ // The passwords 0.5.0 saved for desktops reached through an RD Gateway, under keys that are now those of the
108
+ // same addresses without one, have been forgotten from the Vault (see
109
+ // RemoteDesktopService.gatewayPasswords; each computer's keychain keeps a mark of its own).
110
+ gatewayPasswordsMigrated: false,
84
111
  },
85
112
  };
86
113
  this.platformDefaults = {
@@ -110,30 +137,38 @@ function toggleLabel(desktop, pane) {
110
137
  return desktop.isOpenElsewhere(pane) ? `Switch to ${what} (open in another tab)` : `Open ${what}`;
111
138
  }
112
139
  /**
113
- * The host's other desktops (besides the one the toggle acts on): open one here, replacing the current one.
114
- * Then "Add a desktop behind <host>…".
140
+ * The host's other desktops (besides the one the toggle acts on, see RemoteDesktopService.choicesOf): open one here,
141
+ * replacing the current one, a machine's reached through the host included. Then "Add a desktop behind <host>…".
115
142
  */
116
143
  function desktopChoices(desktop, pane, targetLabel) {
117
- const { specs, current } = desktop.choicesOf(pane);
118
- const items = specs.filter(spec => spec.id !== current?.id).map(spec => ({
144
+ const { specs, chosen } = desktop.choicesOf(pane);
145
+ const items = specs.filter(spec => !chosen(spec)).map(spec => ({
119
146
  label: desktop.isOpenElsewhere(pane, spec) ? `Switch to ${spec.name} (open in another tab)`
120
147
  // Found on the host (see vms.ts): say so, and that opening one that's off starts it.
121
148
  : spec.found === 'off' ? `Start and open ${spec.name} (VM, shut off)` : spec.found ? `Open ${spec.name} (VM)` : `Open ${spec.name}`,
122
149
  click: () => desktop.showDesktop(pane, spec.id),
123
150
  }));
151
+ // A host with more VMs than a menu lists: the rest are left out, and that is said rather than hidden.
152
+ if (desktop.vmsCut(pane)) {
153
+ items.push({ label: `Only the first ${hyperv_1.MAX_VMS} VMs found are listed`, enabled: false });
154
+ }
124
155
  // Not in a remote desktop tab: it isn't an SSH host.
125
156
  if (targetLabel && desktop.hasConsole(pane)) {
126
157
  items.push({ label: `Add a desktop behind ${targetLabel}…`, click: () => desktop.addDesktop(pane) });
127
158
  }
128
159
  return items;
129
160
  }
130
- /** What can be done with the pane's connected desktop: send keys, view only, a screenshot. Shared by the menus. */
161
+ /**
162
+ * What can be done with the pane's connected desktop: send keys, view only, its microphone, a screenshot. Shared by
163
+ * the menus.
164
+ */
131
165
  function desktopActions(desktop, keyboard, pane) {
132
166
  const spec = desktop.desktopOf(pane);
133
167
  if (!spec || !desktop.isConnected(pane)) {
134
168
  return [];
135
169
  }
136
170
  const viewOnly = desktop.isViewOnly(pane);
171
+ const microphoneStopped = desktop.microphoneStoppedFor(pane);
137
172
  return [
138
173
  {
139
174
  label: 'Send keys',
@@ -146,6 +181,14 @@ function desktopActions(desktop, keyboard, pane) {
146
181
  checked: viewOnly,
147
182
  click: () => desktop.setViewOnly(pane, !viewOnly),
148
183
  },
184
+ // With the setting on, every desktop gets the microphone when it asks for it; this one can be left out,
185
+ // reconnects included, and taken back in (see RemoteDesktopService.stopMicrophone).
186
+ ...desktop.settings().microphone ? [{
187
+ type: 'checkbox',
188
+ label: 'Send the microphone (when this desktop asks for it)',
189
+ checked: !microphoneStopped,
190
+ click: () => microphoneStopped ? desktop.resumeMicrophone(pane) : desktop.stopMicrophone(pane),
191
+ }] : [],
149
192
  { label: 'Save a screenshot (to Downloads and the clipboard)', click: () => desktop.saveScreenshot(pane) },
150
193
  ];
151
194
  }
@@ -193,19 +236,23 @@ let RemoteDesktopContextMenu = class RemoteDesktopContextMenu extends tabby_core
193
236
  if (this.desktop.isConnected(pane)) {
194
237
  items.push({ label: 'Send files…', click: () => this.desktop.sendFiles(pane) });
195
238
  items.push(...desktopActions(this.desktop, this.keyboard, pane));
196
- // Several desktops in this tab: paste on all of them, or type into all of them.
239
+ // Several desktops showing in this tab: paste on all of them, or type into all of them. Typing into all, once
240
+ // on, stays here to be turned off while fewer of them show (those hidden get nothing: see
241
+ // RemoteDesktopService.takesAll).
197
242
  const all = this.desktop.desktopsInTab(pane);
198
243
  if (all > 1) {
199
244
  items.push({ label: `Paste to all ${all} desktops in this tab`, click: () => this.desktop.pasteToAll(pane) });
245
+ }
246
+ if (all > 1 || this.desktop.isBroadcast(pane)) {
200
247
  items.push({
201
248
  type: 'checkbox',
202
- label: `Type into all ${all} desktops in this tab`,
249
+ label: all > 1 ? `Type into all ${all} desktops in this tab` : 'Type into all desktops in this tab',
203
250
  checked: this.desktop.isBroadcast(pane),
204
251
  click: () => this.desktop.setBroadcast(pane, !this.desktop.isBroadcast(pane)),
205
252
  });
206
253
  }
207
254
  }
208
- if (this.desktop.desktopOf(pane)?.found) {
255
+ if (this.desktop.canSaveFoundDesktop(pane)) {
209
256
  items.push({ label: `Save ${this.desktop.desktopOf(pane).name} to this host's desktops`, click: () => this.desktop.saveFoundDesktop(pane) });
210
257
  }
211
258
  if (this.desktop.canSignInAgain(pane)) {
@@ -252,6 +299,11 @@ function settingsMenu(desktop, pane, help) {
252
299
  // The desktop open in this pane can have its own sharpness (for example, Retina for Windows only).
253
300
  const spec = pane ? desktop.desktopOf(pane) : null;
254
301
  const own = pane ? desktop.ownSharpness(pane) : null;
302
+ // Narrowed while it was open, by another way of opening it (see DesktopSession.clipboardCap): said only where that
303
+ // is narrower than what the desktop would have otherwise, its own setting or this one.
304
+ const capOf = pane ? desktop.clipboardCapOf(pane) : 'both';
305
+ const otherwise = spec?.clipboard ?? current.clipboard;
306
+ const cap = (0, clipboard_1.narrowest)(capOf, otherwise) !== otherwise ? (0, clipboard_1.narrowest)(capOf, otherwise) : 'both';
255
307
  const ownRadio = (value, label) => ({
256
308
  type: 'radio',
257
309
  label,
@@ -264,6 +316,12 @@ function settingsMenu(desktop, pane, help) {
264
316
  ownRadio('standard', 'Standard'),
265
317
  ownRadio('retina', 'Retina'),
266
318
  ] : [];
319
+ // The configured desktops, each with its place in the list (what editing and removing take); an item that is no
320
+ // entry describes no desktop, and isn't listed. Shown as text whatever an entry holds (see entryText): this menu is
321
+ // part of every SSH tab's, which Tabby builds from every plugin's items at once, so one entry that can't be shown
322
+ // would take Tabby's own items too.
323
+ const entries = desktop.configuredDesktops().flatMap((d, i) => (0, desktops_1.isDesktopEntry)(d) ? [{ index: i, ...(0, desktops_1.entryText)(d) }] : []);
324
+ const entryLabel = (e) => `${e.name}${e.via ? ` (behind ${e.via})` : ''}…`;
267
325
  return [
268
326
  ...help ? [
269
327
  { label: 'All settings, keys and help…', click: () => help.open() },
@@ -300,16 +358,37 @@ function settingsMenu(desktop, pane, help) {
300
358
  },
301
359
  {
302
360
  type: 'checkbox',
303
- label: 'Send the microphone while an app on the remote desktop records (applies on the next connect)',
361
+ label: 'Send the microphone to remote desktops that ask for it (on applies on the next connect, off at once)',
304
362
  checked: current.microphone,
305
363
  click: () => desktop.updateSettings({ microphone: !current.microphone }),
306
364
  },
365
+ {
366
+ // A desktop's own (its profile, or its edit form) takes the place of this: the one open here says so.
367
+ label: 'Clipboard (applies on the next connect)',
368
+ submenu: [
369
+ // Its own: from its profile (or that profile's group's or type's defaults), or its entry behind a host.
370
+ ...spec?.clipboard ? [
371
+ { label: `${spec.name} doesn't follow this: its profile or its own settings say ${clipboard_1.CLIPBOARD_LABELS[spec.clipboard].toLowerCase()}`, enabled: false },
372
+ ] : [],
373
+ ...cap !== 'both' ? [
374
+ { label: `Clipboard sharing with ${spec?.name ?? 'this desktop'} is ${clipboard_1.CLIPBOARD_LABELS[cap].toLowerCase()} until it is closed, as another way of opening it asked`, enabled: false },
375
+ ] : [],
376
+ ...spec?.clipboard || cap !== 'both' ? [{ type: 'separator' }] : [],
377
+ ...Object.entries(clipboard_1.CLIPBOARD_LABELS).map(([mode, label]) => radio('clipboard', mode, label)),
378
+ ],
379
+ },
307
380
  {
308
381
  type: 'checkbox',
309
382
  label: 'Show connection status on the desktop (throughput, frames per second, round trip)',
310
383
  checked: current.connectionStatus,
311
384
  click: () => desktop.updateSettings({ connectionStatus: !current.connectionStatus }),
312
385
  },
386
+ {
387
+ type: 'checkbox',
388
+ label: 'Send text as typed (characters rather than key positions: dead keys, other layouts)',
389
+ checked: current.unicodeKeys,
390
+ click: () => desktop.updateSettings({ unicodeKeys: !current.unicodeKeys }),
391
+ },
313
392
  ...process.platform === 'darwin' ? [{
314
393
  type: 'checkbox',
315
394
  label: 'Mac shortcuts on the remote desktop (⌘C, ⌘V, ⌘Z… act as Ctrl+C, Ctrl+V…; off: ⌘ is the Windows key)',
@@ -321,18 +400,18 @@ function settingsMenu(desktop, pane, help) {
321
400
  {
322
401
  // The form shows over a console (a remote desktop tab edits its desktop in Tabby's profile settings).
323
402
  label: 'Edit a desktop',
324
- enabled: !!pane && desktop.hasConsole(pane) && desktop.configuredDesktops().length > 0,
325
- submenu: desktop.configuredDesktops().map((d, i) => ({
326
- label: `${d.name ?? `${d.host}:${d.port}`} (behind ${d.via})…`,
327
- click: () => pane && desktop.editDesktop(pane, i),
403
+ enabled: !!pane && desktop.hasConsole(pane) && entries.length > 0,
404
+ submenu: entries.map(e => ({
405
+ label: entryLabel(e),
406
+ click: () => pane && desktop.editDesktop(pane, e.index),
328
407
  })),
329
408
  },
330
409
  {
331
410
  label: 'Remove a desktop',
332
- enabled: desktop.configuredDesktops().length > 0,
333
- submenu: desktop.configuredDesktops().map((d, i) => ({
334
- label: `${d.name ?? `${d.host}:${d.port}`} (behind ${d.via})…`,
335
- click: () => desktop.confirmRemoveDesktop(i),
411
+ enabled: entries.length > 0,
412
+ submenu: entries.map(e => ({
413
+ label: entryLabel(e),
414
+ click: () => desktop.confirmRemoveDesktop(e.index),
336
415
  })),
337
416
  },
338
417
  ];
@@ -390,4 +469,3 @@ exports.RemoteDesktopToolbarButton = RemoteDesktopToolbarButton = __decorate([
390
469
  (0, core_1.Injectable)(),
391
470
  __metadata("design:paramtypes", [desktop_service_1.RemoteDesktopService, core_1.NgZone])
392
471
  ], RemoteDesktopToolbarButton);
393
- //# sourceMappingURL=ui.js.map
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UNSHOWABLE = void 0;
4
+ exports.withoutWordJoiners = withoutWordJoiners;
5
+ exports.showable = showable;
6
+ /**
7
+ * A character that can change how the text around it reads without showing itself, in a name someone else chose (an
8
+ * .rdp file's, see RemoteDesktopService.importRdp; an `ssh` destination a host reports, see RemoteTargets.nestedSSH;
9
+ * what a server's certificate says of itself): control and format characters (bidi overrides and isolates, zero-width
10
+ * ones, tags), the other default-ignorable ones (variation selectors, the combining grapheme joiner, Hangul fillers),
11
+ * line and paragraph separators, private-use and unpaired surrogate code points, and blanks other than the space
12
+ * (no-break and wide spaces, the braille blank).
13
+ */
14
+ exports.UNSHOWABLE = /[\p{Cc}\p{Cf}\p{Co}\p{Cs}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}⠀]|(?! )\p{Zs}/u;
15
+ /**
16
+ * A zero-width joiner or non-joiner between two letters of a script that writes words with them: Arabic (as Persian
17
+ * does) and the Indic scripts. There it can change how the letters join, and so show; between letters of other scripts
18
+ * (Latin, say) it shows nothing at all, and `ad\u200dmin` reads as `admin`.
19
+ */
20
+ const WORD_JOINERS = new RegExp(['Arabic', 'Devanagari', 'Bengali', 'Gurmukhi', 'Gujarati', 'Oriya', 'Tamil', 'Telugu', 'Kannada', 'Malayalam', 'Sinhala']
21
+ .map(script => `(?<=[\\p{L}\\p{M}])(?<=\\p{scx=${script}})[\\u200c\\u200d](?=[\\p{L}\\p{M}])(?=\\p{scx=${script}})`).join('|'), 'gu');
22
+ /** `text` without the joiners its scripts write words with (see WORD_JOINERS): what UNSHOWABLE then finds doesn't show. */
23
+ function withoutWordJoiners(text) {
24
+ return text.replace(WORD_JOINERS, '');
25
+ }
26
+ /**
27
+ * Text someone else chose, as it is shown and logged: what could reorder or hide part of it (UNSHOWABLE) as U+FFFD, and
28
+ * at most `max` characters of it, an ellipsis saying where it was cut.
29
+ */
30
+ function showable(text, max = Infinity) {
31
+ const shown = text.replace(new RegExp(exports.UNSHOWABLE.source, 'gu'), '�');
32
+ if (shown.length <= max) {
33
+ return shown;
34
+ }
35
+ // Not between the two halves of a character outside the BMP, which would leave half of one.
36
+ let end = Math.max(0, max - 1);
37
+ if (end > 0 && /[\ud800-\udbff]/.test(shown[end - 1])) {
38
+ end--;
39
+ }
40
+ return `${shown.slice(0, end)}…`;
41
+ }
package/dist/updates.js CHANGED
@@ -10,6 +10,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.UpdateCheck = void 0;
13
+ exports.latestVersion = latestVersion;
13
14
  exports.installedVersion = installedVersion;
14
15
  exports.newer = newer;
15
16
  const core_1 = require("@angular/core");
@@ -20,6 +21,41 @@ const targets_1 = require("./targets");
20
21
  const REGISTRY = 'https://registry.npmjs.org/tabby-rdp/latest';
21
22
  const RELEASES = 'https://github.com/meanaverage/tabby-rdp/releases';
22
23
  const DAY = 24 * 60 * 60 * 1000;
24
+ /**
25
+ * The latest version's number, as npm says, from an answer read for `timeoutMs` and up to `maxBytes` at most (it is
26
+ * that version's package.json, a few KB): whoever answers in npm's name (a proxy that inspects TLS, say) could
27
+ * otherwise keep the request waiting for good, or fill Tabby's memory.
28
+ */
29
+ async function latestVersion(url = REGISTRY, timeoutMs = 15000, maxBytes = 256 * 1024) {
30
+ const abort = new AbortController();
31
+ const timer = setTimeout(() => abort.abort(), timeoutMs);
32
+ try {
33
+ const response = await fetch(url, { cache: 'no-store', signal: abort.signal });
34
+ if (!response.body) {
35
+ throw new Error(`the registry answered ${response.status} with nothing`);
36
+ }
37
+ const reader = response.body.getReader();
38
+ const chunks = [];
39
+ let size = 0;
40
+ for (;;) {
41
+ const { done, value } = await reader.read();
42
+ if (done) {
43
+ break;
44
+ }
45
+ size += value.byteLength;
46
+ if (size > maxBytes) {
47
+ throw new Error('the registry\'s answer is too large');
48
+ }
49
+ chunks.push(value);
50
+ }
51
+ return String(JSON.parse(Buffer.concat(chunks).toString('utf8'))?.version ?? '');
52
+ }
53
+ finally {
54
+ clearTimeout(timer);
55
+ // Ends the request, if it is still going (an answer too large).
56
+ abort.abort();
57
+ }
58
+ }
23
59
  /** This package's version. */
24
60
  function installedVersion() {
25
61
  try {
@@ -74,7 +110,7 @@ let UpdateCheck = class UpdateCheck {
74
110
  if (!this.enabled && latest === undefined) {
75
111
  return null;
76
112
  }
77
- const version = latest ?? String((await (await fetch(REGISTRY, { cache: 'no-store' })).json())?.version ?? '');
113
+ const version = latest ?? await latestVersion();
78
114
  this.zone.run(() => {
79
115
  this.available = newer(version, installedVersion()) ? version : null;
80
116
  this.changed$.next();
@@ -129,4 +165,3 @@ exports.UpdateCheck = UpdateCheck = __decorate([
129
165
  (0, core_1.Injectable)({ providedIn: 'root' }),
130
166
  __metadata("design:paramtypes", [tabby_core_1.ConfigService, tabby_core_1.AppService, help_1.RemoteDesktopHelp, tabby_core_1.PlatformService, core_1.NgZone])
131
167
  ], UpdateCheck);
132
- //# sourceMappingURL=updates.js.map
package/dist/vms.js CHANGED
@@ -2,6 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.scanVMs = scanVMs;
4
4
  exports.vmSpec = vmSpec;
5
+ const net_1 = require("net");
6
+ const desktops_1 = require("./desktops");
7
+ const hyperv_1 = require("./hyperv");
5
8
  /**
6
9
  * RDP security the server chose when offered TLS and NLA (RDP_NEG_RSP): Windows and GNOME Remote Desktop take NLA
7
10
  * (2), xrdp only TLS (1). A Linux VM that takes NLA is GNOME's, whose sign-in the plugin manages over SSH to that VM:
@@ -14,6 +17,7 @@ const XRDP_LIKE = '1';
14
17
  // wake.ts) within a second; the protocol the server chose, or 0 without a negotiation response ('' if none).
15
18
  // Read-only: starts nothing, changes nothing.
16
19
  const SCAN_SCRIPT = String.raw `
20
+ echo TRD_SH
17
21
  command -v virsh >/dev/null 2>&1 || exit 0
18
22
  probe () {
19
23
  if command -v python3 >/dev/null 2>&1; then
@@ -55,10 +59,25 @@ for uri in qemu:///system qemu:///session; do
55
59
  done
56
60
  done
57
61
  `;
58
- /** The host's VMs that have (or, being off, may have) a desktop: running ones whose RDP answers, and Windows ones that are off. */
59
- async function scanVMs(target) {
60
- const out = await target.exec('sh -s', SCAN_SCRIPT);
62
+ /**
63
+ * The host's VMs that have (or, being off, may have) a desktop: libvirt's running ones whose RDP answers, and Windows
64
+ * ones that are off; on a Windows host, Hyper-V's. `host.windows` says the host is known to run Windows, and is set
65
+ * when the scan finds out: such a host isn't asked for libvirt again (its shell takes a while to say it has no sh).
66
+ * `host.cut` says whether the list stopped at MAX_VMS (each VM is a menu item) with more there, so that the menu can
67
+ * say only so many are listed.
68
+ */
69
+ async function scanVMs(target, host = {}) {
70
+ const out = host.windows ? '' : await target.exec('sh -s', SCAN_SCRIPT);
71
+ // No sh answered: a Windows host (cmd.exe or PowerShell as its SSH shell), which is where Hyper-V is. Asked only
72
+ // then, so that a Linux host costs nothing extra.
73
+ if (!/^TRD_SH$/m.test(out)) {
74
+ const vms = await (0, hyperv_1.scanHyperV)(target, host);
75
+ host.windows = true;
76
+ return vms.map(vm => ({ hyperv: vm.id, name: vm.name, state: vm.state, windows: true, address: '', rdp: false }));
77
+ }
61
78
  const found = [];
79
+ // Names seen so far: looked up, not searched for, so that the time taken grows with the lines, not their square.
80
+ const names = new Set();
62
81
  for (const line of out.split('\n')) {
63
82
  const m = /^TRD_VM ([^|]+)\|([^|]*)\|([^|]*)\|([^|]*)\|(\d*)\s*$/.exec(line.trim());
64
83
  if (!m) {
@@ -69,14 +88,27 @@ async function scanVMs(target) {
69
88
  // Windows: RDP answering, or off (started when opened). Others: only xrdp (see XRDP_LIKE).
70
89
  const rdp = windows ? protocol !== '' : protocol === XRDP_LIKE;
71
90
  const vm = { name, state: state === 'running' ? 'running' : 'off', windows, address, rdp };
72
- if (vm.address && (vm.rdp || vm.state === 'off' && vm.windows) && !found.some(f => f.name === vm.name)) {
91
+ // An IPv4 address, as the scan only ever finds: the address is part of the VM's session key, which is also the
92
+ // key its saved password is kept under. Anything else (`10.0.0.5:3389@gateway#gw.example`) could make that the
93
+ // key of a desktop configured behind this host, a password saved for its gateway included (see keyParts).
94
+ if ((0, net_1.isIPv4)(vm.address) && (vm.rdp || vm.state === 'off' && vm.windows) && !names.has(vm.name)) {
95
+ // One more than are listed: the list is cut short.
96
+ if (found.length === hyperv_1.MAX_VMS) {
97
+ host.cut = true;
98
+ return found;
99
+ }
100
+ names.add(vm.name);
73
101
  found.push(vm);
74
102
  }
75
103
  }
104
+ host.cut = false;
76
105
  return found;
77
106
  }
78
107
  /** A found VM as a desktop behind its host: started by name when it's off (see wake.ts). */
79
108
  function vmSpec(vm) {
109
+ if (vm.hyperv) {
110
+ return (0, desktops_1.hyperVSpec)(vm.hyperv, vm.name, { found: vm.state });
111
+ }
80
112
  return {
81
113
  id: `${vm.address}:3389`,
82
114
  name: vm.name,
@@ -87,4 +119,3 @@ function vmSpec(vm) {
87
119
  found: vm.state,
88
120
  };
89
121
  }
90
- //# sourceMappingURL=vms.js.map
package/dist/wake.js CHANGED
@@ -4,19 +4,25 @@ exports.parseWake = parseWake;
4
4
  exports.wakeFromText = wakeFromText;
5
5
  exports.shutDownWhenIdle = shutDownWhenIdle;
6
6
  exports.rdpAnswers = rdpAnswers;
7
+ exports.desktopUp = desktopUp;
7
8
  exports.wakeDesktop = wakeDesktop;
8
9
  exports.waitForRdp = waitForRdp;
9
10
  const deskScript_1 = require("./deskScript");
11
+ const hyperv_1 = require("./hyperv");
12
+ const osd_1 = require("./osd");
10
13
  const MAC = /^[0-9a-f]{2}([:-]?)[0-9a-f]{2}(\1[0-9a-f]{2}){4}$/i;
11
14
  const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
12
15
  /** A `wake` config value, checked; undefined when absent or unusable. */
13
16
  function parseWake(raw) {
17
+ if ((0, hyperv_1.vmId)(raw?.hyperv)) {
18
+ return { hyperv: (0, hyperv_1.vmId)(raw.hyperv) };
19
+ }
14
20
  if (typeof raw?.vm === 'string' && raw.vm.trim()) {
15
21
  return { vm: raw.vm.trim() };
16
22
  }
17
23
  const mac = typeof raw?.mac === 'string' ? raw.mac.trim() : '';
18
24
  if (MAC.test(mac)) {
19
- const port = Number(raw.port ?? 9);
25
+ const port = (0, osd_1.configNumber)(raw.port ?? 9);
20
26
  return {
21
27
  mac,
22
28
  ...typeof raw.broadcast === 'string' && IPV4.test(raw.broadcast.trim()) ? { broadcast: raw.broadcast.trim() } : {},
@@ -93,11 +99,13 @@ const IDLE_SCRIPT = String.raw `
93
99
  command -v virsh >/dev/null 2>&1 || { echo "RD_ERR virsh is not installed on the SSH host"; exit 0; }
94
100
  command -v ss >/dev/null 2>&1 || { echo "RD_ERR ss (iproute2) is not installed on the SSH host"; exit 0; }
95
101
  # The pid file: with the plugin's other files, or where the home folder isn't writable, in a folder of its own in /tmp.
102
+ # Private either way, as the desktop's setup keeps it (this may be the first to make it).
96
103
  D="$HOME/.local/share/tabby-rdp"
97
- if ! mkdir -p "$D" 2>/dev/null; then
104
+ if ! { mkdir -p "$D" && chmod 700 "$D"; } 2>/dev/null; then
98
105
  D="/tmp/tabby-rdp-$(id -u)"
99
106
  mkdir -p -m 700 "$D" 2>/dev/null
100
- [ -d "$D" ] && [ -O "$D" ] && [ ! -L "$D" ] || { echo "RD_ERR could not create a folder for the watcher's pid file"; exit 0; }
107
+ # One that was there already keeps its mode (mkdir -m only sets one it makes): made private here, or not used.
108
+ [ -d "$D" ] && [ -O "$D" ] && [ ! -L "$D" ] && chmod 700 "$D" 2>/dev/null || { echo "RD_ERR could not create a folder for the watcher's pid file"; exit 0; }
101
109
  fi
102
110
  PIDF="$D/idle-$(printf %s "$VM" | tr -c 'A-Za-z0-9._-' _).pid"
103
111
  if [ -s "$PIDF" ] && kill -0 "$(cat "$PIDF")" 2>/dev/null; then
@@ -109,8 +117,11 @@ for u in qemu:///system qemu:///session; do
109
117
  done
110
118
  [ -n "$URI" ] || { echo "RD_ERR $VM is not running"; exit 0; }
111
119
  A=$(getent ahostsv4 "$H" 2>/dev/null | awk 'NR == 1 { print $1 }')
120
+ # The watcher writes its pid file anew (umask 077): one left by an earlier version keeps its mode otherwise.
121
+ rm -f "$PIDF"
112
122
  nohup setsid sh -c '
113
123
  VM=$1 URI=$2 A=$3 P=$4 IDLE=$5 PIDF=$6
124
+ umask 077
114
125
  echo $$ > "$PIDF"
115
126
  trap "rm -f \"$PIDF\"" EXIT
116
127
  idle=0 asked=0
@@ -138,40 +149,49 @@ echo "RD_OK $VM shuts down after $((IDLE / 60)) min without a desktop open ($URI
138
149
  */
139
150
  async function shutDownWhenIdle(target, vm, host, port, idleSeconds) {
140
151
  const out = await target.exec('sh -s', `VM=${(0, deskScript_1.shq)(vm)}\nH=${(0, deskScript_1.shq)(host)}\nP=${port}\nIDLE=${Math.max(1, Math.round(idleSeconds / 30)) * 30}\n${IDLE_SCRIPT}`);
141
- const err = /^RD_ERR (.*)$/m.exec(out);
152
+ // The host's words, as much of them as the log shows: the host decides how long its lines are.
153
+ const err = /^RD_ERR (.{0,1000})/m.exec(out);
142
154
  if (err) {
143
155
  throw new Error(err[1].trim());
144
156
  }
145
- return /^RD_OK (.*)$/m.exec(out)?.[1] ?? 'no result';
157
+ return /^RD_OK (.{0,1000})/m.exec(out)?.[1] ?? 'no result';
146
158
  }
147
159
  /** Whether the RDP server at host:port answers, as seen from the SSH host. */
148
160
  async function rdpAnswers(target, host, port) {
149
161
  const out = await target.exec('sh -s', `H=${(0, deskScript_1.shq)(host)}\nP=${port}\n${PROBE_SCRIPT}`);
150
162
  return /^RD_OPEN$/m.test(out);
151
163
  }
164
+ /** Whether the desktop is up: its RDP server answers, or, for a Hyper-V VM (reached through its host), it runs. */
165
+ async function desktopUp(target, host, port, wake) {
166
+ return wake && 'hyperv' in wake ? (await (0, hyperv_1.hyperVState)(target, wake.hyperv)).running : rdpAnswers(target, host, port);
167
+ }
152
168
  /** Starts the desktop as `wake` says. Resolves with what was done; throws with the reason when it couldn't. */
153
169
  async function wakeDesktop(target, wake) {
170
+ if ('hyperv' in wake) {
171
+ return (0, hyperv_1.startHyperV)(target, wake.hyperv);
172
+ }
154
173
  const script = 'vm' in wake
155
174
  ? `VM=${(0, deskScript_1.shq)(wake.vm)}\n${VM_SCRIPT}`
156
175
  : `MAC=${(0, deskScript_1.shq)(wake.mac)}\nBCAST=${(0, deskScript_1.shq)(wake.broadcast ?? '255.255.255.255')}\nPORT=${wake.port ?? 9}\n${WOL_SCRIPT}`;
157
176
  const out = await target.exec('sh -s', script);
158
- const err = /^RD_ERR (.*)$/m.exec(out);
177
+ // The host's words, as much of them as a status line shows (see shutDownWhenIdle).
178
+ const err = /^RD_ERR (.{0,1000})/m.exec(out);
159
179
  if (err) {
160
180
  throw new Error(err[1].trim());
161
181
  }
162
- return /^RD_OK (.*)$/m.exec(out)?.[1] ?? 'no result';
182
+ return /^RD_OK (.{0,1000})/m.exec(out)?.[1] ?? 'no result';
163
183
  }
164
184
  /**
165
- * Probes until the RDP server answers (true), the time is up or `stopped()` (false). At most one probe every
166
- * 3 s; `progress` is called every 5 s.
185
+ * Probes until the desktop is up (true; see desktopUp), the time is up or `stopped()` (false). At most one probe
186
+ * every 3 s; `progress` is called every 5 s.
167
187
  */
168
- async function waitForRdp(target, host, port, timeoutMs, stopped, progress) {
188
+ async function waitForRdp(target, host, port, timeoutMs, stopped, progress, wake) {
169
189
  const deadline = Date.now() + timeoutMs;
170
190
  const ticker = setInterval(() => stopped() || progress(), 5000);
171
191
  try {
172
192
  while (Date.now() < deadline && !stopped()) {
173
193
  const started = Date.now();
174
- if (await rdpAnswers(target, host, port).catch(() => false)) {
194
+ if (await desktopUp(target, host, port, wake).catch(() => false)) {
175
195
  return !stopped();
176
196
  }
177
197
  await new Promise(resolve => setTimeout(resolve, Math.max(0, 3000 - (Date.now() - started))));
@@ -182,4 +202,3 @@ async function waitForRdp(target, host, port, timeoutMs, stopped, progress) {
182
202
  clearInterval(ticker);
183
203
  }
184
204
  }
185
- //# sourceMappingURL=wake.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabby-rdp",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Remote desktops in Tabby's SSH tabs: Linux (GNOME) and Windows, through the SSH connection, with clipboard, files and sound. Built on IronRDP.",
5
5
  "keywords": [
6
6
  "tabby-plugin",
@@ -26,6 +26,7 @@
26
26
  "main": "dist/index.js",
27
27
  "files": [
28
28
  "dist",
29
+ "!dist/**/*.map",
29
30
  "vendor",
30
31
  "remote",
31
32
  "LICENSE",
@@ -36,7 +37,7 @@
36
37
  },
37
38
  "scripts": {
38
39
  "build": "tsc -p tsconfig.json",
39
- "watch": "tsc -p tsconfig.json --watch",
40
+ "watch": "tsc -p tsconfig.json --watch --sourceMap",
40
41
  "typecheck": "tsc -p tsconfig.json --noEmit && npm run typecheck:test",
41
42
  "typecheck:test": "tsc -p tsconfig.test.json",
42
43
  "test": "tsx test/run.ts",
@@ -45,7 +46,7 @@
45
46
  "prepublishOnly": "npm run build"
46
47
  },
47
48
  "dependencies": {
48
- "ws": "^8.18.0"
49
+ "ws": "^8.21.1"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@angular/core": "15.2.10",