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/CHANGELOG.md +75 -0
- package/README.md +41 -11
- package/TROUBLESHOOTING.md +82 -0
- package/dist/accountRevisions.js +46 -0
- package/dist/accounts.js +5 -1
- package/dist/connectionStatus.js +21 -4
- package/dist/desktop.service.js +296 -96
- package/dist/desktopForm.js +5 -4
- package/dist/diskStorage.js +212 -0
- package/dist/fileTransfer.js +6 -53
- package/dist/help.js +10 -62
- package/dist/hostCompat.js +86 -0
- package/dist/index.js +5 -2
- package/dist/ironrdp.js +26 -0
- package/dist/keyboard.js +12 -1
- package/dist/packageChangeDialog.js +186 -0
- package/dist/rdcleanpath.js +17 -4
- package/dist/rdpFile.js +13 -4
- package/dist/rdpProfile.js +44 -14
- package/dist/releaseSettings.js +72 -0
- package/dist/releases.js +411 -0
- package/dist/settingsPage.js +110 -115
- package/dist/signin.js +73 -10
- package/dist/targets.js +9 -1
- package/dist/updates.js +77 -89
- package/docs/desk.md +157 -0
- package/package.json +11 -2
- package/vendor/SHA256SUMS +1 -0
- package/vendor/ironrdp-factory.js +3237 -0
package/dist/desktopForm.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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;
|
package/dist/fileTransfer.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.FileTransfer = exports.STYLE = exports.
|
|
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 <address> 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 & 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
|
-
|
|
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 },
|
package/dist/ironrdp.js
ADDED
|
@@ -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 (
|
|
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;
|