tabby-rdp 0.3.0 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2
4
+
5
+ **New**
6
+ - **Shut down VMs it started:** a VM the plugin started to open its desktop can go back off after 5 minutes, 15 or an
7
+ hour with no desktop open (a setting; never, by default). A watcher on the SSH host sees to it, so it also works after
8
+ the tab is closed or Tabby has quit.
9
+
10
+ - **A GNOME desktop that's open elsewhere** (another computer, or another Tabby window): opening it asks whether to
11
+ take it over, as Windows does (the other connection is disconnected; the session and its apps carry on), or to open
12
+ a second screen, which is what happened before. A desktop taken over doesn't take itself back.
13
+
14
+ **Better**
15
+ - **The headless GNOME session** runs X11 apps (through XWayland), and GNOME's settings daemons for keyboard, media
16
+ keys and custom shortcuts, accessibility and sound; not power, so it never suspends the machine. A session that's
17
+ already running gets them on the next connect, without closing its apps.
18
+
19
+ **Fixed**
20
+ - **A blank desktop after reconnecting** (GNOME): when the server sent the end of the connection sequence and the start
21
+ of the graphics channel together, IronRDP lost the latter and the desktop stayed transparent. The proxy now hands it
22
+ each server message on its own.
23
+ - **The clipboard with several desktops open:** closing one desktop stopped the automatic clipboard for the others,
24
+ and a desktop in the background could overwrite what was copied on another. The desktop with the focus syncs the
25
+ clipboard; copies made on another reach the Mac when it gets the focus (IronRDP patches 12 and 13).
26
+ - IronRDP patch 14, meant for upstream: H.264 pictures read correctly in Safari, whose `copyTo` ignores the RGBA format.
27
+ - A closed desktop no longer leaves its pane's subscription and its label timer behind.
28
+ - The first-connect tip shows the desktop/console hotkey as text: a label from the config could carry markup into
29
+ the page.
30
+
31
+ **Docs**
32
+ - The limitation about Win+R from macOS is gone: ⌃⌘ with a key has been the Windows key with it since 0.3.0.
33
+
34
+ ## 0.3.1
35
+
36
+ **New**
37
+ - **New versions:** Tabby shows plugin upgrades only while its Plugins page is open, so tabby-rdp now says so itself.
38
+ Once a day it asks npm for the latest version (nothing else is sent); a newer one gets a note once, an **Update
39
+ available** item in the menus and a line on Settings › Remote Desktop, each leading to Tabby's Upgrade button.
40
+ **Tell me about new versions** turns it off.
41
+
42
+ **Docs**
43
+ - The README's demo plays in the page (a GIF), on GitHub and on npm.
44
+
3
45
  ## 0.3.0
4
46
 
5
47
  **New**
package/README.md CHANGED
@@ -11,9 +11,9 @@ Linux and Windows, side by side, through the SSH connections you already have.
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-3b82f6)](LICENSE)
12
12
  [![Tabby plugin](https://img.shields.io/badge/Tabby-plugin-6366f1)](https://tabby.sh)
13
13
 
14
- <a href="https://github.com/meanaverage/tabby-rdp/releases/download/v0.3.0/tabby-rdp-demo.mp4"><img src="docs/images/demo.jpg" width="860" alt="Four desktops in one Tabby tab (GNOME, Windows, xrdp), a file pasted to all of them, and desk (demo video)"></a>
14
+ <img src="docs/images/demo.gif" width="860" alt="Four desktops in one Tabby tab (GNOME, Windows, xrdp), a file pasted to all of them, a pane switched to its console, and desk">
15
15
 
16
- <sub>▶ <a href="https://github.com/meanaverage/tabby-rdp/releases/download/v0.3.0/tabby-rdp-demo.mp4">Watch the 30-second demo</a></sub>
16
+ <sub>Sharper: <a href="https://github.com/meanaverage/tabby-rdp/releases/download/v0.3.0/tabby-rdp-demo.mp4">the same demo as an MP4</a> (downloads)</sub>
17
17
 
18
18
  </div>
19
19
 
@@ -247,6 +247,13 @@ console on the host, [above](#use)). A VM found this way asks for its account th
247
247
  desktop; **Save \<name\> to this host's desktops** in its menu keeps it, for a name of your own and other settings.
248
248
  **Settings › Remote Desktop › Find virtual machines on SSH hosts** turns this off.
249
249
 
250
+ A VM the plugin started can go back off by itself: **Settings › Remote Desktop › Shut down VMs it started** (5
251
+ minutes, 15 or an hour without a desktop open; never, by default). A small watcher on the SSH host sees to it, so it
252
+ also works once the tab is closed or Tabby has quit: after that long with no connection to the VM's desktop through the
253
+ host, it asks the VM to shut down (`virsh shutdown`, with a key press first, since Windows ignores the request while its
254
+ screen sleeps), then ends. VMs that were already running are never touched, and neither are machines woken over the
255
+ network.
256
+
250
257
  ## desk: the console on the desktop
251
258
 
252
259
  Type `desk` in an SSH console, and the tab switches to that machine's desktop with a terminal attached to the very same
@@ -281,6 +288,8 @@ They are stored in Tabby's config under `remoteDesktop`:
281
288
  | Bring the console along with `desk` (`desk`) | Off | Installs `desk` and a login line on each machine you open a desktop on; applies on the next connection there. |
282
289
  | Session backend (`sessionBackend`) | `native` | For `desk`: `native` (trd-pty) or `tmux`. Config file only. |
283
290
  | Find virtual machines on SSH hosts (`discoverVMs`) | On | Lists a host's libvirt VMs with a desktop in its menu ([VMs on a host](#vms-on-a-host)). |
291
+ | Shut down VMs it started (`shutDownIdle`) | Never | 5, 15 or 60: minutes without a desktop open after which a VM the plugin started is shut down again ([VMs on a host](#vms-on-a-host)). |
292
+ | Tell me about new versions (`checkUpdates`) | On | Once a day, asks npm for the latest tabby-rdp (nothing else is sent), and says so in a note, the menus and the settings page when there's a newer one: Tabby itself shows plugin upgrades only on its Plugins page. |
284
293
  | Desktop name overlay (`osd`) | When it helps | `show` (`auto`, `always`, `off`), `font`, `size`, `position`, `color` (empty: white) and `seconds`; the settings page previews it. |
285
294
 
286
295
  ## Requirements
@@ -344,17 +353,19 @@ npm run build:ironrdp # rebuild vendor/ from IronRDP and ironrdp/patches:
344
353
 
345
354
  ## Limitations
346
355
 
347
- - **One client per account on GNOME.** A second client (another machine, or another Tabby window) gets an extra,
348
- empty monitor instead of the same screen, which is how GNOME Remote Desktop's headless mode works.
349
- - **The headless GNOME session is a shell without `gnome-session`:** apps work (Settings, Files, Terminal, Firefox), but
350
- GNOME's background settings daemons don't run, and X11-only apps don't start.
356
+ - **One screen per connection on GNOME.** GNOME Remote Desktop's headless mode can't show the same screen to two
357
+ clients (another computer, or another Tabby window): a second one gets an extra, empty screen. Opening a desktop
358
+ that's open elsewhere asks whether to take it over (the other connection is disconnected, as on Windows; the session
359
+ and its apps carry on) or to open a second screen.
360
+ - **The headless GNOME session is a shell without `gnome-session`.** Apps work, X11 ones too (through XWayland), and so
361
+ do GNOME's settings daemons for keyboard, media keys and custom shortcuts, accessibility and sound. The ones for
362
+ power, sharing and local hardware don't run, so the session never suspends or blanks the machine.
351
363
  - **GNOME's login-screen mode** isn't supported: it hands clients over with a server redirection, which IronRDP can't
352
364
  follow yet. The plugin uses per-user headless sessions instead.
353
365
  - **Remembering a Windows password on Linux** needs an unlocked keyring (GNOME Keyring or KWallet), as it does for
354
366
  Tabby's own passwords. Without one, the plugin asks each time.
355
367
  - **GNOME Remote Desktop listens on all interfaces** (password-protected, TLS); it has no setting to listen on
356
368
  loopback only. See [docs/architecture.md](docs/architecture.md#security-notes) to restrict it.
357
- - **Windows-key combinations** such as Win+R don't come through from macOS. Tapping ⌘ for the Windows key does.
358
369
  - **The microphone** needs the system's permission: macOS asks the first time a remote app records (if it was denied,
359
370
  allow Tabby in System Settings › Privacy & Security › Microphone, then restart Tabby), and Windows needs "Let desktop
360
371
  apps access your microphone". On GNOME, apps record from GNOME Remote Desktop's "Remoteaudio Source", which is the
@@ -370,7 +381,8 @@ npm run build:ironrdp # rebuild vendor/ from IronRDP and ironrdp/patches:
370
381
  far as that xrdp version supports them), sound through the PipeWire or PulseAudio module. Live resize needs xrdp
371
382
  0.10 or newer; with 0.9 (Ubuntu 24.04), the desktop keeps its size, scaled to fit, or choose **Reconnect at the new
372
383
  size**. xrdp's standard RDP security without TLS (`security_layer=rdp`) isn't supported. `desk` needs GNOME.
373
- - **Waking a desktop** starts it but never shuts it down again; that is up to you (or the VM's own settings).
384
+ - **Waking a desktop** over the network (Wake-on-LAN) starts it but never shuts it down again; for VMs, see
385
+ [Shut down VMs it started](#vms-on-a-host).
374
386
 
375
387
  ## License
376
388
 
@@ -277,6 +277,7 @@ class DesktopSession {
277
277
  dispose() {
278
278
  this.markDisposed();
279
279
  this.setVisible(false);
280
+ clearTimeout(this.labelTimer);
280
281
  this.container.removeEventListener('focusin', this.reclaimFocus, true);
281
282
  this.disposers.forEach(f => f());
282
283
  this.indicator?.dispose();
@@ -328,6 +329,10 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
328
329
  this.reconnects = new Map();
329
330
  /** Split tabs where keys typed on one desktop go to all of them (see DesktopKeyboard). */
330
331
  this.broadcast = new WeakSet();
332
+ /** How the pane's next connection to a GNOME desktop that's open elsewhere goes, when that was chosen already. */
333
+ this.openChoices = new Map();
334
+ /** Each pane's name for itself on hosts, for the note a take-over leaves there (see prepareRemoteDesktop). */
335
+ this.clients = new WeakMap();
331
336
  }
332
337
  /**
333
338
  * The remote a desktop opened in the pane now is on: the pane's own, or in an SSH tab where `ssh` was typed at
@@ -719,7 +724,9 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
719
724
  }
720
725
  session = created;
721
726
  this.sessions.set(pane, created);
722
- pane.destroyed$.subscribe(() => this.disconnect(pane));
727
+ // Released with the session: a pane outlives the desktops opened and closed in it.
728
+ const destroyed = pane.destroyed$.subscribe(() => this.disconnect(pane));
729
+ created.onDispose(() => destroyed.unsubscribe());
723
730
  // Tabby focuses the terminal when the pane gets focus (tab switch, split focus); take it back.
724
731
  const focused = pane.focused$.subscribe(() => {
725
732
  if (created.visible) {
@@ -908,7 +915,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
908
915
  }
909
916
  }
910
917
  scheduleReconnect(pane, target, spec, session, message) {
911
- const state = this.reconnects.get(pane) ?? { attempts: 0 };
918
+ // since: when the desktop dropped (see openElsewhere).
919
+ const state = this.reconnects.get(pane) ?? { attempts: 0, since: Date.now() };
912
920
  this.reconnects.set(pane, state);
913
921
  clearTimeout(state.timer);
914
922
  const current = () => this.sessions.get(pane) === session;
@@ -954,6 +962,8 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
954
962
  microphone: store.microphone === true,
955
963
  osd: (0, osd_1.osdSettings)(store.osd),
956
964
  discoverVMs: store.discoverVMs !== false,
965
+ checkUpdates: store.checkUpdates !== false,
966
+ shutDownIdle: [5, 15, 60].includes(Number(store.shutDownIdle)) ? Number(store.shutDownIdle) : 0,
957
967
  };
958
968
  }
959
969
  /** The sharpness chosen for the pane's desktop in particular (`remoteDesktop.desktopSharpness`), or null. */
@@ -1370,11 +1380,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1370
1380
  * it finds xrdp or Windows instead of GNOME, as below. Others: the saved account, or the sign-in form (retryError: after a
1371
1381
  * failed sign-in).
1372
1382
  */
1373
- async endpointFor(target, spec, session, retryError) {
1383
+ async endpointFor(pane, target, spec, session, automatic, retryError) {
1374
1384
  // A Windows host is known from an earlier connect: no setup to run there (it would only fail again).
1375
1385
  if (spec.id === desktops_1.OWN_DESKTOP && !retryError && this.ownDesktops.get(target.key)?.kind !== 'windows') {
1376
1386
  session.status(`Preparing the remote desktop on ${target.label}…`);
1377
- const endpoint = await (0, remoteSetup_1.prepareRemoteDesktop)(target, this.settings().desk, this.config.store.remoteDesktop?.sessionBackend);
1387
+ const endpoint = await (0, remoteSetup_1.prepareRemoteDesktop)(target, this.settings().desk, this.config.store.remoteDesktop?.sessionBackend, this.clientOf(pane));
1378
1388
  this.ownDesktops.set(target.key, { kind: endpoint.kind, xrdpPort: endpoint.xrdpPort, xrdpUser: endpoint.xrdpUser });
1379
1389
  // Only the setup knows which desktop the host has; the session follows it (graphics, sign-in, resizing).
1380
1390
  spec.kind = endpoint.kind;
@@ -1382,7 +1392,29 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1382
1392
  session.log.push('setup: the SSH host runs Windows; its own desktop is its RDP server');
1383
1393
  }
1384
1394
  if (endpoint.kind === 'gnome') {
1385
- return { host: '127.0.0.1', port: endpoint.port, credentials: { username: endpoint.username, password: endpoint.password }, remember: false, certificate: endpoint.certificate };
1395
+ let gnome = endpoint;
1396
+ const chosen = this.openChoices.get(pane);
1397
+ this.openChoices.delete(pane);
1398
+ // An automatic reconnect also gives way to a take-over whose connection isn't up yet: one that happened
1399
+ // when this desktop dropped (the host says how long ago, so the clocks needn't agree).
1400
+ const dropped = this.reconnects.get(pane)?.since;
1401
+ const displaced = automatic && gnome.takenOver !== undefined && dropped !== undefined &&
1402
+ Math.abs(gnome.takenOver - (Date.now() - dropped) / 1000) < 10;
1403
+ if (gnome.clients || displaced) {
1404
+ const choice = chosen ?? await this.openElsewhere(pane, target, spec, session, automatic);
1405
+ if (!choice) {
1406
+ return 'stopped';
1407
+ }
1408
+ if (choice === 'take') {
1409
+ session.status(`Taking over the desktop on ${target.label}…`);
1410
+ gnome = await (0, remoteSetup_1.prepareRemoteDesktop)(target, this.settings().desk, this.config.store.remoteDesktop?.sessionBackend, this.clientOf(pane), true);
1411
+ session.log.push(`setup: took the desktop over (${endpoint.clients} other connection(s))`);
1412
+ }
1413
+ else {
1414
+ session.log.push(`setup: a screen of its own, besides ${endpoint.clients} other connection(s)`);
1415
+ }
1416
+ }
1417
+ return { host: '127.0.0.1', port: gnome.port, credentials: { username: gnome.username, password: gnome.password }, remember: false, certificate: gnome.certificate };
1386
1418
  }
1387
1419
  spec.host = '127.0.0.1';
1388
1420
  spec.port = endpoint.port;
@@ -1425,10 +1457,17 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1425
1457
  }
1426
1458
  // Signing in to a Windows account can take a few tries; a GNOME desktop the plugin set up can't fail that way.
1427
1459
  for (let attempt = 0;; attempt++) {
1428
- const [asked, rdp] = await Promise.all([endpoint ?? this.endpointFor(target, spec, session, retryError), this.loadIronRDP()]);
1460
+ const [asked, rdp] = await Promise.all([endpoint ?? this.endpointFor(pane, target, spec, session, automatic, retryError), this.loadIronRDP()]);
1429
1461
  if (!alive()) {
1430
1462
  return;
1431
1463
  }
1464
+ if (asked === 'stopped') {
1465
+ // Not connecting: the reason and what to do instead are on the layer already.
1466
+ session.state = 'ended';
1467
+ this.changed$.next();
1468
+ this.cancelReconnect(pane);
1469
+ return;
1470
+ }
1432
1471
  if (!asked) {
1433
1472
  session.state = 'ended';
1434
1473
  this.changed$.next();
@@ -1517,6 +1556,49 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1517
1556
  }
1518
1557
  session.log.push(`certificate: SHA-256 ${fingerprint}, as remembered`);
1519
1558
  }
1559
+ clientOf(pane) {
1560
+ let id = this.clients.get(pane);
1561
+ if (!id) {
1562
+ id = Math.random().toString(36).slice(2, 12);
1563
+ this.clients.set(pane, id);
1564
+ }
1565
+ return id;
1566
+ }
1567
+ /**
1568
+ * The host's GNOME desktop has RDP clients already (another Tabby window or computer), and GNOME gives each its own
1569
+ * screen. Opened by hand: asks whether to take it over (disconnecting them, as Windows does), or to add a screen.
1570
+ * An automatic reconnect doesn't ask, nor take it back: this connection gave way to the other, most likely by being
1571
+ * taken over from there, and taking it back by itself would have the two take turns. Resolves with the choice, or
1572
+ * null when not connecting (the layer then says so and offers the choices again).
1573
+ */
1574
+ async openElsewhere(pane, target, spec, session, automatic) {
1575
+ const again = (choice) => () => {
1576
+ this.openChoices.set(pane, choice);
1577
+ this.reopen(pane, spec);
1578
+ };
1579
+ const stopped = (message) => {
1580
+ session.status(message, [
1581
+ { label: automatic ? 'Take it back' : 'Take it over', run: again('take') },
1582
+ { label: 'Open a second screen', run: again('second') },
1583
+ ], true);
1584
+ return null;
1585
+ };
1586
+ if (automatic) {
1587
+ if (!session.visible) {
1588
+ this.notifications.notice(`The desktop on ${target.label} was opened somewhere else`);
1589
+ }
1590
+ return stopped(`The desktop on ${target.label} was opened somewhere else (another Tabby window or computer), and this connection gave way to it.`);
1591
+ }
1592
+ const choice = await new Promise(resolve => {
1593
+ session.status(`The desktop on ${target.label} is open somewhere else too (another Tabby window or computer). GNOME gives each connection a screen of its own, so this one would get a second, empty screen.`, [
1594
+ { label: 'Take it over', run: () => resolve('take') },
1595
+ { label: 'Open a second screen', run: () => resolve('second') },
1596
+ { label: 'Cancel', run: () => resolve(null) },
1597
+ ], true);
1598
+ session.disposed.then(() => resolve(null));
1599
+ });
1600
+ return choice ?? stopped(`Not opened: the desktop on ${target.label} is open somewhere else (another Tabby window or computer).`);
1601
+ }
1520
1602
  /**
1521
1603
  * After the proxy refused a certificate; nothing was sent to that server. The host's own desktop: an error, since
1522
1604
  * the plugin made that certificate itself. Others: both fingerprints, to trust the new certificate or not. Either
@@ -1618,6 +1700,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1618
1700
  const up = await this.zone.runOutsideAngular(() => (0, wake_1.waitForRdp)(target, spec.host, spec.port, WAKE_TIMEOUT_MS, () => cancelled || !alive(), progress));
1619
1701
  if (up) {
1620
1702
  session.log.push(`wake: answered after ${Math.round((Date.now() - started) / 1000)} s`);
1703
+ // Started here, so it can go back off when it's no longer used, if the settings say so (a VM only: a machine
1704
+ // woken over the network would need rights on it).
1705
+ const idle = this.settings().shutDownIdle;
1706
+ if (idle > 0 && 'vm' in spec.wake) {
1707
+ (0, wake_1.shutDownWhenIdle)(target, spec.wake.vm, spec.host, spec.port, idle * 60)
1708
+ .then(done => session.log.push(`wake: ${done}`), e => session.log.push(`wake: no automatic shutdown: ${e?.message ?? e}`));
1709
+ }
1621
1710
  return true;
1622
1711
  }
1623
1712
  if (cancelled || !alive()) {