tabby-rdp 0.3.1 → 0.3.3

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,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3
4
+
5
+ **Fixed**
6
+ - **H.264 video from a hostile remote desktop:** the server picks which parts of a video frame the plugin reads back
7
+ from the browser's decoder, and nothing limited how much. One region larger than the picture, or many overlapping
8
+ copies of the whole screen, could make it allocate enough memory to crash Tabby along with every tab in it. What a
9
+ frame reads back is now bounded by the screen it draws on (overlapping regions are read once, as the box around
10
+ them), and a frame that would still need more than 256 MiB is decoded without reading anything back (IronRDP
11
+ patch 8). H.264 is on by default, so this applies wherever the browser decodes the video.
12
+
13
+ **Docs**
14
+ - The README's table of upstream IronRDP changes lists the keyboard API (#2026), says the Progressive decoding fix is
15
+ covered by IronRDP #2010 and #1977, and says what each patch not submitted yet waits for.
16
+
17
+ ## 0.3.2
18
+
19
+ **New**
20
+ - **Shut down VMs it started:** a VM the plugin started to open its desktop can go back off after 5 minutes, 15 or an
21
+ hour with no desktop open (a setting; never, by default). A watcher on the SSH host sees to it, so it also works after
22
+ the tab is closed or Tabby has quit.
23
+
24
+ - **A GNOME desktop that's open elsewhere** (another computer, or another Tabby window): opening it asks whether to
25
+ take it over, as Windows does (the other connection is disconnected; the session and its apps carry on), or to open
26
+ a second screen, which is what happened before. A desktop taken over doesn't take itself back.
27
+
28
+ **Better**
29
+ - **The headless GNOME session** runs X11 apps (through XWayland), and GNOME's settings daemons for keyboard, media
30
+ keys and custom shortcuts, accessibility and sound; not power, so it never suspends the machine. A session that's
31
+ already running gets them on the next connect, without closing its apps.
32
+
33
+ **Fixed**
34
+ - **A blank desktop after reconnecting** (GNOME): when the server sent the end of the connection sequence and the start
35
+ of the graphics channel together, IronRDP lost the latter and the desktop stayed transparent. The proxy now hands it
36
+ each server message on its own.
37
+ - **The clipboard with several desktops open:** closing one desktop stopped the automatic clipboard for the others,
38
+ and a desktop in the background could overwrite what was copied on another. The desktop with the focus syncs the
39
+ clipboard; copies made on another reach the Mac when it gets the focus (IronRDP patches 12 and 13).
40
+ - IronRDP patch 14, meant for upstream: H.264 pictures read correctly in Safari, whose `copyTo` ignores the RGBA format.
41
+ - A closed desktop no longer leaves its pane's subscription and its label timer behind.
42
+ - The first-connect tip shows the desktop/console hotkey as text: a label from the config could carry markup into
43
+ the page.
44
+
45
+ **Docs**
46
+ - The limitation about Win+R from macOS is gone: ⌃⌘ with a key has been the Windows key with it since 0.3.0.
47
+
3
48
  ## 0.3.1
4
49
 
5
50
  **New**
package/README.md CHANGED
@@ -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,7 @@ 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)). |
284
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. |
285
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. |
286
294
 
@@ -320,9 +328,12 @@ tabby-rdp ships IronRDP with a short series of patches ([ironrdp/](ironrdp)). Th
320
328
  | Echo the correct size in the sound channel's Training Confirm | [Devolutions/IronRDP#2019](https://github.com/Devolutions/IronRDP/pull/2019) |
321
329
  | The graphics pipeline in the web client, following its resets | Covered by [Devolutions/IronRDP#1977](https://github.com/Devolutions/IronRDP/pull/1977) (not ours; [tested with GNOME](https://github.com/Devolutions/IronRDP/pull/1977#issuecomment-5851624036)) |
322
330
  | Sound in the web client | [Devolutions/IronRDP#2020](https://github.com/Devolutions/IronRDP/pull/2020) |
323
- | H.264 in the web client, decoded by the browser (WebCodecs) | Not submitted yet |
324
- | Decode Windows' RemoteFX Progressive refinements | Not submitted yet |
325
- | Microphone in the web client | Not submitted yet |
331
+ | Let the host send key events to a session | [Devolutions/IronRDP#2026](https://github.com/Devolutions/IronRDP/pull/2026) (design under discussion) |
332
+ | Decode Windows' RemoteFX Progressive refinements | Covered by [Devolutions/IronRDP#2010](https://github.com/Devolutions/IronRDP/pull/2010) and [#1977](https://github.com/Devolutions/IronRDP/pull/1977) (not ours) |
333
+ | H.264 in the web client, decoded by the browser (WebCodecs) | Not submitted yet: waits for #1977 |
334
+ | Microphone in the web client | Not submitted yet: waits for #2020 |
335
+ | Keep clipboard sync working for the other desktops when one closes | Not submitted yet |
336
+ | Sync the clipboard only for the desktop that has the focus | Not submitted yet |
326
337
 
327
338
  The aim is to make IronRDP's browser client work well with both Linux and Windows desktops, for everyone who embeds
328
339
  it, not just this plugin.
@@ -345,17 +356,19 @@ npm run build:ironrdp # rebuild vendor/ from IronRDP and ironrdp/patches:
345
356
 
346
357
  ## Limitations
347
358
 
348
- - **One client per account on GNOME.** A second client (another machine, or another Tabby window) gets an extra,
349
- empty monitor instead of the same screen, which is how GNOME Remote Desktop's headless mode works.
350
- - **The headless GNOME session is a shell without `gnome-session`:** apps work (Settings, Files, Terminal, Firefox), but
351
- GNOME's background settings daemons don't run, and X11-only apps don't start.
359
+ - **One screen per connection on GNOME.** GNOME Remote Desktop's headless mode can't show the same screen to two
360
+ clients (another computer, or another Tabby window): a second one gets an extra, empty screen. Opening a desktop
361
+ that's open elsewhere asks whether to take it over (the other connection is disconnected, as on Windows; the session
362
+ and its apps carry on) or to open a second screen.
363
+ - **The headless GNOME session is a shell without `gnome-session`.** Apps work, X11 ones too (through XWayland), and so
364
+ do GNOME's settings daemons for keyboard, media keys and custom shortcuts, accessibility and sound. The ones for
365
+ power, sharing and local hardware don't run, so the session never suspends or blanks the machine.
352
366
  - **GNOME's login-screen mode** isn't supported: it hands clients over with a server redirection, which IronRDP can't
353
367
  follow yet. The plugin uses per-user headless sessions instead.
354
368
  - **Remembering a Windows password on Linux** needs an unlocked keyring (GNOME Keyring or KWallet), as it does for
355
369
  Tabby's own passwords. Without one, the plugin asks each time.
356
370
  - **GNOME Remote Desktop listens on all interfaces** (password-protected, TLS); it has no setting to listen on
357
371
  loopback only. See [docs/architecture.md](docs/architecture.md#security-notes) to restrict it.
358
- - **Windows-key combinations** such as Win+R don't come through from macOS. Tapping ⌘ for the Windows key does.
359
372
  - **The microphone** needs the system's permission: macOS asks the first time a remote app records (if it was denied,
360
373
  allow Tabby in System Settings › Privacy & Security › Microphone, then restart Tabby), and Windows needs "Let desktop
361
374
  apps access your microphone". On GNOME, apps record from GNOME Remote Desktop's "Remoteaudio Source", which is the
@@ -371,7 +384,8 @@ npm run build:ironrdp # rebuild vendor/ from IronRDP and ironrdp/patches:
371
384
  far as that xrdp version supports them), sound through the PipeWire or PulseAudio module. Live resize needs xrdp
372
385
  0.10 or newer; with 0.9 (Ubuntu 24.04), the desktop keeps its size, scaled to fit, or choose **Reconnect at the new
373
386
  size**. xrdp's standard RDP security without TLS (`security_layer=rdp`) isn't supported. `desk` needs GNOME.
374
- - **Waking a desktop** starts it but never shuts it down again; that is up to you (or the VM's own settings).
387
+ - **Waking a desktop** over the network (Wake-on-LAN) starts it but never shuts it down again; for VMs, see
388
+ [Shut down VMs it started](#vms-on-a-host).
375
389
 
376
390
  ## License
377
391
 
@@ -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;
@@ -955,6 +963,7 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
955
963
  osd: (0, osd_1.osdSettings)(store.osd),
956
964
  discoverVMs: store.discoverVMs !== false,
957
965
  checkUpdates: store.checkUpdates !== false,
966
+ shutDownIdle: [5, 15, 60].includes(Number(store.shutDownIdle)) ? Number(store.shutDownIdle) : 0,
958
967
  };
959
968
  }
960
969
  /** The sharpness chosen for the pane's desktop in particular (`remoteDesktop.desktopSharpness`), or null. */
@@ -1371,11 +1380,11 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1371
1380
  * it finds xrdp or Windows instead of GNOME, as below. Others: the saved account, or the sign-in form (retryError: after a
1372
1381
  * failed sign-in).
1373
1382
  */
1374
- async endpointFor(target, spec, session, retryError) {
1383
+ async endpointFor(pane, target, spec, session, automatic, retryError) {
1375
1384
  // A Windows host is known from an earlier connect: no setup to run there (it would only fail again).
1376
1385
  if (spec.id === desktops_1.OWN_DESKTOP && !retryError && this.ownDesktops.get(target.key)?.kind !== 'windows') {
1377
1386
  session.status(`Preparing the remote desktop on ${target.label}…`);
1378
- 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));
1379
1388
  this.ownDesktops.set(target.key, { kind: endpoint.kind, xrdpPort: endpoint.xrdpPort, xrdpUser: endpoint.xrdpUser });
1380
1389
  // Only the setup knows which desktop the host has; the session follows it (graphics, sign-in, resizing).
1381
1390
  spec.kind = endpoint.kind;
@@ -1383,7 +1392,29 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1383
1392
  session.log.push('setup: the SSH host runs Windows; its own desktop is its RDP server');
1384
1393
  }
1385
1394
  if (endpoint.kind === 'gnome') {
1386
- 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 };
1387
1418
  }
1388
1419
  spec.host = '127.0.0.1';
1389
1420
  spec.port = endpoint.port;
@@ -1426,10 +1457,17 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1426
1457
  }
1427
1458
  // Signing in to a Windows account can take a few tries; a GNOME desktop the plugin set up can't fail that way.
1428
1459
  for (let attempt = 0;; attempt++) {
1429
- 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()]);
1430
1461
  if (!alive()) {
1431
1462
  return;
1432
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
+ }
1433
1471
  if (!asked) {
1434
1472
  session.state = 'ended';
1435
1473
  this.changed$.next();
@@ -1518,6 +1556,49 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1518
1556
  }
1519
1557
  session.log.push(`certificate: SHA-256 ${fingerprint}, as remembered`);
1520
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
+ }
1521
1602
  /**
1522
1603
  * After the proxy refused a certificate; nothing was sent to that server. The host's own desktop: an error, since
1523
1604
  * the plugin made that certificate itself. Others: both fingerprints, to trust the new certificate or not. Either
@@ -1619,6 +1700,13 @@ let RemoteDesktopService = RemoteDesktopService_1 = class RemoteDesktopService {
1619
1700
  const up = await this.zone.runOutsideAngular(() => (0, wake_1.waitForRdp)(target, spec.host, spec.port, WAKE_TIMEOUT_MS, () => cancelled || !alive(), progress));
1620
1701
  if (up) {
1621
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
+ }
1622
1710
  return true;
1623
1711
  }
1624
1712
  if (cancelled || !alive()) {