tabby-rdp 0.5.1 → 0.5.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/dist/signin.js CHANGED
@@ -8,10 +8,12 @@ exports.renameCredentials = renameCredentials;
8
8
  exports.loadCredentials = loadCredentials;
9
9
  exports.hasCredentials = hasCredentials;
10
10
  exports.saveCredentials = saveCredentials;
11
+ exports.saveCredentialsIf = saveCredentialsIf;
11
12
  exports.forgetMark = forgetMark;
12
13
  exports.forgottenSince = forgottenSince;
13
14
  exports.forgetCredentials = forgetCredentials;
14
15
  exports.forgetCredentialsOrFail = forgetCredentialsOrFail;
16
+ exports.forgetCredentialsIf = forgetCredentialsIf;
15
17
  exports.gatewayScope = gatewayScope;
16
18
  exports.isFor = isFor;
17
19
  exports.forgetCredentialsFor = forgetCredentialsFor;
@@ -50,8 +52,14 @@ function storeName() {
50
52
  * back, so two at once (two desktops signing in together) would lose one's change.
51
53
  */
52
54
  let vaultQueue = Promise.resolve();
55
+ /** Same-origin windows share these locks. Store work holds the lock through its actual completion. */
56
+ async function storeLock(name, work, signal) {
57
+ const locks = globalThis.navigator?.locks;
58
+ return locks ? await locks.request(`tabby-rdp.credentials.${name}`, signal ? { signal } : {}, work) : work();
59
+ }
53
60
  function queued(work) {
54
- const next = vaultQueue.then(work, work);
61
+ const run = () => storeLock('vault', work);
62
+ const next = vaultQueue.then(run, run);
55
63
  vaultQueue = next.catch(() => null);
56
64
  return next;
57
65
  }
@@ -90,16 +98,21 @@ function keytar() {
90
98
  */
91
99
  function keychain(call) {
92
100
  const run = () => new Promise((resolve, reject) => {
93
- const store = keytar();
94
- if (!store) {
95
- resolve(undefined);
101
+ if (keychainStuck) {
102
+ reject(new Error('the keychain did not answer earlier'));
96
103
  return;
97
104
  }
105
+ const abort = new AbortController();
98
106
  const timer = setTimeout(() => {
99
107
  keychainStuck = true;
108
+ abort.abort(); // cancels a lock still waiting, never a native call already running
100
109
  reject(new Error('the keychain did not answer'));
101
110
  }, KEYCHAIN_TIMEOUT_MS);
102
- Promise.resolve(call(store)).then(value => { clearTimeout(timer); resolve(value); }, error => { clearTimeout(timer); reject(error); });
111
+ // Timeout is outside the lock: a late native write keeps other windows' writes behind it until it ends.
112
+ storeLock('keychain', async () => {
113
+ const store = keytar();
114
+ return store ? call(store) : undefined;
115
+ }, abort.signal).then(value => { clearTimeout(timer); resolve(value); }, error => { clearTimeout(timer); reject(error); });
103
116
  });
104
117
  const next = keychainQueue.then(run, run);
105
118
  keychainQueue = next.catch(() => null);
@@ -134,6 +147,8 @@ async function used() {
134
147
  onceRunning = doOnce(job).catch(() => undefined).finally(() => { onceRunning = null; });
135
148
  }
136
149
  await onceRunning;
150
+ // A previous background pass may have failed before the store became available. This use gets one fresh attempt.
151
+ await laterRunning;
137
152
  if (later.length && !laterRunning) {
138
153
  laterRunning = doLater().catch(() => undefined).finally(() => { laterRunning = null; });
139
154
  }
@@ -354,19 +369,54 @@ async function hasCredentials(key) {
354
369
  }
355
370
  }
356
371
  async function saveCredentials(key, credentials, label) {
372
+ await saveCredentialsIf(key, credentials, () => true, label);
373
+ }
374
+ /**
375
+ * Checks a revision within the store queue/lock, including after an asynchronous write. If invalidated while writing,
376
+ * removes that write before a newer queued replacement can run. Never schedules a deletion behind a newer write.
377
+ */
378
+ async function saveCredentialsIf(key, credentials, current, label) {
357
379
  await used();
358
380
  if (vaultOn()) {
359
- await queued(() => vault.addSecret({ type: exports.VAULT_SECRET_TYPE, key: { ...vaultKey(key), description: label ?? vaultName(key) }, value: JSON.stringify(credentials) }));
381
+ const saved = await queued(async () => {
382
+ if (!current()) {
383
+ return false;
384
+ }
385
+ await vault.addSecret({ type: exports.VAULT_SECRET_TYPE, key: { ...vaultKey(key), description: label ?? vaultName(key) }, value: JSON.stringify(credentials) });
386
+ if (!current()) {
387
+ await vault.removeSecret(exports.VAULT_SECRET_TYPE, vaultKey(key));
388
+ return false;
389
+ }
390
+ return true;
391
+ });
392
+ if (!saved) {
393
+ return false;
394
+ }
360
395
  newlySaved(key, true);
361
396
  // Not in two places: what the keychain had for it goes.
362
397
  try {
363
- await keychain(store => store.deletePassword(KEYCHAIN_SERVICE, key));
398
+ await keychain(async (store) => { if (current()) {
399
+ await store.deletePassword(KEYCHAIN_SERVICE, key);
400
+ } });
364
401
  }
365
402
  catch { }
366
- return;
403
+ return true;
367
404
  }
368
- await keychain(store => store.setPassword(KEYCHAIN_SERVICE, key, JSON.stringify(credentials)));
369
- newlySaved(key, false);
405
+ const saved = await keychain(async (store) => {
406
+ if (!current()) {
407
+ return false;
408
+ }
409
+ await store.setPassword(KEYCHAIN_SERVICE, key, JSON.stringify(credentials));
410
+ if (!current()) {
411
+ await store.deletePassword(KEYCHAIN_SERVICE, key);
412
+ return false;
413
+ }
414
+ return true;
415
+ });
416
+ if (saved) {
417
+ newlySaved(key, false);
418
+ }
419
+ return !!saved;
370
420
  }
371
421
  /**
372
422
  * A password was saved under `key` just now, in the Vault or with `inVault` false the keychain, in place of what a
@@ -423,6 +473,19 @@ async function forgetCredentialsOrFail(key) {
423
473
  await used();
424
474
  return forgetNow(key);
425
475
  }
476
+ /** An account edit/removal must not delete a replacement committed by a later edit. */
477
+ async function forgetCredentialsIf(key, current) {
478
+ noteForget(k => k === key);
479
+ await used();
480
+ if (vaultOn()) {
481
+ await queued(async () => { if (current()) {
482
+ await vault.removeSecret(exports.VAULT_SECRET_TYPE, vaultKey(key));
483
+ } });
484
+ }
485
+ await keychain(async (store) => { if (current()) {
486
+ await store.deletePassword(KEYCHAIN_SERVICE, key);
487
+ } });
488
+ }
426
489
  /** Forgets what is saved under `key`, in both stores. */
427
490
  async function forgetNow(key) {
428
491
  await forgetIn(true, key);
package/dist/targets.js CHANGED
@@ -153,7 +153,15 @@ class SystemSSHTarget {
153
153
  child.stderr.on('data', d => { stderr = (stderr + d).slice(-4096); });
154
154
  const timer = setTimeout(() => stop(new Error('Remote command timed out after 60s')), 60000);
155
155
  child.on('error', reject);
156
- child.on('exit', code => {
156
+ // ssh can exit before it reads stdin, or the command may not use it. Its status and stderr say why:
157
+ // EPIPE on the input pipe must not escape as an uncaught error or replace ssh's own explanation.
158
+ child.stdin.on('error', (e) => {
159
+ if (e.code !== 'EPIPE') {
160
+ stop(e);
161
+ }
162
+ });
163
+ // All output, and any input pipe error, has arrived only once the process's streams have closed.
164
+ child.on('close', code => {
157
165
  clearTimeout(timer);
158
166
  if (stopped) {
159
167
  reject(stopped);
package/dist/updates.js CHANGED
@@ -9,92 +9,95 @@ var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.UpdateCheck = void 0;
13
- exports.latestVersion = latestVersion;
12
+ exports.UpdateCheck = exports.newer = void 0;
14
13
  exports.installedVersion = installedVersion;
15
- exports.newer = newer;
14
+ exports.latestVersion = latestVersion;
15
+ const fs_1 = require("fs");
16
+ const path_1 = require("path");
16
17
  const core_1 = require("@angular/core");
17
18
  const rxjs_1 = require("rxjs");
18
19
  const tabby_core_1 = require("tabby-core");
19
20
  const help_1 = require("./help");
20
21
  const targets_1 = require("./targets");
21
- const REGISTRY = 'https://registry.npmjs.org/tabby-rdp/latest';
22
- const RELEASES = 'https://github.com/meanaverage/tabby-rdp/releases';
22
+ const hostCompat_1 = require("./hostCompat");
23
+ const packageChangeDialog_1 = require("./packageChangeDialog");
24
+ const releases_1 = require("./releases");
25
+ var releases_2 = require("./releases");
26
+ Object.defineProperty(exports, "newer", { enumerable: true, get: function () { return releases_2.newer; } });
23
27
  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);
28
+ // Capture the running package before an installer replaces its files on disk.
29
+ const PACKAGE = require('../package.json');
30
+ const RUNNING = PACKAGE.version;
31
+ function installedVersion() { return RUNNING; }
32
+ /** Kept for callers needing only npm's latest version, with the same response bounds. */
33
+ async function latestVersion(url = 'https://registry.npmjs.org/tabby-rdp/latest', timeoutMs = 15000, maxBytes = 256 * 1024) {
34
+ return String((await (0, releases_1.registryJSON)(url, timeoutMs, maxBytes))?.version ?? '');
35
+ }
36
+ function localStore() {
32
37
  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 ?? '');
38
+ return window.localStorage;
52
39
  }
53
- finally {
54
- clearTimeout(timer);
55
- // Ends the request, if it is still going (an answer too large).
56
- abort.abort();
40
+ catch {
41
+ return undefined;
57
42
  }
58
43
  }
59
- /** This package's version. */
60
- function installedVersion() {
44
+ function hasPreferences(storage) {
61
45
  try {
62
- return require('../package.json').version;
46
+ return typeof JSON.parse(storage?.getItem(releases_1.RELEASE_STATE_KEY) ?? 'null')?.paused === 'boolean';
63
47
  }
64
48
  catch {
65
- return '0.0.0';
66
- }
67
- }
68
- /** Whether `a` is a later release than `b` (x.y.z; a version with a pre-release part is never offered). */
69
- function newer(a, b) {
70
- const parse = (v) => /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim())?.slice(1).map(Number);
71
- const x = parse(a), y = parse(b);
72
- if (!x || !y) {
73
49
  return false;
74
50
  }
75
- for (let i = 0; i < 3; i++) {
76
- if (x[i] !== y[i]) {
77
- return x[i] > y[i];
78
- }
79
- }
80
- return false;
81
51
  }
82
- /**
83
- * Tells about a newer tabby-rdp: Tabby only shows plugin upgrades while its Plugins page is open, and never on its own.
84
- * Once a day (and a little after Tabby starts) it asks npm for the latest version, and nothing else: no identifier,
85
- * no usage. A newer one gets a note, once per version, over the active pane; an "Update available" item in the
86
- * menus and a line on the settings page until it's installed. `remoteDesktop.checkUpdates` turns it off.
87
- */
88
- let UpdateCheck = class UpdateCheck {
89
- constructor(config, app, help, platform, zone) {
52
+ /** Daily, opt-out update notes; explicit package changes are handled by ReleaseManager. */
53
+ let UpdateCheck = class UpdateCheck extends releases_1.ReleaseManager {
54
+ constructor(config, app, help, platform, zone, compat) {
55
+ const storage = localStore();
56
+ const migratePreferences = !hasPreferences(storage);
57
+ const dialog = new packageChangeDialog_1.PackageChangeDialog();
58
+ super({ running: RUNNING, policy: PACKAGE.tabbyRdp, tabby: compat.info.version ?? '', node: process.versions.node,
59
+ os: process.platform, cpu: process.arch, storage, fetchCatalog: releases_1.publishedVersions,
60
+ withLock: async (run) => {
61
+ if (!navigator.locks) {
62
+ throw new Error('This Tabby cannot coordinate version changes between windows. Use Tabby Plugins.');
63
+ }
64
+ await navigator.locks.request('tabby-rdp.package-change', run);
65
+ },
66
+ diskVersion: () => {
67
+ try {
68
+ return JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(__dirname, '..', 'package.json'), 'utf8')).version;
69
+ }
70
+ catch (error) {
71
+ if (error.code === 'ENOENT') {
72
+ return null;
73
+ }
74
+ throw error;
75
+ }
76
+ },
77
+ install: (name, version) => platform.installPlugin(name, version), uninstall: name => platform.uninstallPlugin(name),
78
+ confirm: (message, detail, action) => dialog.confirm(message, detail, action),
79
+ progress: state => dialog.update(state), }, !config.store || config.store.remoteDesktop?.checkUpdates === false);
90
80
  this.config = config;
91
81
  this.app = app;
92
82
  this.help = help;
93
83
  this.platform = platform;
94
84
  this.zone = zone;
95
- /** The newer version, once one is known. */
96
- this.available = null;
97
- this.changed$ = new rxjs_1.Subject();
85
+ // Tabby can construct providers before ConfigService has loaded its store.
86
+ // Keep checks paused until ready, and migrate the old switch only without a saved local preference.
87
+ if (migratePreferences) {
88
+ const migrate = () => { void this.setPaused(config.store?.remoteDesktop?.checkUpdates === false); };
89
+ if (config.store) {
90
+ migrate();
91
+ }
92
+ else {
93
+ config.ready$.pipe((0, rxjs_1.take)(1)).subscribe(migrate);
94
+ }
95
+ }
96
+ window.addEventListener('storage', event => {
97
+ if (event.key === releases_1.RELEASE_STATE_KEY) {
98
+ this.zone.run(() => this.syncFromStorage());
99
+ }
100
+ });
98
101
  }
99
102
  start() {
100
103
  this.zone.runOutsideAngular(() => {
@@ -102,41 +105,26 @@ let UpdateCheck = class UpdateCheck {
102
105
  this.timer = setInterval(() => this.check().catch(() => null), DAY);
103
106
  });
104
107
  }
105
- get enabled() {
106
- return this.config.store.remoteDesktop?.checkUpdates !== false;
107
- }
108
- /** Asks npm for the latest version (or takes `latest`, for tests), and tells about it if it's newer. */
109
- async check(latest) {
110
- if (!this.enabled && latest === undefined) {
108
+ async check() {
109
+ if (this.state.paused) {
111
110
  return null;
112
111
  }
113
- const version = latest ?? await latestVersion();
112
+ await this.refreshCatalog();
114
113
  this.zone.run(() => {
115
- this.available = newer(version, installedVersion()) ? version : null;
116
- this.changed$.next();
117
114
  if (this.available) {
118
115
  this.noteOnce(this.available);
119
116
  }
120
117
  });
121
118
  return this.available;
122
119
  }
123
- /**
124
- * Asks again now, when a version is known already: the settings page is where it's shown, and a Tabby left open for
125
- * days would otherwise offer the version it found first, not the latest (meanaverage/tabby-rdp#17).
126
- */
127
- refresh() {
128
- if (this.available && this.enabled) {
129
- this.check().catch(() => null);
120
+ /** Opening Updates loads published versions even when ordinary prompts are paused. */
121
+ refresh() { void this.refreshCatalog(); }
122
+ upgrade() { this.help.open('updates'); }
123
+ whatsNew(version = this.available ?? this.running) {
124
+ if ((0, releases_1.releaseVersion)(version)) {
125
+ this.platform.openExternal(`${releases_1.RELEASES}/tag/v${version}`);
130
126
  }
131
127
  }
132
- /** Settings › Plugins, where Tabby's Upgrade button is. */
133
- upgrade() {
134
- this.help.openSettings('plugins');
135
- }
136
- /** The release notes of the newer version. */
137
- whatsNew() {
138
- this.platform.openExternal(`${RELEASES}/tag/v${this.available ?? ''}`);
139
- }
140
128
  /** A note over the active pane, once per version (then the menus and the settings page keep saying it). */
141
129
  noteOnce(version) {
142
130
  const store = this.config.store.remoteDesktop;
@@ -163,5 +151,5 @@ let UpdateCheck = class UpdateCheck {
163
151
  exports.UpdateCheck = UpdateCheck;
164
152
  exports.UpdateCheck = UpdateCheck = __decorate([
165
153
  (0, core_1.Injectable)({ providedIn: 'root' }),
166
- __metadata("design:paramtypes", [tabby_core_1.ConfigService, tabby_core_1.AppService, help_1.RemoteDesktopHelp, tabby_core_1.PlatformService, core_1.NgZone])
154
+ __metadata("design:paramtypes", [tabby_core_1.ConfigService, tabby_core_1.AppService, help_1.RemoteDesktopHelp, tabby_core_1.PlatformService, core_1.NgZone, hostCompat_1.HostCompatibility])
167
155
  ], UpdateCheck);
package/docs/desk.md ADDED
@@ -0,0 +1,157 @@
1
+ # `desk`: the console, on the desktop
2
+
3
+ Type `desk` in an SSH console and the tab switches to that machine's desktop, where a maximized terminal is attached
4
+ to the *same* shell: same scrollback, working directory, jobs and running program. Typing on either side shows on
5
+ both. The hotkey or the header button takes you back to the console.
6
+
7
+ `desk` is off by default, because it changes how SSH logins start on the remote. Turn it on in
8
+ the menu's **Remote Desktop › Settings**, or Tabby's Settings › Remote Desktop (the `remoteDesktop.desk` setting).
9
+
10
+ <p align="center">
11
+ <img src="images/console.png" width="49%" alt="A console before desk">
12
+ <img src="images/desk.png" width="49%" alt="The same shell in a terminal on the desktop">
13
+ </p>
14
+
15
+ ## How it fits together
16
+
17
+ | Step | What happens | Code |
18
+ |---|---|---|
19
+ | Setup | With `desk` on, the next desktop connect installs `~/.local/share/tabby-rdp/` (`login.sh`, `bin/desk`, `bin/trd-pty`, `tmux.conf`, `backend`, `desk-key`) and one line in `~/.bashrc` / `~/.zshrc`, and tells the plugin desk's key. Turning `desk` off removes exactly that line and those files on the next connect: the rest of each rc file is written back into it byte for byte (a last line without its newline gets one), so links to it, its other names, its mode and its owner stay. A read-only rc file is left as it is, and the desktop's log says so; should writing one fail part way (a full disk), what it should hold stays in `~/.local/share/tabby-rdp/`, and the log says where. | `src/remoteSetup.ts` |
20
+ | Login | Interactive SSH logins start inside a shareable session: `trd-pty` (default) or tmux. | `login.sh` |
21
+ | `desk` | `bin/desk` prints an OSC 7777 sequence (session, socket, working directory, kind, machine, host name, desk's key). | `login.sh`, `bin/desk` |
22
+ | Interception | A `SessionMiddleware` on every terminal session strips the sequence and, if it carries a key a host gave, asks the plugin to open the console on the desktop. | `src/desk.ts`, `src/deskScript.ts` |
23
+ | Desktop | The host's own desktop opens over the pane (or the one already open for that account is shown). | `src/desktop.service.ts` |
24
+ | Terminal | Over an SSH exec channel, in the headless session: `gnome-terminal --maximize -- trd-pty attach <id>` (or `tmux attach`). | `src/deskScript.ts` |
25
+
26
+ `TABBY_NO_SESSION=1` skips the shared session for one login. `desk` outside a shared session opens a terminal in the
27
+ same folder. Logins that started before the hook was installed need to reconnect.
28
+
29
+ ## Which requests count
30
+
31
+ `desk` reaches Tabby the way any program's output does, so a file shown with `cat`, a log, or another machine's output
32
+ could print the same sequence. What tells them apart is the key `bin/desk` adds: 128 random bits in
33
+ `~/.local/share/tabby-rdp/desk-key` (0600, in the plugin's 0700 folder), which only the user's own processes on that
34
+ host can read. The setup reports it to the plugin (`RD_DESK`), which keeps its SHA-256 per host account in
35
+ `remoteDesktop.deskKeys` (never the key itself), so `desk` keeps working after Tabby restarts.
36
+
37
+ - **A request counts only with the key of the host it is for:** the tab's host, or the machine `ssh` typed in its
38
+ console went to. A request without a key (a `desk` from before keys) or with one no host gave is dropped as the
39
+ output is read, before anything runs, here or there, and without a word in Tabby's window, `desk` on or off; the
40
+ developer console counts them, once per 2 s. A request with a key known for another host shows a note saying so.
41
+ - **So `desk` works on hosts whose desktop this Tabby opened with `desk` on:** the setup that runs as the desktop opens
42
+ reports the key, whether or not the connection that follows gets through (for a machine reached with `ssh` typed in a
43
+ console, its desktop opened through that host, under whose key it is kept). A setup run with `desk` off gives no key,
44
+ whatever the host prints, and has Tabby forget the one it had for that host as it starts (so also when the setup then
45
+ fails or never finishes); one during which `desk` is turned off gives none either, and has Tabby forget it when it
46
+ ends or fails with `desk` still off; with `desk` on again by then, it changes nothing, so that what a setup since kept
47
+ (a new key, or none) stays. A setup that finds xrdp or Windows there instead of GNOME has Tabby forget it too. After
48
+ updating from an earlier version, open each host's desktop once: that installs the new `bin/desk` there and tells
49
+ Tabby its key. The same goes for a host set up from another computer, unless that computer's Tabby shares this one's
50
+ config (config sync): the keys' fingerprints are part of it.
51
+ - **Only in the pane in front.** A request from a pane that isn't the focused pane of the active tab, or that stops
52
+ being it by the time the desktop would show (the plugin first asks the machine which one it is), gets a note in that
53
+ pane instead of the desktop: shown there, the desktop would take the keyboard, and this computer's clipboard with
54
+ it, from the pane in use, even from another tab (Tabby keeps those in the page, off screen). A desktop that
55
+ finishes connecting, a sign-in form or a question that shows up, or the first connection's tip as it closes, takes
56
+ the keyboard only in the pane in front too.
57
+ - **One at a time:** of the requests with a known key, a terminal passes on one every 2 s (the last one in its chunk
58
+ of output), and a pane handles one at a time, then none for 2 s. Output full of look-alikes costs about what any
59
+ output of its size does. With `desk` off, a request with a known key from the pane in front says that `desk` is off,
60
+ as often as that; from another pane, nothing.
61
+ - **Only sessions of the user's own** get a desktop terminal attached: a trd-pty session by its id (the client checks
62
+ that the session runs as the same user), or a tmux server whose socket the user owns, in tmux's folder for them
63
+ (`tmux-<uid>`, no access for others), with no symbolic link on the way and every folder above it root's or the
64
+ user's and one other users can't write to (or sticky, like `/tmp`), so that nobody else can swap the path before the
65
+ attach. Anything else opens a terminal in the folder instead.
66
+ - Turning `desk` off and on again (opening the desktop in between) makes a new key.
67
+ - The key tells `desk` apart from look-alike output, not from the host itself, which can always read it: a host
68
+ whose desktop you opened with `desk` on can open that desktop again from its console, while you are in its pane.
69
+ Nor from a recording of `desk`'s output (`script`, asciinema, a terminal log), which holds the key: shown again in a
70
+ pane connected to that machine, it counts as `desk`. Open the desktop of a host you don't trust with `desk` off.
71
+
72
+ ## `trd-pty`: a shared terminal session, not a multiplexer
73
+
74
+ The shared session only has to do a few things, which is why it isn't tmux by default:
75
+
76
+ - own the shell's PTY, so the shell survives SSH disconnects;
77
+ - let several terminals attach at once, with output to all of them and input from any;
78
+ - show the current screen to a terminal that attaches late;
79
+ - follow one terminal's size at a time, without flapping;
80
+ - end like a plain SSH login when its last terminal goes.
81
+
82
+ tmux does all of that, but also takes Ctrl-B, re-renders output (so the terminal's own scrollback needs overrides),
83
+ needs passthrough for OSC sequences such as working-directory reporting, clipboard and `desk` itself, and nests with a
84
+ tmux of your own. `remote/trd-pty.py` (Python 3 standard library, about 510 lines of code) does only the list above:
85
+
86
+ ```
87
+ ┌── trd-pty client ◀─▶ Tabby console (SSH PTY → xterm.js)
88
+ login shell ◀─▶ PTY ◀─▶ session owner ──┤
89
+ └── trd-pty client ◀─▶ GNOME Terminal on the desktop
90
+ ```
91
+
92
+ - **One owner process per session** holds the PTY (login shell, `TRD_SESSION` / `TRD_SOCKET` in its environment),
93
+ detached with `setsid`.
94
+ - **Access:** a Unix socket in `$XDG_RUNTIME_DIR/trd-pty/` (directory `0700`, checked; socket `0600`), or
95
+ `/tmp/trd-pty-<uid>/` without a runtime directory, only while `/tmp` has its sticky bit (otherwise another user
96
+ could swap the folder). Both ends check that the other runs as the same user (`SO_PEERCRED` on Linux,
97
+ `LOCAL_PEERCRED` on macOS and the BSDs): a session doesn't take another user's keystrokes, and a terminal doesn't
98
+ send its own to another user's session. Where the system can't say, neither end goes on, and `trd-pty new` starts
99
+ no session. No network listener.
100
+ - **Protocol:** framed messages (hello, input, resize, claim, query; output, exit). A malformed or oversized frame drops
101
+ that client, never the session; a client more than 8 MB behind is dropped.
102
+ - **Output** goes to every terminal byte for byte: colors, titles, OSC sequences and scrollback stay native.
103
+ - **Size:** one terminal at a time is the *controller* and sets the PTY size: the one that attached, typed or got
104
+ focus last. Clients turn on focus reporting in their own terminal to notice focus; the program only sees focus
105
+ reports if it asked for them.
106
+ - **Late attach:** the last 256 KB of output is replayed (from a line boundary), then a SIGWINCH makes full-screen
107
+ programs repaint. Input from the new terminal is ignored for 300 ms, which swallows its automatic answers to
108
+ replayed queries.
109
+ - **Lifetime:** the shell exiting ends the session. When the last terminal detaches, the shell gets SIGHUP, like a
110
+ dropped SSH login (`TRD_PTY_LINGER=<seconds>` keeps it for a reattach). `trd-pty ls` lists sessions and removes
111
+ sockets whose owner is gone.
112
+ - **Fallbacks:** if `trd-pty` can't start a session (no `python3`; or exit code 3: no safe folder for its sockets,
113
+ one it can't make, or no way to check who is at the other end of a socket), the login falls back to tmux, then to a
114
+ plain shell. The `remoteDesktop.sessionBackend: tmux` setting uses tmux throughout.
115
+
116
+ ### Alternatives considered
117
+
118
+ - **dtach / abduco:** multiple attach and raw passthrough, but no size policy or late-attach redraw, and another
119
+ package to install.
120
+ - **Keeping screen state on the server** (pyte, libvterm, a headless xterm.js): exact late attach, at the cost of a
121
+ terminal emulator on every host. Replay plus a repaint covers shells and full-screen programs.
122
+ - **Moving the PTY (reptyr):** needs ptrace, which hardened hosts restrict.
123
+
124
+ ## Tests
125
+
126
+ - `test/unit/trd-pty.py`, run on a Linux host (`npm test -- trd-pty`): creating, attaching and reattaching; output to
127
+ two views and each keystroke exactly once; the controller following input and focus; Ctrl-C, job control, UTF-8,
128
+ colors and titles; the alternate screen and repaint on late attach; malformed clients, no network listener, another
129
+ user refused; shell exit, SIGHUP on last detach, linger, and stale-socket cleanup; another user's session socket
130
+ neither counted nor attached; neither end going on without peer credentials; the socket folder's checks, and exit
131
+ code 3 when it is unsafe, can't be made, or no peer can be checked.
132
+ - `test/unit/desk.ts`, `test/unit/desk-service.ts` and `test/unit/remote-scripts.ts` (`npm run test:unit`): requests
133
+ taken out of the output and their key, the last one with a known key, floods over one chunk or many, one at a time;
134
+ the terminal's listener with `desk` off; which keys the plugin keeps from which setups, and what a request with no
135
+ key, another host's, desk off, a machine reached with `ssh` typed in the console or a pane not in front gets; a
136
+ sign-in form leaving the keyboard in the pane in front; the setup, `bin/desk`, the terminal script and the idle
137
+ watcher run under `/bin/sh` against a fake host, checking that no command line carries the password or the key, that
138
+ the login hook is added and removed exactly (through a linked, hard-linked or read-only rc file too, with bytes that
139
+ aren't text, and when writing fails part way), that two setups at once agree on the key, and which sockets a desktop
140
+ terminal may attach to.
141
+ - `test/suites/desk.ts` (`npm test -- desk`, with `TRD_TEST_BACKEND=native` or `tmux`): logins inside the session,
142
+ `desk` to the desktop with the same session attached, typing reaching the shell once, requests without the key
143
+ ignored, RDP and SSH disconnects keeping the session, and turning `desk` off and on leaving `~/.bashrc` as it was.
144
+
145
+ Typical timings on a local network: `desk` to the desktop about 0.4 s; the terminal attached after about 1 s, mostly
146
+ GNOME Terminal starting.
147
+
148
+ ## Limitations
149
+
150
+ - A late attach replays output rather than restoring exact screen state. A program that ignores SIGWINCH shows the
151
+ replay until it next redraws.
152
+ - Terminals that aren't the controller draw the stream at their own width, so long lines wrap differently there until
153
+ that terminal takes over.
154
+ - Typing in the first 300 ms after a late attach is dropped.
155
+ - Tabby keeps a closed tab's remote shell while other tabs share its SSH connection, so that terminal stays attached
156
+ until the connection closes.
157
+ - Needs `python3` on the remote (for `trd-pty`) and GNOME Terminal on the desktop.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabby-rdp",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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",
@@ -30,7 +30,9 @@
30
30
  "vendor",
31
31
  "remote",
32
32
  "LICENSE",
33
- "CHANGELOG.md"
33
+ "CHANGELOG.md",
34
+ "TROUBLESHOOTING.md",
35
+ "docs/desk.md"
34
36
  ],
35
37
  "engines": {
36
38
  "node": ">=18"
@@ -41,21 +43,28 @@
41
43
  "typecheck": "tsc -p tsconfig.json --noEmit && npm run typecheck:test",
42
44
  "typecheck:test": "tsc -p tsconfig.test.json",
43
45
  "test": "tsx test/run.ts",
46
+ "test:smoke": "tsx test/run.ts --packed --hidden smoke-host smoke-rdp direct",
44
47
  "test:unit": "tsx --test test/unit/*.ts",
45
48
  "build:ironrdp": "scripts/build-ironrdp.sh",
46
49
  "prepublishOnly": "npm run build"
47
50
  },
48
51
  "dependencies": {
52
+ "semver": "^7.8.5",
49
53
  "ws": "^8.21.1"
50
54
  },
51
55
  "devDependencies": {
52
56
  "@angular/core": "15.2.10",
53
57
  "@types/node": "^20.19.0",
58
+ "@types/semver": "^7.8.0",
54
59
  "rxjs": "7.8.2",
55
60
  "tabby-core": "1.0.231-nightly.0",
56
61
  "tabby-settings": "1.0.231-nightly.0",
57
62
  "tabby-terminal": "1.0.231-nightly.0",
58
63
  "tsx": "^4.23.15",
59
64
  "typescript": "~5.9.3"
65
+ },
66
+ "tabbyRdp": {
67
+ "minimumTabbyVersion": "1.0.236",
68
+ "rollbackFloor": "0.5.3"
60
69
  }
61
70
  }
package/vendor/SHA256SUMS CHANGED
@@ -24,3 +24,4 @@ d67aa6029edb142daa1e5069fa79ab1917687474a89a61bd221081f204ba8e12 ironrdp/patche
24
24
  538641d863e52972074b032c111a67cfa02d06290bff0601423f3dfd25ad563b vendor/iron-remote-desktop-rdp.js
25
25
  5a6363b9140a5a2abf0218aa39edb38f10445e8d7e9c5a798c321772e1f07c11 vendor/iron-remote-desktop.js
26
26
  31f961cc5fc60f6a3dc580333f871bf9b380950dc849395465844dcc90f871f3 vendor/ironrdp_web_bg.wasm
27
+ 82470ad2980db3fe48787cb821e231fc44811b046f7820555f6e25f2b4ccce28 vendor/ironrdp-factory.js