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.
@@ -10,6 +10,7 @@ const desktops_1 = require("./desktops");
10
10
  const gateway_1 = require("./gateway");
11
11
  const osd_1 = require("./osd");
12
12
  const wake_1 = require("./wake");
13
+ const hostCompat_1 = require("./hostCompat");
13
14
  /** host:port, [v6]:port, or a bare host (RDP's 3389); null if it doesn't look like an address. */
14
15
  function parseAddress(text) {
15
16
  // No '#' in a host: session and trust keys are split on it (see keyParts).
@@ -109,13 +110,13 @@ function askDesktop(pane, options) {
109
110
  account.hidden = account.options.length < 2;
110
111
  }
111
112
  ownAccount();
112
- // The gateway's own account: offered once there is a gateway and a saved account to choose.
113
+ // The gateway's own account, including a separate prompt when no saved account is chosen.
113
114
  const gateway = field('gateway');
114
115
  const gatewayAccount = field('gatewayAccount');
115
116
  gateway.value = (0, desktops_1.configText)(initial.gateway);
116
- gatewayAccount.append(new Option('Gateway account: the same as the desktop\'s', ''), ...accounts.map(a => new Option(`Gateway account: ${a.name} (${(0, accounts_1.signInName)(a)})`, a.id)));
117
+ gatewayAccount.append(new Option('Gateway account: the same as the desktop\'s', ''), new Option('Gateway account: ask when connecting', accounts_1.ASK_GATEWAY_ACCOUNT), ...accounts.map(a => new Option(`Gateway account: ${a.name} (${(0, accounts_1.signInName)(a)})`, a.id)));
117
118
  const initialGatewayAccount = (0, desktops_1.configText)(initial.gatewayAccount);
118
- if (initialGatewayAccount && !accounts.some(a => a.id === initialGatewayAccount)) {
119
+ if (initialGatewayAccount && initialGatewayAccount !== accounts_1.ASK_GATEWAY_ACCOUNT && !accounts.some(a => a.id === initialGatewayAccount)) {
119
120
  gatewayAccount.append(new Option('(a saved account that no longer exists)', initialGatewayAccount));
120
121
  }
121
122
  gatewayAccount.value = initialGatewayAccount;
@@ -134,7 +135,7 @@ function askDesktop(pane, options) {
134
135
  }
135
136
  pane.appendChild(overlay);
136
137
  // Same as a desktop layer: the terminal underneath can't take the keyboard while the form shows.
137
- const inputs = Array.from(pane.querySelectorAll('textarea.xterm-helper-textarea')).filter(t => !t.disabled);
138
+ const inputs = (0, hostCompat_1.terminalInputs)(pane).filter(t => !t.disabled);
138
139
  inputs.forEach(t => { t.disabled = true; });
139
140
  const focusForm = () => {
140
141
  if (!form.contains(document.activeElement)) {
@@ -0,0 +1,212 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DiskStorage = exports.DISK_PATH = exports.DownloadBudget = exports.MAX_DOWNLOAD_BYTES = void 0;
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const path = require("path");
7
+ /** The provider's supported file-size limit. Files are streamed; this much memory is never reserved. */
8
+ exports.MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024 * 1024;
9
+ const HANDLE_PENDING_BYTES = 16 * 1024 * 1024;
10
+ const WINDOW_PENDING_BYTES = 64 * 1024 * 1024;
11
+ const MIN_WRITE_COST = 64 * 1024;
12
+ const MAX_HANDLES = 32;
13
+ const INIT_TIMEOUT_MS = 15000; // earlier than the provider's 30-second initialization deadline
14
+ /** Both bytes and tasks are bounded: tiny chunks must not make an arbitrarily long promise queue. */
15
+ class DownloadBudget {
16
+ constructor(limit = WINDOW_PENDING_BYTES) {
17
+ this.limit = limit;
18
+ this.pending = 0;
19
+ }
20
+ reserve(bytes) {
21
+ const cost = Math.max(MIN_WRITE_COST, bytes);
22
+ if (cost > this.limit - this.pending) {
23
+ throw new Error('Too much download data is waiting to be saved.');
24
+ }
25
+ this.pending += cost;
26
+ let held = true;
27
+ return () => { if (held) {
28
+ held = false;
29
+ this.pending -= cost;
30
+ } };
31
+ }
32
+ }
33
+ exports.DownloadBudget = DownloadBudget;
34
+ const windowBudget = new DownloadBudget();
35
+ exports.DISK_PATH = Symbol('tabby-rdp download');
36
+ class DownloadFile {
37
+ constructor(file, handle, expected, ownBudget, sharedBudget, ended) {
38
+ this.file = file;
39
+ this.handle = handle;
40
+ this.expected = expected;
41
+ this.ownBudget = ownBudget;
42
+ this.sharedBudget = sharedBudget;
43
+ this.ended = ended;
44
+ this.bytesWritten = 0;
45
+ this.accepted = 0;
46
+ this.state = 'open';
47
+ this.tail = Promise.resolve();
48
+ }
49
+ get cancelled() { return this.state === 'aborting' || this.state === 'aborted'; }
50
+ write(chunk) {
51
+ let releaseOwn;
52
+ let releaseShared;
53
+ let data;
54
+ try {
55
+ if (this.state !== 'open') {
56
+ throw new Error('The download is closed for writing.');
57
+ }
58
+ if (!(chunk instanceof Uint8Array) || !chunk.byteLength) {
59
+ throw new Error('A download write must contain bytes.');
60
+ }
61
+ if (chunk.byteLength > this.expected - this.accepted) {
62
+ throw new Error('The download exceeds its expected size.');
63
+ }
64
+ releaseOwn = this.ownBudget.reserve(chunk.byteLength);
65
+ releaseShared = this.sharedBudget.reserve(chunk.byteLength);
66
+ // Hold only the admitted bytes, not a small view retaining a much larger backing buffer.
67
+ data = Uint8Array.from(chunk);
68
+ this.accepted += data.byteLength;
69
+ }
70
+ catch (error) {
71
+ releaseOwn?.();
72
+ releaseShared?.();
73
+ if (this.state === 'open') {
74
+ this.fail(error);
75
+ }
76
+ return Promise.reject(error);
77
+ }
78
+ const writing = this.tail.then(async () => {
79
+ if (this.failure || this.cancelled) {
80
+ throw this.failure ?? new Error('The download was cancelled.');
81
+ }
82
+ await this.handle.appendFile(data);
83
+ this.bytesWritten += data.byteLength;
84
+ if (this.cancelled) {
85
+ throw new Error('The download was cancelled.');
86
+ }
87
+ }).finally(() => { releaseOwn(); releaseShared(); });
88
+ this.tail = writing.catch(error => { this.fail(error); });
89
+ return writing;
90
+ }
91
+ fail(error) {
92
+ this.failure ??= error instanceof Error ? error : new Error('The download could not be written.');
93
+ // Cleanup waits for already-started native I/O. The error is returned by write/finalize; dispose retries cleanup.
94
+ void this.abort().catch(() => null);
95
+ }
96
+ close() {
97
+ return this.closed ??= this.handle.close().finally(this.ended);
98
+ }
99
+ finalize() {
100
+ if (this.cancelled) {
101
+ return Promise.reject(this.failure ?? new Error('The download was cancelled.'));
102
+ }
103
+ if (this.finalized) {
104
+ return this.finalized;
105
+ }
106
+ if (this.state !== 'open') {
107
+ return Promise.reject(this.failure ?? new Error('The download is closed.'));
108
+ }
109
+ this.state = 'finalizing'; // no further admission; writes already accepted still finish in order
110
+ this.finalized = this.tail.then(async () => {
111
+ if (this.failure || this.state !== 'finalizing') {
112
+ throw this.failure ?? new Error('The download was cancelled.');
113
+ }
114
+ if (this.bytesWritten !== this.expected) {
115
+ throw new Error('The download ended before its expected size.');
116
+ }
117
+ await this.close();
118
+ if (this.state !== 'finalizing') {
119
+ throw new Error('The download was cancelled.');
120
+ }
121
+ this.state = 'finalized';
122
+ return Object.assign(new Blob([]), { [exports.DISK_PATH]: this.file });
123
+ }).catch(async (error) => { await this.abort().catch(() => null); throw error; });
124
+ return this.finalized;
125
+ }
126
+ abort() {
127
+ if (this.aborted) {
128
+ return this.aborted;
129
+ }
130
+ this.state = 'aborting';
131
+ this.aborted = this.tail.then(async () => {
132
+ try {
133
+ await this.close();
134
+ }
135
+ finally {
136
+ await fs.promises.rm(this.file, { force: true });
137
+ }
138
+ this.state = 'aborted';
139
+ });
140
+ return this.aborted;
141
+ }
142
+ }
143
+ /** Private temporary storage, bounded before asynchronous I/O, with ordered completion and cancellation. */
144
+ class DiskStorage {
145
+ /** Smaller budgets/deadlines can be supplied by local filesystem tests without large allocations. */
146
+ constructor(options = {}) {
147
+ this.options = options;
148
+ this.name = 'disk';
149
+ this.dir = null;
150
+ this.count = 0;
151
+ this.disposed = false;
152
+ this.files = new Set();
153
+ this.creating = new Set();
154
+ }
155
+ createWriteHandle(_fileName, expectedSize) {
156
+ if (this.disposed) {
157
+ return Promise.reject(new Error('The download storage is closed.'));
158
+ }
159
+ if (!Number.isSafeInteger(expectedSize) || expectedSize < 0 || expectedSize > exports.MAX_DOWNLOAD_BYTES) {
160
+ return Promise.reject(new Error('The download has no supported expected size.'));
161
+ }
162
+ if (this.creating.size + this.files.size >= MAX_HANDLES) {
163
+ return Promise.reject(new Error('Too many downloads are open.'));
164
+ }
165
+ let expired = false;
166
+ let timer;
167
+ const making = (async () => {
168
+ this.dir ??= fs.promises.mkdtemp(path.join(os.tmpdir(), 'tabby-rdp-'));
169
+ const dir = await this.dir;
170
+ if (this.disposed || expired) {
171
+ throw new Error('Download initialization was cancelled.');
172
+ }
173
+ const file = path.join(dir, String(++this.count));
174
+ let handle;
175
+ try {
176
+ const opened = await fs.promises.open(file, 'wx', 0o600);
177
+ handle = new DownloadFile(file, opened, expectedSize, new DownloadBudget(this.options.pendingBytes ?? HANDLE_PENDING_BYTES), this.options.budget ?? windowBudget, () => this.files.delete(handle));
178
+ this.files.add(handle);
179
+ if (this.disposed || expired) {
180
+ throw new Error('Download initialization was cancelled.');
181
+ }
182
+ return handle;
183
+ }
184
+ catch (error) {
185
+ await handle?.abort().catch(() => null);
186
+ await fs.promises.rm(file, { force: true }).catch(() => null);
187
+ throw error;
188
+ }
189
+ })();
190
+ this.creating.add(making);
191
+ void making.then(() => this.creating.delete(making), () => this.creating.delete(making));
192
+ const deadline = new Promise((_resolve, reject) => {
193
+ timer = setTimeout(() => { expired = true; reject(new Error('Download storage did not open in time.')); }, this.options.initTimeoutMs ?? INIT_TIMEOUT_MS);
194
+ });
195
+ return Promise.race([making, deadline]).finally(() => clearTimeout(timer));
196
+ }
197
+ dispose() {
198
+ this.disposed = true; // also invalidates initialization still waiting on native filesystem work
199
+ this.disposing ??= (async () => {
200
+ // Abort before awaiting initialization: accepted writes stop taking more data immediately.
201
+ const aborting = [...this.files].map(file => file.abort());
202
+ await Promise.allSettled([...this.creating, ...aborting]);
203
+ if (this.dir) {
204
+ await this.dir.then(dir => fs.promises.rm(dir, { recursive: true, force: true }), () => null);
205
+ }
206
+ })();
207
+ // The provider does not await dispose(); callers that do still receive its actual cleanup result.
208
+ void this.disposing.catch(() => null);
209
+ return this.disposing;
210
+ }
211
+ }
212
+ exports.DiskStorage = DiskStorage;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.FileTransfer = exports.STYLE = exports.DiskStorage = exports.MIN_COST = exports.MAX_WAITING = exports.MAX_IN_FLIGHT = exports.MAX_RANGE = exports.MAX_LINKS = exports.MAX_LOOKED_AT = exports.MAX_ENTRIES = void 0;
3
+ exports.FileTransfer = exports.STYLE = exports.MIN_COST = exports.MAX_WAITING = exports.MAX_IN_FLIGHT = exports.MAX_RANGE = exports.MAX_LINKS = exports.MAX_LOOKED_AT = exports.MAX_ENTRIES = exports.DiskStorage = void 0;
4
4
  exports.clipboardPaths = clipboardPaths;
5
5
  exports.entriesFor = entriesFor;
6
6
  exports.safeName = safeName;
@@ -14,6 +14,9 @@ const fs = require("fs");
14
14
  const os = require("os");
15
15
  const path = require("path");
16
16
  const child_process_1 = require("child_process");
17
+ const diskStorage_1 = require("./diskStorage");
18
+ var diskStorage_2 = require("./diskStorage");
19
+ Object.defineProperty(exports, "DiskStorage", { enumerable: true, get: function () { return diskStorage_2.DiskStorage; } });
17
20
  /**
18
21
  * Limits for a folder copied here and pasted with ⌘V: entries offered, and how deep. (IronRDP walks dropped folders
19
22
  * itself, with limits of its own.)
@@ -400,56 +403,6 @@ function entriesFor(paths) {
400
403
  }
401
404
  return { entries, leftOut, cut };
402
405
  }
403
- /** On a download's result: the temporary file holding it (see DiskStorage). */
404
- const DISK_PATH = Symbol('tabby-rdp download');
405
- /**
406
- * Where downloads are kept while they arrive (IronRDP's FileStorageBackend): a file each in a private temporary
407
- * folder, written chunk by chunk, so a large file takes its size on disk rather than twice that in memory. The
408
- * result handed back is an empty Blob that names the file; saveAll moves it into place.
409
- */
410
- class DiskStorage {
411
- constructor() {
412
- this.name = 'disk';
413
- this.dir = null;
414
- this.count = 0;
415
- }
416
- async createWriteHandle(_fileName, _expectedSize) {
417
- this.dir ??= fs.promises.mkdtemp(path.join(os.tmpdir(), 'tabby-rdp-')); // 0700, a name nobody could prepare
418
- const file = path.join(await this.dir, String(++this.count));
419
- const handle = await fs.promises.open(file, 'wx', 0o600);
420
- let bytesWritten = 0;
421
- let open = true;
422
- const close = async () => {
423
- if (open) {
424
- open = false;
425
- await handle.close();
426
- }
427
- };
428
- return {
429
- get bytesWritten() { return bytesWritten; },
430
- async write(chunk) {
431
- await handle.appendFile(chunk);
432
- bytesWritten += chunk.length;
433
- },
434
- async finalize() {
435
- await close();
436
- return Object.assign(new Blob([]), { [DISK_PATH]: file });
437
- },
438
- async abort() {
439
- await close().catch(() => null);
440
- await fs.promises.rm(file, { force: true });
441
- },
442
- };
443
- }
444
- async dispose() {
445
- const dir = this.dir;
446
- this.dir = null;
447
- if (dir) {
448
- await dir.then(d => fs.promises.rm(d, { recursive: true, force: true }), () => null);
449
- }
450
- }
451
- }
452
- exports.DiskStorage = DiskStorage;
453
406
  exports.STYLE = `
454
407
  .trd-toast { position: absolute; top: 12px; right: 12px; z-index: 2; max-width: min(420px, calc(100% - 24px));
455
408
  display: flex; gap: 10px; align-items: center; padding: 8px 10px; border-radius: 6px;
@@ -534,7 +487,7 @@ class FileTransfer {
534
487
  this.overlapping = false;
535
488
  /** Whether an answer for a request that is no more was dropped (logged once: see the constructor). */
536
489
  this.stray = false;
537
- this.provider = new rdp.RdpFileTransferProvider({ storageBackend: new DiskStorage() });
490
+ this.provider = new rdp.RdpFileTransferProvider({ storageBackend: new diskStorage_1.DiskStorage() });
538
491
  // The remote can ask again, later, for files offered from here: their contents go only while this computer's
539
492
  // clipboard goes there. The provider's extensions call the method on it, so this one takes its place.
540
493
  this.serve = this.provider.handleFileContentsRequest.bind(this.provider);
@@ -1263,7 +1216,7 @@ async function foldersInside(dir, folder, made = []) {
1263
1216
  async function place(dir, wanted, blob, made = []) {
1264
1217
  const folder = await foldersInside(dir, path.dirname(wanted), made);
1265
1218
  const { name, ext } = path.parse(wanted);
1266
- const temporary = blob[DISK_PATH];
1219
+ const temporary = blob[diskStorage_1.DISK_PATH];
1267
1220
  // Writes the file at `target`, a name that was free just before: false if it has been taken since (EEXIST).
1268
1221
  const write = async (target) => {
1269
1222
  if (temporary) {
package/dist/help.js CHANGED
@@ -9,7 +9,7 @@ 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.RemoteDesktopHelp = exports.esc = exports.TROUBLESHOOTING = exports.SETTINGS_TAB_ID = exports.HOTKEYS = exports.TOGGLE_HOTKEY = void 0;
12
+ exports.RemoteDesktopHelp = exports.esc = exports.TROUBLESHOOTING = exports.TROUBLESHOOTING_URL = exports.SETTINGS_TAB_ID = exports.HOTKEYS = exports.TOGGLE_HOTKEY = void 0;
13
13
  exports.entryFor = entryFor;
14
14
  exports.hotkeyLabel = hotkeyLabel;
15
15
  const core_1 = require("@angular/core");
@@ -33,108 +33,50 @@ exports.HOTKEYS = [
33
33
  ];
34
34
  /** The plugin's page in Tabby's settings (see settingsPage.ts). */
35
35
  exports.SETTINGS_TAB_ID = 'remote-desktop';
36
+ exports.TROUBLESHOOTING_URL = 'https://github.com/meanaverage/tabby-rdp/blob/main/TROUBLESHOOTING.md';
36
37
  exports.TROUBLESHOOTING = [
37
38
  {
38
39
  id: 'certificate',
39
40
  title: 'A certificate this computer can\'t verify, or one that has changed',
40
41
  match: /certificate/i,
41
- body: `A desktop you connect to directly (a remote desktop profile, an imported file) and an RD Gateway connect
42
- without asking when their certificate is valid for their name by this computer's certificate authorities, as
43
- an organisation's own desktops and gateways often are. Any other is shown the first time, with why it can't
44
- be verified (most often: it is the server's own, self-signed) and its fingerprint, before anything of your
45
- sign-in is sent: check the fingerprint with whoever runs it, some other way than this connection (it is in the
46
- desktop's log too, which <b>Copy log</b> under Open desktops below copies), then choose <b>Trust the
47
- certificate</b>. The question also says whom the certificate says it was issued to and by, and when it is
48
- valid: its own word, which nothing has checked, unless its chain leads to an authority this computer trusts
49
- (another machine's certificate then shows that machine's name) and the TLS library didn't refuse it for a
50
- reason of its own (a limit set on the authority, say). An issuer you wouldn't expect there (one
51
- that inspects your network's connections, say) is worth asking about before you trust it, but the one you
52
- would expect proves nothing: anyone can make a certificate that names it. Only the fingerprint, checked some
53
- other way, does. A desktop behind an SSH host remembers its certificate on first use without asking, since
54
- the way there runs inside SSH. When a remembered certificate differs later, the plugin stops before signing
55
- in, so nothing of your sign-in has gone to that server; only a certificate an authority vouched for is
56
- replaced without a question, by another it vouches for too. Reinstalling the machine or renewing its
57
- certificate changes it: then choose <b>Trust the new certificate</b>. If neither happened, something else
58
- is answering at that address. A GNOME desktop only ever accepts the certificate the plugin made for it; a
59
- different one there means another program listens on its port. Remembered certificates are listed above.`,
60
42
  },
61
43
  {
62
44
  id: 'sign-in',
63
45
  title: 'Wrong user name or password',
64
46
  match: /sign-in|sign in|password|user name/i,
65
- body: `Windows and xrdp desktops sign in with an account on that machine (<code>DOMAIN\\user</code> works). A saved
66
- password that no longer works is asked for again; <b>Sign in again…</b> in the tab's menu replaces it on
67
- purpose. xrdp doesn't refuse a wrong password: it shows its own login window instead. GNOME desktops never
68
- ask: the plugin manages their sign-in.`,
69
47
  },
70
48
  {
71
49
  id: 'no-desktop',
72
50
  title: 'No desktop found on a Linux host',
73
51
  match: /GNOME|xrdp|grdctl|headless/i,
74
- body: `The plugin uses GNOME Remote Desktop 46 or newer (Ubuntu 24.04, for example), with no root and no login
75
- screen. For KDE, XFCE, MATE and others, install xrdp and a desktop for its sessions: on Debian and Ubuntu,
76
- <code>sudo apt install xrdp xfce4</code>. A GNOME session already shared from the machine's own screen (GNOME's
77
- Desktop Sharing) has to be turned off first.`,
78
52
  },
79
53
  {
80
54
  id: 'unreachable',
81
55
  title: 'Can\'t reach a Windows desktop',
82
56
  match: /refused|timed out|timeout|unreachable|ECONN|EHOST|no route|could not connect|couldn't connect/i,
83
- body: `Windows needs Remote Desktop turned on (Settings › System › Remote Desktop; Pro, Enterprise or Server).
84
- Behind an SSH host, the address is as that host sees it: from there, <code>nc -z &lt;address&gt; 3389</code>
85
- should succeed. For a VM or machine that may be off, give the desktop a VM name or MAC address in its edit form
86
- and it is started when opened.`,
87
57
  },
88
58
  {
89
59
  id: 'reconnecting',
90
60
  title: 'It keeps reconnecting',
91
61
  match: /reconnect|connection lost/i,
92
- body: `After sleep or a network change the desktop reconnects by itself, with growing pauses, and waits while the
93
- SSH connection is down (<b>Reconnect SSH</b> hurries that along). It stops after a few tries or on
94
- <b>Stop</b>. If it drops again right after connecting, check the host's free memory and that the desktop's
95
- session is still running there.`,
96
62
  },
97
63
  {
98
64
  id: 'picture',
99
65
  title: 'The picture is blurry or slow',
100
66
  match: /H\.264|decod|video/i,
101
- body: `<b>Sharpness › Retina</b> renders at your screen's device pixels, with the remote's scale set to match. Video
102
- (H.264) is used when the remote sends it: Windows does, GNOME only with a hardware encoder (VA-API or NVENC).
103
- <b>Show connection status</b> shows the frame rate, the round trip and how the picture comes.`,
104
67
  },
105
68
  {
106
69
  id: 'sound',
107
70
  title: 'No sound, or the microphone isn\'t heard',
108
- body: `Sound and microphone changes apply on the next connection. xrdp needs <code>pipewire-module-xrdp</code> or
109
- <code>pulseaudio-module-xrdp</code>. The microphone needs the system's permission here (macOS: System Settings ›
110
- Privacy &amp; Security › Microphone, then restart Tabby). On GNOME, apps record from "Remoteaudio Source"; on
111
- Windows, the machine must allow audio recording redirection. A desktop whose microphone was stopped (<b>Stop</b>
112
- in the microphone's menu in Tabby's header, or <b>Send the microphone</b> unchecked in the desktop's menu) gets
113
- none, its reconnects included, until <b>Send the microphone</b> is checked again in its menu or that Tabby window is closed.`,
114
71
  },
115
72
  {
116
73
  id: 'nested-ssh',
117
74
  title: 'I typed ssh to another machine in a terminal',
118
- body: `In an SSH tab (or a split pane) where you typed <code>ssh</code> on to another machine, <b>Desktop</b> opens that
119
- machine's desktop, going through the first one, and <code>desk</code> works there too. Desktop asks which desktop
120
- you mean, since which <code>ssh</code> runs there is the first machine's to say; pick the other machine's
121
- "from now on" to have it open at once the next times. Desktops you set up behind a host open in a tab connected
122
- to that host, not through another one.
123
- The first machine has to log in to the other by itself for this: with a key there, or agent forwarding, as
124
- <code>ssh -o BatchMode=yes</code> would. Tabby's <b>Reconnect</b> belongs to the tab: it reconnects the first
125
- machine and ends the ssh typed in it.
126
- To have both desktops side by side, open the first machine's desktop in one pane, and ssh on in the other.`,
127
75
  },
128
76
  {
129
77
  id: 'extra-monitor',
130
78
  title: 'The desktop is open somewhere else',
131
79
  match: /open somewhere else|opened somewhere else/,
132
- body: `GNOME Remote Desktop's headless mode gives each connection a screen of its own: a second computer, or another
133
- Tabby window, connected to the same account gets an empty extra screen rather than the same one. So when the
134
- desktop is open somewhere else already, you choose. <b>Take it over</b> disconnects the other connection, as
135
- Windows does; the session and its apps carry on, on this screen. <b>Open a second screen</b> keeps both, each
136
- with a screen of its own. A desktop taken over from here doesn't take itself back: it says so, and offers
137
- the same two choices.`,
138
80
  },
139
81
  ];
140
82
  /** The troubleshooting entry that explains a message, if one does. */
@@ -173,10 +115,11 @@ const TIP_STYLE = `
173
115
  * connects. Nothing else pops up.
174
116
  */
175
117
  let RemoteDesktopHelp = class RemoteDesktopHelp {
176
- constructor(app, config, zone) {
118
+ constructor(app, config, zone, platform) {
177
119
  this.app = app;
178
120
  this.config = config;
179
121
  this.zone = zone;
122
+ this.platform = platform;
180
123
  /** A section to show; the open settings page follows it (see settingsPage.ts). */
181
124
  this.show$ = new rxjs_1.Subject();
182
125
  /** For a settings page not yet made: where to scroll once it is. */
@@ -187,6 +130,11 @@ let RemoteDesktopHelp = class RemoteDesktopHelp {
187
130
  }
188
131
  /** Opens Tabby's settings at the plugin's page, at `topic` (and a troubleshooting entry). */
189
132
  open(topic = 'start', entry) {
133
+ if (topic === 'troubleshooting') {
134
+ const anchor = exports.TROUBLESHOOTING.some(e => e.id === entry) ? `#${entry}` : '';
135
+ this.platform.openExternal(exports.TROUBLESHOOTING_URL + anchor);
136
+ return;
137
+ }
190
138
  this.zone.run(() => {
191
139
  this.pending = { topic, entry };
192
140
  this.openSettings(exports.SETTINGS_TAB_ID);
@@ -280,5 +228,5 @@ let RemoteDesktopHelp = class RemoteDesktopHelp {
280
228
  exports.RemoteDesktopHelp = RemoteDesktopHelp;
281
229
  exports.RemoteDesktopHelp = RemoteDesktopHelp = __decorate([
282
230
  (0, core_1.Injectable)({ providedIn: 'root' }),
283
- __metadata("design:paramtypes", [tabby_core_1.AppService, tabby_core_1.ConfigService, core_1.NgZone])
231
+ __metadata("design:paramtypes", [tabby_core_1.AppService, tabby_core_1.ConfigService, core_1.NgZone, tabby_core_1.PlatformService])
284
232
  ], RemoteDesktopHelp);
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HostCompatibility = void 0;
4
+ exports.terminalInputs = terminalInputs;
5
+ exports.createHostCompatibility = createHostCompatibility;
6
+ function text(value) {
7
+ return typeof value === 'string' && value.trim() ? value.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 256) || null : null;
8
+ }
9
+ function read(source) {
10
+ try {
11
+ return source?.();
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ /** Ranges spanning multiple majors, URLs and workspace aliases cannot establish a single major. */
18
+ function declaredMajor(declaration) {
19
+ const match = declaration?.match(/^[~^]?(\d+)(?:\.(?:\d+|x|\*)(?:\.(?:\d+|x|\*))?)?(?:-[\da-z.-]+)?(?:\+[\da-z.-]+)?$/i);
20
+ const major = match ? Number(match[1]) : NaN;
21
+ return Number.isSafeInteger(major) ? major : null;
22
+ }
23
+ /** Shared terminal DOM adapter; keep desktop layers and forms in agreement about the input they cover. */
24
+ function terminalInputs(container) {
25
+ return Array.from(container.querySelectorAll('textarea.xterm-helper-textarea'));
26
+ }
27
+ /** An Angular factory provides one instance per plugin window; this module itself needs no Angular runtime. */
28
+ class HostCompatibility {
29
+ constructor(sources) {
30
+ this.nativeModalDragRegion = null;
31
+ const xtermDeclaration = text(read(() => {
32
+ const terminal = sources.terminalPackage?.();
33
+ return text(terminal?.dependencies?.['@xterm/xterm']) ?? text(terminal?.devDependencies?.['@xterm/xterm']);
34
+ }));
35
+ this.info = Object.freeze({
36
+ name: text(read(sources.name)), version: text(read(sources.version)), platform: text(sources.platform),
37
+ electron: text(sources.electron), node: text(sources.node), xtermDeclaration,
38
+ xtermMajor: declaredMajor(xtermDeclaration),
39
+ });
40
+ this.modalStyle = sources.modalStyle ?? ((element, pseudo) => getComputedStyle(element, pseudo));
41
+ }
42
+ /** Probe each modal; themes and UI scale can change while the application is running. */
43
+ hasNativeModalDragRegion(modal) {
44
+ let supported = false;
45
+ try {
46
+ const style = this.modalStyle(modal, '::before');
47
+ supported = style.content !== 'none' && style.content !== 'normal' && style.display !== 'none' &&
48
+ style.getPropertyValue('-webkit-app-region') === 'drag' && parseFloat(style.height) > 0;
49
+ }
50
+ catch { }
51
+ this.nativeModalDragRegion = supported;
52
+ return supported;
53
+ }
54
+ snapshot(pane) {
55
+ return {
56
+ ...this.info,
57
+ capabilities: {
58
+ nativeModalDragRegion: this.nativeModalDragRegion,
59
+ terminalInput: pane ? terminalInputs(pane).length > 0 : null,
60
+ },
61
+ };
62
+ }
63
+ /** Local build information and observed capabilities only; no config, accounts, paths or connection details. */
64
+ diagnosticLines(pane) {
65
+ const info = this.snapshot(pane);
66
+ const { nativeModalDragRegion, terminalInput } = info.capabilities;
67
+ const drag = nativeModalDragRegion === null ? 'unobserved' : nativeModalDragRegion ? 'host' : 'plugin fallback';
68
+ const input = terminalInput === null ? 'unobserved' : terminalInput ? 'available' : 'unavailable';
69
+ return [
70
+ `Host: ${info.name ?? 'unknown'} ${info.version ?? 'unknown'} on ${info.platform ?? 'unknown'}`,
71
+ `Runtime: Electron ${info.electron ?? 'unknown'}, Node ${info.node ?? 'unknown'}`,
72
+ `Terminal: xterm ${info.xtermDeclaration ?? 'unknown'} (host declaration)`,
73
+ `Compatibility: modal drag region ${drag}; terminal input ${input}`,
74
+ ];
75
+ }
76
+ }
77
+ exports.HostCompatibility = HostCompatibility;
78
+ /** Prefer Tabby's public version API; optional metadata reads never become requirements for operating a desktop. */
79
+ function createHostCompatibility(platform) {
80
+ return new HostCompatibility({
81
+ name: () => require('@electron/remote').app.getName(),
82
+ version: () => platform.getAppVersion(),
83
+ platform: process.platform, electron: process.versions.electron, node: process.versions.node,
84
+ terminalPackage: () => require('tabby-terminal/package.json'),
85
+ });
86
+ }
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ const desk_1 = require("./desk");
18
18
  const header_1 = require("./header");
19
19
  const keyboard_1 = require("./keyboard");
20
20
  const help_1 = require("./help");
21
+ const hostCompat_1 = require("./hostCompat");
21
22
  const signin_1 = require("./signin");
22
23
  const rdpFile_1 = require("./rdpFile");
23
24
  const rdpProfile_1 = require("./rdpProfile");
@@ -28,7 +29,8 @@ const targets_1 = require("./targets");
28
29
  const ui_1 = require("./ui");
29
30
  let RemoteDesktopModule = class RemoteDesktopModule {
30
31
  constructor(app, hotkeys, desktop, header, targets, keyboard, injector) {
31
- (0, desktop_service_1.installStyle)();
32
+ const compat = injector.get(hostCompat_1.HostCompatibility);
33
+ (0, desktop_service_1.installStyle)(compat);
32
34
  header.install();
33
35
  // Passwords go to Tabby's Vault while it is enabled (as Tabby's SSH passwords do), else to the system keychain.
34
36
  try {
@@ -62,12 +64,13 @@ let RemoteDesktopModule = class RemoteDesktopModule {
62
64
  });
63
65
  // Handle for tests and troubleshooting from DevTools.
64
66
  const w = window;
65
- w.__remoteDesktop = { app, desktop, targets, desktopPaneOf: targets_1.desktopPaneOf, injector, execRemote: ssh_1.execRemote, parseRdpFile: rdpFile_1.parseRdpFile, help: injector.get(help_1.RemoteDesktopHelp), updates: injector.get(updates_1.UpdateCheck) };
67
+ w.__remoteDesktop = { app, desktop, targets, desktopPaneOf: targets_1.desktopPaneOf, injector, compat, execRemote: ssh_1.execRemote, parseRdpFile: rdpFile_1.parseRdpFile, help: injector.get(help_1.RemoteDesktopHelp), updates: injector.get(updates_1.UpdateCheck) };
66
68
  }
67
69
  };
68
70
  RemoteDesktopModule = __decorate([
69
71
  (0, core_1.NgModule)({
70
72
  providers: [
73
+ { provide: hostCompat_1.HostCompatibility, useFactory: hostCompat_1.createHostCompatibility, deps: [tabby_core_1.PlatformService] },
71
74
  { provide: tabby_core_1.HotkeyProvider, useClass: ui_1.RemoteDesktopHotkeys, multi: true },
72
75
  { provide: tabby_core_1.ConfigProvider, useClass: ui_1.RemoteDesktopConfig, multi: true },
73
76
  { provide: tabby_core_1.TabContextMenuItemProvider, useClass: ui_1.RemoteDesktopContextMenu, multi: true },
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IronRDPLoader = void 0;
4
+ /** Loads and compiles once; every connection attempt gets fresh glue, classes, memory and allocator. */
5
+ class IronRDPLoader {
6
+ constructor(load) {
7
+ this.load = load;
8
+ this.source = null;
9
+ }
10
+ async create(level) {
11
+ const pending = this.source ??= this.load();
12
+ let source;
13
+ try {
14
+ source = await pending;
15
+ }
16
+ catch (error) {
17
+ if (this.source === pending) {
18
+ this.source = null;
19
+ }
20
+ throw error;
21
+ }
22
+ // A failed/trapped instance does not invalidate shared compiled code or another desktop's instance.
23
+ return source.createBackend(source.module, level);
24
+ }
25
+ }
26
+ exports.IronRDPLoader = IronRDPLoader;
package/dist/keyboard.js CHANGED
@@ -117,7 +117,18 @@ let DesktopKeyboard = class DesktopKeyboard {
117
117
  route(event) {
118
118
  const pane = this.startedOn.has(event) ? this.startedOn.get(event) : this.coveredPane();
119
119
  const focused = document.activeElement;
120
- if (!pane || focused?.tagName !== 'IRON-REMOTE-DESKTOP' || !pane.element.nativeElement.contains(focused)) {
120
+ if (focused?.tagName !== 'IRON-REMOTE-DESKTOP') {
121
+ return;
122
+ }
123
+ if (!pane || !pane.element.nativeElement.contains(focused)) {
124
+ // A desktop has the focus though its pane isn't the one the user is in (see
125
+ // RemoteDesktopService.letGoUnlessInFront, which takes it back a moment after the pane is left): IronRDP,
126
+ // whose listener comes after this one and only looks at the focus, would send it the key, view only or not.
127
+ // It goes nowhere instead, a keyup too: IronRDP lets go of nothing as its canvas loses the focus (only as
128
+ // the window does, or as the pointer leaves the canvas), so the keys the desktop still holds on the remote
129
+ // are let go of as the focus is taken back, this one among them.
130
+ event.stopImmediatePropagation();
131
+ event.preventDefault();
121
132
  return;
122
133
  }
123
134
  this.routing = pane;