tabby-rdp 0.4.1 → 0.5.1

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +580 -0
  2. package/README.md +252 -34
  3. package/dist/accountForm.js +0 -1
  4. package/dist/accounts.js +0 -1
  5. package/dist/audio.js +0 -1
  6. package/dist/authorities.js +331 -0
  7. package/dist/byteQueue.js +102 -0
  8. package/dist/clipboard.js +106 -0
  9. package/dist/connectionStatus.js +0 -1
  10. package/dist/desk.js +16 -47
  11. package/dist/deskScript.js +196 -6
  12. package/dist/desktop.service.js +1763 -204
  13. package/dist/desktopForm.js +72 -13
  14. package/dist/desktops.js +243 -19
  15. package/dist/drives.js +525 -0
  16. package/dist/fileTransfer.js +1064 -72
  17. package/dist/gateway.js +651 -0
  18. package/dist/header.js +76 -5
  19. package/dist/help.js +34 -13
  20. package/dist/hyperv.js +129 -0
  21. package/dist/index.js +2 -2
  22. package/dist/keyboard.js +0 -1
  23. package/dist/microphone.js +57 -13
  24. package/dist/ntlm.js +225 -0
  25. package/dist/osd.js +56 -15
  26. package/dist/probe.js +15 -3
  27. package/dist/rdcleanpath.js +468 -52
  28. package/dist/rdpFile.js +53 -4
  29. package/dist/rdpProfile.js +75 -22
  30. package/dist/remoteSetup.js +103 -23
  31. package/dist/sessionLog.js +53 -0
  32. package/dist/settingsPage.js +117 -27
  33. package/dist/signin.js +429 -54
  34. package/dist/ssh.js +99 -18
  35. package/dist/sshChannelStream.js +50 -6
  36. package/dist/targets.js +185 -39
  37. package/dist/ui.js +98 -20
  38. package/dist/unshowable.js +41 -0
  39. package/dist/updates.js +37 -2
  40. package/dist/vms.js +36 -5
  41. package/dist/wake.js +31 -12
  42. package/package.json +4 -3
  43. package/remote/trd-pty.py +74 -13
  44. package/vendor/SHA256SUMS +26 -0
  45. package/vendor/iron-remote-desktop-rdp.js +418 -390
  46. package/vendor/ironrdp_web_bg.wasm +0 -0
  47. package/dist/accountForm.js.map +0 -1
  48. package/dist/accounts.js.map +0 -1
  49. package/dist/audio.js.map +0 -1
  50. package/dist/connectionStatus.js.map +0 -1
  51. package/dist/desk.js.map +0 -1
  52. package/dist/deskScript.js.map +0 -1
  53. package/dist/desktop.service.js.map +0 -1
  54. package/dist/desktopForm.js.map +0 -1
  55. package/dist/desktops.js.map +0 -1
  56. package/dist/fileTransfer.js.map +0 -1
  57. package/dist/header.js.map +0 -1
  58. package/dist/help.js.map +0 -1
  59. package/dist/index.js.map +0 -1
  60. package/dist/keyboard.js.map +0 -1
  61. package/dist/microphone.js.map +0 -1
  62. package/dist/osd.js.map +0 -1
  63. package/dist/probe.js.map +0 -1
  64. package/dist/rdcleanpath.js.map +0 -1
  65. package/dist/rdpFile.js.map +0 -1
  66. package/dist/rdpProfile.js.map +0 -1
  67. package/dist/remoteSetup.js.map +0 -1
  68. package/dist/settingsPage.js.map +0 -1
  69. package/dist/signin.js.map +0 -1
  70. package/dist/ssh.js.map +0 -1
  71. package/dist/sshChannelStream.js.map +0 -1
  72. package/dist/targets.js.map +0 -1
  73. package/dist/ui.js.map +0 -1
  74. package/dist/updates.js.map +0 -1
  75. package/dist/vms.js.map +0 -1
  76. package/dist/wake.js.map +0 -1
@@ -1,15 +1,77 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.FileTransfer = exports.STYLE = void 0;
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;
4
4
  exports.clipboardPaths = clipboardPaths;
5
+ exports.entriesFor = entriesFor;
6
+ exports.safeName = safeName;
5
7
  exports.savePath = savePath;
8
+ exports.newFolders = newFolders;
6
9
  exports.uniquePath = uniquePath;
10
+ exports.foldersInside = foldersInside;
11
+ exports.place = place;
12
+ exports.markDownloaded = markDownloaded;
7
13
  const fs = require("fs");
8
14
  const os = require("os");
9
15
  const path = require("path");
10
- /** Limits for a copied folder, as for a dropped one in IronRDP. */
11
- const MAX_ENTRIES = 10000;
16
+ const child_process_1 = require("child_process");
17
+ /**
18
+ * Limits for a folder copied here and pasted with ⌘V: entries offered, and how deep. (IronRDP walks dropped folders
19
+ * itself, with limits of its own.)
20
+ */
21
+ exports.MAX_ENTRIES = 10000;
12
22
  const MAX_DEPTH = 20;
23
+ /**
24
+ * What the walk of copied folders may look at to find those entries, whatever becomes of it (offered, links left out,
25
+ * pipes): names read and folders opened, and links resolved, which cost more each. The walk is synchronous, in Tabby's
26
+ * window (⌘V must know at once whether files are offered for it), so a folder of a million names, or of links that
27
+ * lead out of it, mustn't hold the window up for long. Past either limit, nothing more is looked at.
28
+ */
29
+ exports.MAX_LOOKED_AT = 2 * exports.MAX_ENTRIES;
30
+ exports.MAX_LINKS = 2000;
31
+ /**
32
+ * The most of a file sent for one request of the remote's. It asks for a range at a time, as much as a read on its
33
+ * side wants, but may ask for up to 4 GB at once, and each range is held whole in memory to be sent. A request for
34
+ * more of a file than this gets an error, not less than it asked for: a remote can take a short answer for the end of
35
+ * the file, and keep the file cut short without a word. xrdp and GNOME Remote Desktop read pasted files through FUSE,
36
+ * which takes a short answer for the end of the file, and reads up to 256 pages at once: 1 MB with 4 KB pages, 16 MB
37
+ * where a page is 64 KB (some ARM Linux).
38
+ */
39
+ exports.MAX_RANGE = 16 * 1024 * 1024;
40
+ /**
41
+ * File contents a desktop's remote may have asked for and not been sent yet: more requests wait their turn (at most
42
+ * MAX_WAITING of them; more get an error). Each answer is read whole into memory, and the remote may ask for any
43
+ * number at once: 128 requests for 16 MB each held 2 GB. A remote reading a pasted file asks for a part, or a few, at a
44
+ * time. An answer counts until it is handed to the connection, and takes room there from before it is read until the
45
+ * proxy has read it (see ServerFlow): no part is read for the remote while there is none.
46
+ */
47
+ exports.MAX_IN_FLIGHT = 4 * exports.MAX_RANGE;
48
+ exports.MAX_WAITING = 256;
49
+ /**
50
+ * What a request counts as towards MAX_IN_FLIGHT, however little it asks for: each one opens the file and reads it,
51
+ * and requests for nothing at all mustn't pile up in any number.
52
+ */
53
+ exports.MIN_COST = 64 * 1024;
54
+ /**
55
+ * How long a request the provider has may stay unanswered before it no longer counts towards MAX_IN_FLIGHT: a read
56
+ * that takes a minute is answered with an error, and one that is dropped (the remote's clipboard changed, say) may not
57
+ * be answered at all. What it answers for such a request later is for no request (see FileTransfer's constructor). A
58
+ * pasted file's part still being read here counts until it has been read (see FileTransfer.fetch).
59
+ */
60
+ const READ_EXPIRES = 65 * 1000;
61
+ /** FILECONTENTS_SIZE and FILECONTENTS_RANGE: a file's size, or part of what is in it. */
62
+ const SIZE = 1;
63
+ const RANGE = 2;
64
+ /**
65
+ * How much of a file (`size` bytes) a request for `start` to `end` gets: what there is of it, as long as that isn't
66
+ * more than MAX_RANGE; else the request is refused (see there).
67
+ */
68
+ function rangeOf(name, size, start, end) {
69
+ const length = Math.max(0, Math.min(end, size) - start);
70
+ if (length > exports.MAX_RANGE) {
71
+ throw new RangeError(`the remote desktop asked for ${Math.ceil(length / 1024 / 1024)} MB of ${name} at once, more than the ${exports.MAX_RANGE / 1024 / 1024} MB a request gets`);
72
+ }
73
+ return length;
74
+ }
13
75
  /**
14
76
  * Files copied in Finder (Explorer, Files): their paths, from the local clipboard. Empty when it holds no files.
15
77
  */
@@ -45,61 +107,349 @@ function clipboardPaths() {
45
107
  const list = read('x-special/gnome-copied-files') || read('text/uri-list');
46
108
  return list.split(/\r?\n/).filter(l => l.startsWith('file://')).map(l => decodeURIComponent(new URL(l).pathname));
47
109
  }
110
+ /**
111
+ * How a pasted file is opened to read a part of it. It is the file that was offered, not whatever has its name now:
112
+ * the remote can ask again much later, and a link put in its place meanwhile isn't followed, nor a pipe waited on
113
+ * (opened to read, a pipe waits for a writer).
114
+ */
115
+ const READ_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
48
116
  /**
49
117
  * A file on disk, for IronRDP's upload: it reads `slice(start, end)` of a file as the remote asks for each range, so
50
- * only that range is read, not the whole file up front.
118
+ * only that range is read, not the whole file up front (and no more than MAX_RANGE of it at a time). FileTransfer
119
+ * reads each range ahead, on Node's thread pool (read), and hands it over as the provider slices (serving).
51
120
  */
52
121
  class DiskFile {
53
- constructor(file, name, size, lastModified) {
122
+ constructor(file, stat, name) {
54
123
  this.file = file;
55
124
  this.name = name;
56
- this.size = size;
57
- this.lastModified = lastModified;
58
125
  this.type = '';
126
+ /** The part read ahead for the request being answered (see serving). */
127
+ this.ready = null;
128
+ this.size = stat.size;
129
+ this.lastModified = stat.mtimeMs;
130
+ this.dev = stat.dev;
131
+ this.ino = stat.ino;
132
+ }
133
+ /**
134
+ * Throws unless the file opened (`now`: its stat) is the one offered: a file still (a file removed leaves its inode
135
+ * number free, and a pipe made in its place can get it), on the same device, with the same inode.
136
+ */
137
+ check(now) {
138
+ if (!now.isFile() || now.dev !== this.dev || now.ino !== this.ino) {
139
+ throw new Error(`${this.name} was replaced after it was offered`);
140
+ }
141
+ }
142
+ /**
143
+ * The file ended before the part asked for, which its size when it was offered says is there. The remote gets an
144
+ * error, not a short answer, which it could take for the end of the file and keep the file cut short.
145
+ */
146
+ shorter() {
147
+ throw new Error(`${this.name} is shorter than when it was offered`);
148
+ }
149
+ /**
150
+ * Reads what `slice(start, end)` gives, with the same checks, on Node's thread pool: a slow disk or network folder
151
+ * holds up the transfer, not Tabby's window.
152
+ */
153
+ async read(start, end) {
154
+ const length = rangeOf(this.name, this.size, start, end);
155
+ const file = await fs.promises.open(this.file, READ_FLAGS);
156
+ try {
157
+ this.check(await file.stat());
158
+ const buffer = Buffer.allocUnsafe(length);
159
+ let done = 0;
160
+ while (done < length) {
161
+ const { bytesRead } = await file.read(buffer, done, length - done, start + done);
162
+ if (!bytesRead) {
163
+ this.shorter();
164
+ }
165
+ done += bytesRead;
166
+ }
167
+ return buffer;
168
+ }
169
+ finally {
170
+ await file.close().catch(() => null);
171
+ }
172
+ }
173
+ /** While `fn` runs, `slice(start, end)` is `data`, read for it (IronRDP's provider slices as it answers). */
174
+ serving(start, end, data, fn) {
175
+ this.ready = { start, end, data };
176
+ try {
177
+ return fn();
178
+ }
179
+ finally {
180
+ this.ready = null;
181
+ }
59
182
  }
60
183
  slice(start = 0, end = this.size) {
61
- const length = Math.max(0, Math.min(end, this.size) - start);
62
- const buffer = Buffer.alloc(length);
63
- const fd = fs.openSync(this.file, 'r');
184
+ const ready = this.ready;
185
+ if (ready && ready.start === start && ready.end === end) {
186
+ this.ready = null;
187
+ return new Blob([ready.data]);
188
+ }
189
+ // Not read ahead (the provider only slices as FileTransfer has it answer): read now, in the window.
190
+ const length = rangeOf(this.name, this.size, start, end);
191
+ const buffer = Buffer.allocUnsafe(length);
192
+ const fd = fs.openSync(this.file, READ_FLAGS);
64
193
  try {
194
+ this.check(fs.fstatSync(fd));
65
195
  let done = 0;
66
196
  while (done < length) {
67
197
  const n = fs.readSync(fd, buffer, done, length - done, start + done);
68
198
  if (!n) {
69
- break;
199
+ this.shorter();
70
200
  }
71
201
  done += n;
72
202
  }
73
- return new Blob([buffer.subarray(0, done)]);
203
+ return new Blob([buffer]);
74
204
  }
75
205
  finally {
76
206
  fs.closeSync(fd);
77
207
  }
78
208
  }
79
209
  }
80
- /** IronRDP's DroppedFile entries for paths on disk: files, and folders with everything in them (`path`: the folder, `\`-separated). */
210
+ /**
211
+ * A file dropped on the desktop or picked from the menu (the browser's own File), as IronRDP's upload reads it: a
212
+ * part at a time as the remote asks, and no more than MAX_RANGE per request, as for files pasted (DiskFile). The
213
+ * browser reads it, outside Tabby's window, but the part is held in memory all the same.
214
+ */
215
+ class BoundedFile {
216
+ constructor(file, name, lastModified) {
217
+ this.file = file;
218
+ this.name = name;
219
+ this.lastModified = lastModified;
220
+ }
221
+ get size() {
222
+ return this.file.size;
223
+ }
224
+ get type() {
225
+ return this.file.type;
226
+ }
227
+ slice(start = 0, end = this.size) {
228
+ rangeOf(this.name, this.size, start, end);
229
+ return this.file.slice(start, end);
230
+ }
231
+ }
232
+ /**
233
+ * IronRDP's DroppedFile entries for files to send: the browser's files as entries, as its provider would make them
234
+ * of a picked list, each read no more than MAX_RANGE at a time (BoundedFile; files pasted are DiskFiles already).
235
+ */
236
+ function bounded(files) {
237
+ return files.map(f => {
238
+ const entry = f && 'file' in f ? f : { file: f, name: f.name, size: f.size, lastModified: f.lastModified };
239
+ return entry.file && !(entry.file instanceof DiskFile) ? { ...entry, file: new BoundedFile(entry.file, entry.name, entry.lastModified) } : entry;
240
+ });
241
+ }
242
+ /**
243
+ * A path's real path, links all resolved, for the walk of copied folders. The system's own resolves a link that leads
244
+ * through others (up to the 32 or 40 a system follows) several times quicker than Node's, which counts where a copied
245
+ * folder is full of such links. Not on Windows, where it can name a folder otherwise than Node's (a mapped drive by its
246
+ * share); the walk's paths must all be named one way.
247
+ */
248
+ function realpath(file) {
249
+ return process.platform === 'win32' ? fs.realpathSync(file) : fs.realpathSync.native(file);
250
+ }
251
+ /** Where a link leads, links all resolved; null if nowhere (it dangles, or goes round). */
252
+ function resolved(file) {
253
+ try {
254
+ return realpath(file);
255
+ }
256
+ catch {
257
+ return null;
258
+ }
259
+ }
260
+ /** The same file or folder, by device and inode (not by name, which another can take). */
261
+ const same = (a, b) => !!a && a.dev === b.dev && a.ino === b.ino;
262
+ /**
263
+ * Up to `most` names in a folder, in order, and whether it has more. Read a few at a time, so a folder of a million
264
+ * names isn't read whole for the first few.
265
+ */
266
+ function namesIn(folder, most) {
267
+ const dir = fs.opendirSync(folder);
268
+ try {
269
+ const names = [];
270
+ for (let entry = dir.readSync(); entry; entry = dir.readSync()) {
271
+ if (names.length >= most) {
272
+ return { names: names.sort(), more: true };
273
+ }
274
+ names.push(entry.name);
275
+ }
276
+ return { names: names.sort(), more: false };
277
+ }
278
+ finally {
279
+ dir.closeSync();
280
+ }
281
+ }
282
+ /**
283
+ * IronRDP's DroppedFile entries for paths on disk: files, and folders with everything in them (`path`: the folder,
284
+ * `\`-separated). The paths copied are taken as they are, links followed. Inside a copied folder, a link is followed
285
+ * only when it leads somewhere in that folder (and not to a folder it is in, which would go round and round), as a
286
+ * shared folder's links are (drives.ts): a folder from an archive or a repository can hold a link to anywhere
287
+ * (`attachments -> ../../.ssh`), and copying the folder isn't sending what that leads to. `leftOut` counts such links;
288
+ * those that lead nowhere (they dangle, or go round), and pipes and the like, have nothing to send and aren't counted.
289
+ *
290
+ * The walk stops at its limits (MAX_ENTRIES and MAX_DEPTH, MAX_LOOKED_AT and MAX_LINKS), looking at nothing more,
291
+ * and `cut` says something was left out for it. Each folder is checked to still be the one found once its names are
292
+ * read: one put in its place meanwhile (a link to elsewhere, say) would have had its names read, and the files they
293
+ * are found to be would be sent; the walk then stops with an error. Paths are still taken by name, though (Node can't
294
+ * read a folder through what it opened), so a program on this computer swapping folders while the walk runs, at the
295
+ * right moments, can get past that; it could read such files itself.
296
+ */
81
297
  function entriesFor(paths) {
82
298
  const entries = [];
83
- const walk = (file, parent, depth) => {
84
- if (entries.length >= MAX_ENTRIES || depth > MAX_DEPTH) {
299
+ let leftOut = 0;
300
+ let cut = false;
301
+ // What the walk has looked at: names read, and folders opened (see MAX_LOOKED_AT); links resolved (MAX_LINKS).
302
+ let looked = 0;
303
+ let links = 0;
304
+ const full = () => entries.length >= exports.MAX_ENTRIES || looked >= exports.MAX_LOOKED_AT;
305
+ // A folder and what is in it. `real`: where it is, links resolved; `stat`: what was found there; `root`: the
306
+ // copied folder's real path; `above`: the real paths of the folders it is in, from that one down.
307
+ const folder = (name, real, stat, root, above, parent, depth) => {
308
+ // Past the limits, a folder is neither opened nor offered: what is in it would be past them too.
309
+ if (full()) {
310
+ cut = true;
85
311
  return;
86
312
  }
87
- const stat = fs.statSync(file, { throwIfNoEntry: false });
88
- const name = path.basename(file);
313
+ entries.push({ file: null, name, size: 0, lastModified: 0, path: parent, isDirectory: true });
314
+ looked++;
315
+ if (depth >= MAX_DEPTH) {
316
+ // What is in it would be deeper than the limit.
317
+ cut ||= namesIn(real, 0).more;
318
+ return;
319
+ }
320
+ const inner = parent !== undefined ? `${parent}\\${name}` : name;
321
+ const outer = [...above, real];
322
+ const { names, more } = namesIn(real, exports.MAX_LOOKED_AT - looked);
323
+ looked += names.length;
324
+ cut ||= more;
325
+ // What each name is, as long as entries could still be offered for it.
326
+ const found = [];
327
+ for (const child of names) {
328
+ if (entries.length + found.length >= exports.MAX_ENTRIES) {
329
+ cut = true;
330
+ break;
331
+ }
332
+ let at = path.join(real, child);
333
+ let what = fs.lstatSync(at, { throwIfNoEntry: false });
334
+ if (what?.isSymbolicLink()) {
335
+ if (links >= exports.MAX_LINKS) {
336
+ cut = true;
337
+ continue;
338
+ }
339
+ links++;
340
+ const target = resolved(at);
341
+ if (!target) {
342
+ continue;
343
+ }
344
+ if (target !== root && !target.startsWith(root.endsWith(path.sep) ? root : root + path.sep)) {
345
+ leftOut++;
346
+ continue;
347
+ }
348
+ if (outer.includes(target)) {
349
+ continue;
350
+ }
351
+ // Its links all resolved: a link there now was put there since, and isn't followed.
352
+ at = target;
353
+ what = fs.lstatSync(at, { throwIfNoEntry: false });
354
+ }
355
+ if (what && (what.isFile() || what.isDirectory())) {
356
+ found.push({ name: child, real: at, stat: what });
357
+ }
358
+ }
359
+ if (!same(fs.lstatSync(real, { throwIfNoEntry: false }), stat)) {
360
+ throw new Error(`${inner} changed while it was being read`);
361
+ }
362
+ for (const child of found) {
363
+ if (entries.length >= exports.MAX_ENTRIES) {
364
+ cut = true;
365
+ break;
366
+ }
367
+ if (child.stat.isFile()) {
368
+ entries.push({ file: new DiskFile(child.real, child.stat, child.name), name: child.name, size: child.stat.size, lastModified: child.stat.mtimeMs, path: inner });
369
+ }
370
+ else {
371
+ folder(child.name, child.real, child.stat, root, outer, inner, depth + 1);
372
+ }
373
+ }
374
+ };
375
+ for (const p of paths) {
376
+ if (full()) {
377
+ cut = true;
378
+ break;
379
+ }
380
+ looked++;
381
+ let real;
382
+ try {
383
+ real = realpath(p);
384
+ }
385
+ catch (e) {
386
+ // Gone since it was copied, or a link that leads nowhere: nothing to send, as for a link in a folder.
387
+ if (e?.code === 'ENOENT' || e?.code === 'ENOTDIR' || e?.code === 'ELOOP') {
388
+ continue;
389
+ }
390
+ throw e;
391
+ }
392
+ const stat = fs.lstatSync(real, { throwIfNoEntry: false });
89
393
  if (stat?.isFile()) {
90
- entries.push({ file: new DiskFile(file, name, stat.size, stat.mtimeMs), name, size: stat.size, lastModified: stat.mtimeMs, path: parent });
394
+ const name = path.basename(p);
395
+ entries.push({ file: new DiskFile(real, stat, name), name, size: stat.size, lastModified: stat.mtimeMs, path: undefined });
91
396
  }
92
397
  else if (stat?.isDirectory()) {
93
- entries.push({ file: null, name, size: 0, lastModified: 0, path: parent, isDirectory: true });
94
- const inner = parent !== undefined ? `${parent}\\${name}` : name;
95
- for (const child of fs.readdirSync(file).sort()) {
96
- walk(path.join(file, child), inner, depth + 1);
398
+ folder(path.basename(p), real, stat, real, [], undefined, 0);
399
+ }
400
+ }
401
+ return { entries, leftOut, cut };
402
+ }
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();
97
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);
98
449
  }
99
- };
100
- paths.forEach(p => walk(p, undefined, 0));
101
- return entries;
450
+ }
102
451
  }
452
+ exports.DiskStorage = DiskStorage;
103
453
  exports.STYLE = `
104
454
  .trd-toast { position: absolute; top: 12px; right: 12px; z-index: 2; max-width: min(420px, calc(100% - 24px));
105
455
  display: flex; gap: 10px; align-items: center; padding: 8px 10px; border-radius: 6px;
@@ -109,17 +459,31 @@ exports.STYLE = `
109
459
  .trd-overlay.trd-drop-target::after { content: 'Drop to copy to the remote desktop, then paste there'; position: absolute; inset: 0;
110
460
  display: flex; align-items: center; justify-content: center; background: rgba(40, 90, 160, 0.35); color: #fff; font-size: 15px;
111
461
  pointer-events: none; }
462
+ .trd-overlay.trd-drop-target.trd-drop-refused::after { content: "Files don't go to this desktop: its clipboard setting keeps them here";
463
+ background: rgba(90, 90, 90, 0.45); }
112
464
  `;
113
465
  const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
466
+ /** What a paste of copied folders left out (see entriesFor), for its messages: links, and whether limits cut it. */
467
+ function leftOutNote(leftOut, cut) {
468
+ return (leftOut ? `\nLeft out: ${plural(leftOut, 'link')} to outside the copied folder` : '')
469
+ + (cut ? `\nLeft out: what is past a paste's limits (${exports.MAX_ENTRIES.toLocaleString('en')} files and folders, ${MAX_DEPTH} folders deep)` : '');
470
+ }
114
471
  /**
115
472
  * Files through the clipboard, with IronRDP's RdpFileTransferProvider: files dropped on the desktop (or
116
473
  * picked) are offered on the remote clipboard, to paste there; files copied on the remote can be saved to
117
- * the Mac's Downloads folder. Messages show as a small bar over the desktop.
474
+ * the Mac's Downloads folder. Each goes only the ways the clipboard goes with this desktop (see clipboard.ts),
475
+ * asked each time, since the settings can narrow them while connected. Messages show as a small bar over the desktop.
118
476
  */
119
477
  class FileTransfer {
120
- constructor(rdp, overlay, log) {
478
+ /**
479
+ * `ways`: the ways the clipboard goes with this desktop right now (see RemoteDesktopService.clipboardWaysOf).
480
+ * `flow`: the room in the connection for what is read for the remote (the desktop's proxy, see ServerFlow).
481
+ */
482
+ constructor(rdp, overlay, log, ways, flow) {
121
483
  this.overlay = overlay;
122
484
  this.log = log;
485
+ this.ways = ways;
486
+ this.flow = flow;
123
487
  this.toastEl = null;
124
488
  /** Files the remote offered last (for tests: saveAll). */
125
489
  this.offered = [];
@@ -129,44 +493,389 @@ class FileTransfer {
129
493
  this.pendingPaste = null;
130
494
  /** Resolves when the remote has taken in the files last offered (its Format List Response): true if it accepted them. */
131
495
  this.offerTaken = null;
132
- this.provider = new rdp.RdpFileTransferProvider({ storageBackend: 'blob' });
496
+ /**
497
+ * Whether the last ⌘V was taken with nothing offered for it (see pasteClipboardFiles), the remote not even asked:
498
+ * offerAnswered's false then isn't the remote's.
499
+ */
500
+ this.nothingOffered = false;
501
+ /** Whether the remote was refused files offered before the clipboard narrowed (said once: it asks for each part). */
502
+ this.refused = false;
503
+ /** The entries last offered (see send), which the remote's requests name by index. */
504
+ this.sent = [];
505
+ /** Requests for parts of files being read for the remote and not answered yet (see MAX_IN_FLIGHT). */
506
+ this.reading = [];
507
+ /**
508
+ * The requests handed to the provider and not answered, by the number each is handed over as: one of the plugin's
509
+ * own, not the remote's number for its stream. The provider answers under the number it was given, and only an
510
+ * answer for a request in here goes to the remote (under its own number) and lets the room that request took go
511
+ * (see the constructor), so an answer is never taken for another request's, whatever the remote numbers its
512
+ * streams.
513
+ */
514
+ this.served = new Map();
515
+ /** The number the last request was handed to the provider as (see served). */
516
+ this.numbered = 0;
517
+ /** Requests for parts of files waiting for those (see MAX_WAITING). */
518
+ this.waiting = [];
519
+ /** Parts of the pasted files last offered being read, one at a time for those files (see fetch). */
520
+ this.disk = Promise.resolve();
521
+ /** Whether a request of the remote's for the files last offered went unanswered (said once: see unreadable). */
522
+ this.unanswered = false;
523
+ /** The connection ended (see dispose): parts still being read aren't answered. */
524
+ this.disposed = false;
525
+ /**
526
+ * Takes the overlay's drop listeners off again: the overlay outlives this (a reconnect makes a new FileTransfer
527
+ * on it). With removeEventListener, not an AbortSignal: zone.js keeps one native listener per event and its own
528
+ * list of handlers, and a signal removes the native one behind its back, so the next handlers never get attached.
529
+ */
530
+ this.stopListening = [];
531
+ /** Whether next() waits for room in the connection (see ServerFlow). */
532
+ this.roomAwaited = false;
533
+ /** Whether a request on a stream with one under way already was refused (logged once: see request). */
534
+ this.overlapping = false;
535
+ /** Whether an answer for a request that is no more was dropped (logged once: see the constructor). */
536
+ this.stray = false;
537
+ this.provider = new rdp.RdpFileTransferProvider({ storageBackend: new DiskStorage() });
538
+ // The remote can ask again, later, for files offered from here: their contents go only while this computer's
539
+ // clipboard goes there. The provider's extensions call the method on it, so this one takes its place.
540
+ this.serve = this.provider.handleFileContentsRequest.bind(this.provider);
541
+ this.provider.handleFileContentsRequest = (request) => this.ways().toRemote ? this.request(request) : this.refuse(request);
542
+ // The provider answers each request under the number it was handed it as (see read), and this goes with it to
543
+ // the connection under the remote's own number, and lets the room that request took go, which lets the requests
544
+ // waiting be read (see release). What it hands the connection takes room there as it goes (see ServerFlow). An
545
+ // answer for a request that is no more (gone with the files offered, expired, or the connection ended) is
546
+ // dropped: it goes to no one and releases nothing, so that it can't be taken for another request's, on the same
547
+ // stream or not. The plugin's own answers, errors for requests it
548
+ // doesn't hand over (see fail), go to the connection directly, and release what they are for themselves.
549
+ this.submit = this.provider.sendSubmitFileContents?.bind(this.provider);
550
+ if (this.submit) {
551
+ this.provider.sendSubmitFileContents = (number, isError, data) => {
552
+ const served = this.served.get(number);
553
+ if (!served) {
554
+ if (!this.disposed && !this.stray) {
555
+ this.stray = true;
556
+ this.log('files: an answer for a request of the remote\'s that is no more was dropped');
557
+ }
558
+ return;
559
+ }
560
+ this.served.delete(number);
561
+ this.submit(served.streamId, isError, data);
562
+ if (!isError && data?.length) {
563
+ this.flow?.spend(data.length);
564
+ }
565
+ if (served.reading) {
566
+ this.release(served.reading);
567
+ }
568
+ };
569
+ }
133
570
  this.provider.on('files-available', (files) => this.offer(files));
134
571
  this.provider.on('error', (e) => {
135
572
  this.log(`files: ${e?.message ?? JSON.stringify(e)}`);
136
573
  this.toast(`File transfer failed: ${e?.message ?? 'unknown error'}`);
137
574
  });
138
575
  // Drop files on the desktop to send them. Handled here so Tabby doesn't paste their paths into
139
- // the console under the desktop.
140
- overlay.addEventListener('dragover', event => {
576
+ // the console under the desktop, also where they don't go to the desktop (the layer says so).
577
+ const listen = (type, handler, capture = false) => {
578
+ overlay.addEventListener(type, handler, capture);
579
+ this.stopListening.push(() => overlay.removeEventListener(type, handler, capture));
580
+ };
581
+ listen('dragover', event => {
141
582
  if (event.dataTransfer?.types.includes('Files')) {
142
583
  event.preventDefault();
143
584
  event.stopPropagation();
144
585
  overlay.classList.add('trd-drop-target');
586
+ overlay.classList.toggle('trd-drop-refused', !this.ways().toRemote);
145
587
  }
146
588
  });
147
- overlay.addEventListener('dragleave', event => {
589
+ listen('dragleave', event => {
148
590
  if (!overlay.contains(event.relatedTarget)) {
149
- overlay.classList.remove('trd-drop-target');
591
+ overlay.classList.remove('trd-drop-target', 'trd-drop-refused');
150
592
  }
151
593
  });
152
- overlay.addEventListener('drop', event => {
153
- overlay.classList.remove('trd-drop-target');
594
+ listen('drop', event => {
595
+ overlay.classList.remove('trd-drop-target', 'trd-drop-refused');
154
596
  if (!event.dataTransfer?.types.includes('Files')) {
155
597
  return;
156
598
  }
157
599
  event.preventDefault();
158
600
  event.stopPropagation();
159
- this.provider.handleDrop(event).then((dropped) => this.send(dropped), (e) => this.toast(`Couldn't read the dropped files: ${e?.message ?? e}`));
601
+ if (!this.mayGoThere()) {
602
+ return;
603
+ }
604
+ this.provider.handleDrop(event).then((dropped) => this.send(dropped), (e) => {
605
+ this.log(`files: drop: ${e?.message ?? e}`);
606
+ this.toast(`Couldn't read the dropped files: ${e?.message ?? e}`);
607
+ });
160
608
  }, true);
161
609
  }
162
- /** Mac → remote: offers the files on the remote clipboard; the remote copies them when they're pasted. */
163
- send(files, pasting = false) {
164
- const count = files.filter(f => !f.isDirectory).length;
165
- if (!count) {
610
+ /**
611
+ * Whether files may go to the remote now, that is, whether this computer's clipboard does; when not, says so over
612
+ * the desktop.
613
+ */
614
+ mayGoThere() {
615
+ const ways = this.ways();
616
+ if (!ways.toRemote) {
617
+ this.log('files: not sent: this computer\'s clipboard doesn\'t go to the remote');
618
+ this.toast(ways.fromRemote
619
+ ? 'Not sent: the clipboard only goes from this desktop to this computer.'
620
+ : 'Not sent: clipboard sharing with this desktop is off.');
621
+ }
622
+ return ways.toRemote;
623
+ }
624
+ /**
625
+ * This computer's clipboard stopped going to the remote while connected: the files offered there are taken back.
626
+ * A paste still pulling them fails at once, the remote's requests still waiting their turn (see request) get an
627
+ * error at once, rather than once the parts being read are done, and those the provider keeps for pasting again are
628
+ * dropped; asked for anyway, they get an error (see refuse). Their names and sizes stay on the remote's clipboard
629
+ * until it reconnects.
630
+ */
631
+ revoke() {
632
+ this.pendingPaste = null;
633
+ this.waiting.splice(0).forEach(request => this.refuse(request));
634
+ try {
635
+ if (this.provider.isUploadInProgress?.()) {
636
+ this.provider.failPendingUpload?.('this computer\'s clipboard doesn\'t go to this desktop any more');
637
+ }
638
+ this.provider.retainedFiles = undefined;
639
+ }
640
+ catch (e) {
641
+ this.log(`files: offered files not taken back: ${e?.message ?? e}`);
642
+ }
643
+ }
644
+ /**
645
+ * The remote asked for files offered before the clipboard narrowed: it gets an error instead of their contents.
646
+ * `reading`: the request is one being read, which the error answers (see fail).
647
+ */
648
+ refuse(request, reading) {
649
+ if (!this.refused) {
650
+ this.refused = true;
651
+ this.mayGoThere();
652
+ }
653
+ this.fail(request.streamId, reading);
654
+ }
655
+ /**
656
+ * A request of the remote's for what is offered on its clipboard from here, where this computer's clipboard goes:
657
+ * a file's size is answered at once, part of a file in turn, once what is being read for the remote leaves room
658
+ * for it (MAX_IN_FLIGHT). One on a stream that has a request being read or waiting already gets an error: the
659
+ * remote chooses the streams' numbers, and its answers go back under them, so it couldn't tell which of its two
660
+ * requests on a stream an answer is for. Clients ask one thing at a time on a stream (FreeRDP's and GNOME Remote
661
+ * Desktop's, xrdp's). (An answer is matched to the request it was made for by a number of the plugin's own,
662
+ * whatever the remote numbers its streams: see the constructor.)
663
+ */
664
+ request(request) {
665
+ // The remote is pulling the files: the provider is told so first, as its own handler does, whether this
666
+ // request is answered at once, waits its turn or is refused. Its watchdogs fail a paste once the remote has
667
+ // asked it for nothing for a minute, and a request waiting its turn here (see next) hasn't reached it.
668
+ this.provider.acknowledgePaste?.();
669
+ // A read the provider dropped long ago no longer holds its stream (see READ_EXPIRES).
670
+ this.expire();
671
+ if (this.reading.some(r => r.streamId === request.streamId) || this.waiting.some(r => r.streamId === request.streamId)) {
672
+ this.fail(request.streamId);
673
+ if (!this.overlapping) {
674
+ this.overlapping = true;
675
+ this.log(`files: a request of the remote's was refused: another one on its stream (${request.streamId}) wasn't answered yet`);
676
+ }
677
+ return;
678
+ }
679
+ if ((request.flags & SIZE) || !(request.flags & RANGE)) {
680
+ this.read(request);
681
+ }
682
+ else if (this.waiting.length >= exports.MAX_WAITING) {
683
+ this.unreadable(request, `the remote desktop asked for more than ${exports.MAX_WAITING} parts of files at once`);
684
+ }
685
+ else {
686
+ this.waiting.push(request);
687
+ this.next();
688
+ }
689
+ }
690
+ /**
691
+ * Reads the requests waiting, oldest first, for as long as what is being read leaves room for them, and the
692
+ * connection has room for what they are read for (see ServerFlow). A part answered is handed to the connection,
693
+ * where it stays, in the window's memory, until the proxy has read it, as the server takes what is sent: one that
694
+ * asks and reads slowly, or not at all, would otherwise have every part it asks for kept there. Each takes room
695
+ * for as much as it may be from the moment it starts to be read until it is answered, and what the answer hands
696
+ * the connection takes room of its own (see the constructor). The requests wait meanwhile, MAX_WAITING at most,
697
+ * and are read once there is room. (The room holds back what is kept there as far as ServerFlow says: the proxy
698
+ * reading the connection's other messages makes room too.)
699
+ */
700
+ next() {
701
+ this.expire();
702
+ while (this.waiting.length) {
703
+ if (this.flow && !this.flow.room()) {
704
+ if (!this.roomAwaited) {
705
+ this.roomAwaited = true;
706
+ this.flow.whenRoom().then(() => {
707
+ this.roomAwaited = false;
708
+ if (!this.disposed) {
709
+ this.next();
710
+ }
711
+ });
712
+ }
713
+ return;
714
+ }
715
+ const bytes = Math.min(Math.max(this.waiting[0].size, exports.MIN_COST), exports.MAX_RANGE);
716
+ if (this.reading.length && this.reading.reduce((sum, r) => sum + r.bytes, 0) + bytes > exports.MAX_IN_FLIGHT) {
717
+ return;
718
+ }
719
+ const request = this.waiting.shift();
720
+ if (!this.ways().toRemote) {
721
+ this.refuse(request);
722
+ continue;
723
+ }
724
+ this.flow?.spend(bytes);
725
+ const reading = { streamId: request.streamId, bytes, since: null, number: null };
726
+ this.reading.push(reading);
727
+ this.fetch(request, reading);
728
+ }
729
+ }
730
+ /**
731
+ * Has the provider answer a request for part of a file (`reading`: what counts it towards MAX_IN_FLIGHT). IronRDP
732
+ * takes the part as a Blob there and then, so a pasted file's part is read first (DiskFile.read), on Node's thread
733
+ * pool and one at a time for the files offered, and handed over as the provider slices the file: a slow disk or
734
+ * network folder holds up the transfer, not Tabby's window. The browser reads files dropped or picked by itself.
735
+ */
736
+ fetch(request, reading) {
737
+ const file = this.sent[request.index]?.file;
738
+ if (!(file instanceof DiskFile)) {
739
+ reading.since = Date.now();
740
+ this.read(request, reading);
741
+ return;
742
+ }
743
+ const sent = this.sent;
744
+ const start = request.position;
745
+ const end = request.position + request.size;
746
+ const part = this.disk.then(() => file.read(start, end));
747
+ this.disk = part.catch(() => null);
748
+ const settled = (answer) => {
749
+ reading.since = Date.now();
750
+ if (this.disposed) {
751
+ return;
752
+ }
753
+ if (sent !== this.sent) {
754
+ // For the files another offer has replaced since, as those still waiting were (see send): the remote
755
+ // gets an error, and the room the read took goes back now that it is over, not before.
756
+ this.fail(request.streamId, reading);
757
+ }
758
+ else {
759
+ answer();
760
+ }
761
+ };
762
+ part.then(data => settled(() => this.ways().toRemote ? file.serving(start, end, data, () => this.read(request, reading)) : this.refuse(request, reading)), (e) => settled(() => this.unreadable(request, e?.message ?? String(e), reading)));
763
+ }
764
+ /**
765
+ * An error for the remote's request on `streamId`, from the plugin itself rather than the provider: it goes to the
766
+ * connection at once, under the remote's number, and for a request being read (`reading`) the room it took goes
767
+ * back (see release). For a request that was never read (see request), or isn't any more (see send), there is
768
+ * nothing to give back.
769
+ */
770
+ fail(streamId, reading) {
771
+ this.submit?.(streamId, true, new Uint8Array());
772
+ if (reading) {
773
+ this.release(reading);
774
+ }
775
+ }
776
+ /**
777
+ * Drops what is being read for requests the provider took longer ago than READ_EXPIRES (see there), and gives
778
+ * back the room they took: nothing was handed to the connection for them. What the provider answers for them later
779
+ * is for no request any more (see the constructor).
780
+ */
781
+ expire() {
782
+ const now = Date.now();
783
+ this.reading = this.reading.filter(r => {
784
+ const live = r.since === null || now - r.since < READ_EXPIRES;
785
+ if (!live) {
786
+ this.flow?.refund(r.bytes);
787
+ if (r.number !== null) {
788
+ this.served.delete(r.number);
789
+ }
790
+ }
791
+ return live;
792
+ });
793
+ }
794
+ /**
795
+ * Gives back the room taken for the parts being read that `which` picks (all of them by default; see ServerFlow):
796
+ * none of them will be handed over.
797
+ */
798
+ dropReading(which = () => true) {
799
+ this.reading = this.reading.filter(r => {
800
+ if (!which(r)) {
801
+ return true;
802
+ }
803
+ this.flow?.refund(r.bytes);
804
+ return false;
805
+ });
806
+ }
807
+ /**
808
+ * What was being read for a request has gone to the remote (or an error has): room for the next, and the room it
809
+ * took while it was read given back (what was handed takes its own, see the constructor). Only for that request:
810
+ * one that isn't being read any more (it expired, or went with the files offered) gives back nothing.
811
+ */
812
+ release(reading) {
813
+ if (reading.number !== null) {
814
+ this.served.delete(reading.number);
815
+ }
816
+ const i = this.reading.indexOf(reading);
817
+ if (i >= 0) {
818
+ this.reading.splice(i, 1);
819
+ this.flow?.refund(reading.bytes);
820
+ this.next();
821
+ }
822
+ }
823
+ /**
824
+ * Has the provider answer a request, handed to it under a number of the plugin's own (see served): the remote's
825
+ * numbers for streams are the remote's to choose, and the provider keeps its reads, and answers, by the number it
826
+ * is given. A file that can't be read for it (one replaced or removed since it was offered, a part larger than
827
+ * MAX_RANGE) throws there, which would leave the remote waiting for an answer: it gets an error. `reading`: the
828
+ * request is one for part of a file, answered when the provider has read it; otherwise (a file's size) the provider
829
+ * answers as it goes, or not at all.
830
+ */
831
+ read(request, reading = null) {
832
+ const number = ++this.numbered;
833
+ this.served.set(number, { streamId: request.streamId, reading });
834
+ if (reading) {
835
+ reading.number = number;
836
+ }
837
+ try {
838
+ this.serve({ streamId: number, index: request.index, flags: request.flags, position: request.position, size: request.size });
839
+ }
840
+ catch (e) {
841
+ // Not answered yet, if it is still among those handed over (the provider can answer first, then throw).
842
+ if (this.served.has(number)) {
843
+ this.unreadable(request, e?.message ?? String(e), reading ?? undefined);
844
+ }
845
+ }
846
+ finally {
847
+ if (!reading) {
848
+ this.served.delete(number);
849
+ }
850
+ }
851
+ }
852
+ /**
853
+ * The remote gets an error for a request it can't have answered, and the user is told why (once for an offer).
854
+ * `reading`: the request is one being read, which the error answers (see fail).
855
+ */
856
+ unreadable(request, why, reading) {
857
+ if (!this.unanswered) {
858
+ this.unanswered = true;
859
+ this.log(`files: a request of the remote's was refused: ${why}`);
860
+ this.toast(`Copying to the remote desktop failed: ${why}`);
861
+ }
862
+ this.fail(request.streamId, reading);
863
+ }
864
+ /**
865
+ * Mac → remote: offers the files on the remote clipboard; the remote copies them when they're pasted. `leftOut`:
866
+ * links in copied folders that weren't followed, and `cut`, whether the copied folders held more than is sent (see
867
+ * entriesFor), which the messages mention.
868
+ */
869
+ send(files, pasting = false, leftOut = 0, cut = false) {
870
+ const entries = bounded(files);
871
+ const count = entries.filter(f => !f.isDirectory).length;
872
+ if (!count || !this.mayGoThere()) {
166
873
  return null;
167
874
  }
875
+ const note = leftOutNote(leftOut, cut);
168
876
  try {
169
877
  // The remote answers the new clipboard before it can paste it; a paste sent sooner gets what it had.
878
+ this.nothingOffered = false;
170
879
  this.offerTaken = new Promise(resolve => {
171
880
  const answered = (ok) => {
172
881
  this.provider.off('format-list-response', answered);
@@ -174,12 +883,25 @@ class FileTransfer {
174
883
  };
175
884
  this.provider.on('format-list-response', answered);
176
885
  });
177
- const upload = this.provider.uploadFiles(files);
886
+ // The new offer takes the last one's place, and its reads with it: requests still waiting were for those,
887
+ // and so were those being read. Each gets an error, and what the provider answers for them after that is
888
+ // dropped (see the constructor). A pasted file's part still being read from disk goes on being read, and
889
+ // keeps the room it took until then, when its request gets the error (see fetch): what it reads is in
890
+ // memory all the same. The new offer's parts are read on their own, not after those.
891
+ this.sent = entries;
892
+ this.disk = Promise.resolve();
893
+ this.served.forEach(served => this.fail(served.streamId));
894
+ this.served.clear();
895
+ this.dropReading(r => r.since !== null);
896
+ this.waiting.splice(0).forEach(request => this.fail(request.streamId));
897
+ this.unanswered = false;
898
+ const upload = this.provider.uploadFiles(entries);
178
899
  this.log(`files: offered ${count} to the remote${pasting ? ' (copied here, pasted there)' : ''}`);
179
- this.toast(pasting ? `Pasting ${plural(count, 'file')}…` : `${plural(count, 'file')} ready: paste on the remote desktop (⌘V) to copy ${count === 1 ? 'it' : 'them'} there`, [], 0);
180
- return upload.completion.then(() => { this.log('files: pasted on the remote'); this.toast(`Copied ${plural(count, 'file')} to the remote desktop`); }, (e) => { this.log(`files: upload: ${e?.message ?? e}`); this.toast(`Copying to the remote desktop failed: ${e?.message ?? e}`); });
900
+ this.toast(pasting ? `Pasting ${plural(count, 'file')}…${note}` : `${plural(count, 'file')} ready: paste on the remote desktop (⌘V) to copy ${count === 1 ? 'it' : 'them'} there`, [], 0);
901
+ return upload.completion.then(() => { this.log('files: pasted on the remote'); this.toast(`Copied ${plural(count, 'file')} to the remote desktop${note}`); }, (e) => { this.log(`files: upload: ${e?.message ?? e}`); this.toast(`Copying to the remote desktop failed: ${e?.message ?? e}`); });
181
902
  }
182
903
  catch (e) {
904
+ this.log(`files: not offered: ${e?.message ?? e}`);
183
905
  this.toast(`Couldn't offer the files: ${e?.message ?? e}`);
184
906
  return null;
185
907
  }
@@ -210,26 +932,57 @@ class FileTransfer {
210
932
  /**
211
933
  * ⌘V (Ctrl+V) on the desktop with files copied here (Finder, Explorer, Files): offers them on the remote clipboard
212
934
  * for that paste. True when it did, and the paste should wait a moment for the remote to see them; false when the
213
- * clipboard holds no files, or these are already offered and not pasted yet.
935
+ * clipboard holds no files, or these are already offered and not pasted yet, or this computer's clipboard doesn't
936
+ * go to the remote (then it isn't even read, and the keys paste what the remote has). Also true, with nothing
937
+ * offered and no paste (offerAnswered: false), when no file can go: the copied folders hold none that can (links
938
+ * left out, or past the walk's limits), or what was copied is gone, links that lead nowhere, pipes or folders with
939
+ * nothing in them; or they couldn't be read (a folder that can't be opened, or one found swapped for another while
940
+ * it was read): the keys would paste whatever the remote had instead.
214
941
  */
215
942
  pasteClipboardFiles() {
216
- const paths = clipboardPaths();
217
- if (!paths.length) {
943
+ if (!this.ways().toRemote) {
218
944
  return false;
219
945
  }
220
- const key = paths.join('\n');
221
- if (this.pendingPaste === key) {
222
- return false;
223
- }
224
- let entries;
946
+ let key;
947
+ let found;
225
948
  try {
226
- entries = entriesFor(paths);
949
+ // Reading what was copied counts too: a file name on the clipboard that isn't one (a bad address) fails
950
+ // here.
951
+ const paths = clipboardPaths();
952
+ if (!paths.length) {
953
+ return false;
954
+ }
955
+ key = paths.join('\n');
956
+ if (this.pendingPaste === key) {
957
+ return false;
958
+ }
959
+ found = entriesFor(paths);
227
960
  }
228
961
  catch (e) {
962
+ // Taken, with nothing to paste, as when nothing can go (below): the keys would paste what the remote had.
963
+ this.log(`files: not sent: ${e?.message ?? e}`);
229
964
  this.toast(`Couldn't read the copied files: ${e?.message ?? e}`);
230
- return false;
965
+ this.offerTaken = Promise.resolve(false);
966
+ this.nothingOffered = true;
967
+ return true;
231
968
  }
232
- const done = this.send(entries, true);
969
+ if (found.leftOut) {
970
+ this.log(`files: left out ${plural(found.leftOut, 'link')} leading out of the copied folders`);
971
+ }
972
+ if (found.cut) {
973
+ this.log('files: the copied folders hold more than a paste takes; not all sent');
974
+ }
975
+ // Nothing a file could be sent for: links left out, limits, or nothing there (copied and gone since, links that
976
+ // lead nowhere, pipes, folders with nothing in them). Taken all the same: the keys would paste what the remote
977
+ // had.
978
+ if (!found.entries.some(e => !e.isDirectory)) {
979
+ this.log('files: nothing to send');
980
+ this.toast(`Nothing to send${leftOutNote(found.leftOut, found.cut)}`);
981
+ this.offerTaken = Promise.resolve(false);
982
+ this.nothingOffered = true;
983
+ return true;
984
+ }
985
+ const done = this.send(found.entries, true, found.leftOut, found.cut);
233
986
  if (!done) {
234
987
  return false;
235
988
  }
@@ -238,46 +991,87 @@ class FileTransfer {
238
991
  this.pendingPaste = null; });
239
992
  return true;
240
993
  }
241
- /** Picks files on the Mac to send. */
994
+ /** Picks files on the Mac to send (not even asked for where they wouldn't go). */
242
995
  async pickAndSend() {
996
+ if (!this.mayGoThere()) {
997
+ return;
998
+ }
243
999
  const files = await this.provider.showFilePicker({ multiple: true });
244
1000
  this.send(files);
245
1001
  }
246
- /** Remote → Mac: the remote copied files; offer saving them. */
1002
+ /** Remote → Mac: the remote copied files; offer saving them, where the remote's clipboard comes here. */
247
1003
  offer(files) {
248
- this.offered = files;
249
1004
  const count = files.filter(f => !f.isDirectory).length;
1005
+ if (!this.ways().fromRemote) {
1006
+ this.log(`files: the remote copied ${count}; not offered: its clipboard doesn't come here`);
1007
+ return;
1008
+ }
1009
+ this.offered = files;
250
1010
  this.log(`files: the remote copied ${count}`);
251
1011
  if (count) {
252
1012
  this.toast(`${plural(count, 'file')} copied on the remote desktop`, [{ label: 'Save to Downloads', run: () => this.saveAll() }], 15000);
253
1013
  }
254
1014
  }
255
- /** Downloads what the remote offered into a folder (default ~/Downloads), keeping their folder structure. */
1015
+ /**
1016
+ * Downloads what the remote offered into a folder (default ~/Downloads), keeping their folder structure in new
1017
+ * folders (see newFolders). The files, and the folders made for them, are marked as downloaded (see
1018
+ * markDownloaded).
1019
+ */
256
1020
  async saveAll(dir = path.join(os.homedir(), 'Downloads')) {
1021
+ if (!this.ways().fromRemote) {
1022
+ // Offered before the settings narrowed the clipboard: not any more.
1023
+ this.log('files: not saved: the remote\'s clipboard doesn\'t come here');
1024
+ this.toast('Not saved: clipboard sharing with this desktop is off.');
1025
+ return [];
1026
+ }
257
1027
  const files = this.offered;
258
1028
  const saved = [];
1029
+ let unmarked = 0;
259
1030
  this.toast(`Saving ${plural(files.filter(f => !f.isDirectory).length, 'file')}…`, [], 0);
1031
+ // What was saved, and whether any of it isn't marked as downloaded: also when a later file fails.
1032
+ const done = () => {
1033
+ this.lastSaved = saved;
1034
+ this.log(`files: saved ${saved.length}${unmarked ? `, ${unmarked} of them (or their folders) not marked as downloaded` : ''}`);
1035
+ return unmarked ? '\nNot all of it could be marked as downloaded, so the system may not warn before opening it' : '';
1036
+ };
1037
+ const show = () => saved.length ? [{ label: 'Show in Finder', run: () => require('electron').shell.showItemInFolder(saved[0]) }] : [];
1038
+ const into = newFolders(dir);
260
1039
  try {
261
1040
  for (const [index, file] of files.entries()) {
262
1041
  if (file.isDirectory) {
263
1042
  continue;
264
1043
  }
265
- const target = uniquePath(savePath(dir, file.path, file.name));
1044
+ // The settings can narrow while the files arrive: none is fetched once the remote's clipboard doesn't
1045
+ // come here any more. What was saved before keeps its note about marking, as after a failure.
1046
+ if (!this.ways().fromRemote) {
1047
+ this.log(`files: stopped saving after ${saved.length}: the remote's clipboard doesn't come here any more`);
1048
+ const note = done();
1049
+ this.toast(`Stopped saving: clipboard sharing with this desktop is off now${saved.length ? ` (${plural(saved.length, 'file')} saved)` : ''}.${note}`, show());
1050
+ return saved;
1051
+ }
1052
+ // The name is checked before the download; the folder it goes in is made once the file is here, so that
1053
+ // one that fails doesn't leave an empty folder, and the folder is this file's, not one made since.
1054
+ const wanted = savePath(dir, file.path, file.name);
266
1055
  const blob = await this.provider.downloadFile(file, index).completion;
267
- await fs.promises.mkdir(path.dirname(target), { recursive: true });
268
- await fs.promises.writeFile(target, Buffer.from(await blob.arrayBuffer()));
1056
+ const made = [];
1057
+ const target = await place(dir, into(wanted, made), blob, made);
269
1058
  saved.push(target);
1059
+ for (const item of [...made, target]) {
1060
+ const error = await markDownloaded(item);
1061
+ if (error && !unmarked++) {
1062
+ this.log(`files: saved without marking it as downloaded: ${error}`);
1063
+ }
1064
+ }
270
1065
  }
271
- this.lastSaved = saved;
272
- this.log(`files: saved ${saved.length}`);
273
- this.toast(`Saved ${plural(saved.length, 'file')} to ${path.basename(dir)}`, saved.length ? [{
274
- label: 'Show in Finder',
275
- run: () => require('electron').shell.showItemInFolder(saved[0]),
276
- }] : []);
1066
+ const note = done();
1067
+ this.toast(`Saved ${plural(saved.length, 'file')} to ${path.basename(dir)}${note}`, show());
277
1068
  }
278
1069
  catch (e) {
279
1070
  this.log(`files: save: ${e?.message ?? e}`);
280
- this.toast(`Saving failed: ${e?.message ?? e}`);
1071
+ // A folder made for what this save was putting there, with nothing in it: taken away (not one with files).
1072
+ await Promise.all(into.made.map(folder => fs.promises.rmdir(folder).catch(() => null)));
1073
+ const note = done();
1074
+ this.toast(`Saving failed: ${e?.message ?? e}${saved.length ? `\n${plural(saved.length, 'file')} saved before that` : ''}${note}`, show());
281
1075
  }
282
1076
  return saved;
283
1077
  }
@@ -310,6 +1104,12 @@ class FileTransfer {
310
1104
  }
311
1105
  }
312
1106
  dispose() {
1107
+ this.disposed = true;
1108
+ this.waiting = [];
1109
+ this.dropReading();
1110
+ this.served.clear();
1111
+ this.stopListening.splice(0).forEach(stop => stop());
1112
+ this.overlay.classList.remove('trd-drop-target', 'trd-drop-refused');
313
1113
  clearTimeout(this.toastTimer);
314
1114
  this.toastEl?.remove();
315
1115
  try {
@@ -319,16 +1119,44 @@ class FileTransfer {
319
1119
  }
320
1120
  }
321
1121
  exports.FileTransfer = FileTransfer;
1122
+ /** Control characters, and the invisible ones that change how a name reads, but for the joiners scripts and emoji use. */
1123
+ const INVISIBLE = /(?![\u200c\u200d])[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
1124
+ /**
1125
+ * A subdivision's flag (England's, Scotland's and Wales' are the ones in Unicode's list): a black flag, the tags of a
1126
+ * subdivision's code (two lower-case letters for its country, one to four letters or digits for itself; UTS #51), a
1127
+ * cancel tag. Tags of anything else, text of any length that doesn't show, aren't a flag.
1128
+ */
1129
+ const FLAG = /(\u{1F3F4}[\u{E0061}-\u{E007A}]{2}[\u{E0030}-\u{E0039}\u{E0061}-\u{E007A}]{1,4}\u{E007F})/u;
1130
+ /**
1131
+ * A file or folder name from the remote, as it is saved here. Without control characters or the invisible ones that
1132
+ * change how a name reads: a right-to-left override shows `invoice<RLO>fdp.app` as "invoiceppa.pdf", an app made to
1133
+ * look like a document (the tags of a flag stay, in a flag). Composed (NFC), so that it is spelled as names typed here
1134
+ * are. On Windows, characters Windows doesn't take in a name (`:` would write into a stream of another file) become
1135
+ * `_`, dots or spaces at the end go (dropped there, so the file isn't what it says), and a device's name (`CON`,
1136
+ * `NUL.txt`, `COM1`: the device, not a file) gets a `_` in front. Letters that look like others remain: a name can't be
1137
+ * made to look like nothing else.
1138
+ */
1139
+ function safeName(name, windows = process.platform === 'win32') {
1140
+ let safe = name.normalize('NFC').split(FLAG).map((part, i) => i % 2 ? part : part.replace(INVISIBLE, '')).join('').trim();
1141
+ if (windows) {
1142
+ safe = safe.replace(/[<>:"|?*]/g, '_').replace(/[. ]+$/, '');
1143
+ if (/^(con|prn|aux|nul|conin\$|conout\$|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3])$/i.test(safe.split('.')[0].trimEnd())) {
1144
+ safe = `_${safe}`;
1145
+ }
1146
+ }
1147
+ return safe;
1148
+ }
322
1149
  /**
323
1150
  * Where a file the remote offered goes under `dir`, keeping its folder structure. The names come from the remote
324
1151
  * (a server or anything on it can put them on the clipboard), so every component is cut at both separators (`\\` is
325
- * the wire convention, but a name may hold `/`), `.`, `..` and drive letters are dropped, and the result must stay
326
- * inside `dir`: a name like `../../Library/LaunchAgents/x.plist` must not land outside Downloads.
1152
+ * the wire convention, but a name may hold `/`), `.`, `..` and drive letters are dropped, each is made safe to show
1153
+ * (safeName), and the result must stay inside `dir`: a name like `../../Library/LaunchAgents/x.plist` must not land
1154
+ * outside Downloads.
327
1155
  */
328
1156
  function savePath(dir, folder, name) {
329
1157
  const parts = [folder ?? '', name]
330
1158
  .flatMap(p => String(p).split(/[\\/]+/))
331
- .map(p => p.replace(/[\u0000-\u001f]/g, '').replace(/^[A-Za-z]:$/, '').trim())
1159
+ .map(p => safeName(p.replace(/^[A-Za-z]:$/, '')))
332
1160
  .filter(p => p && p !== '.' && p !== '..');
333
1161
  const root = path.resolve(dir);
334
1162
  const target = path.resolve(root, ...parts.length ? parts : ['file']);
@@ -337,7 +1165,54 @@ function savePath(dir, folder, name) {
337
1165
  }
338
1166
  return target;
339
1167
  }
340
- /** `file.txt`, or `file 2.txt`, `file 3.txt`… if taken. */
1168
+ /**
1169
+ * For one save into `dir`: where each file the remote offered goes, so that the folders at the top of what it sends are
1170
+ * new ones (`name 2`, `name 3`… when the name is taken, as for files), not folders that were there before. What the
1171
+ * remote sends then never lands among files of the user's (in an app or a repository there, by a name it guessed),
1172
+ * and each folder it fills is one made for it, marked as downloaded. A top folder is made as it is chosen (mkdir fails
1173
+ * where the name is taken, by a folder, a file or a link), so that it is this save's whatever else is made meanwhile,
1174
+ * and is added to `made` (see place) for marking; `made` on the function itself lists them all, for a save that stops
1175
+ * to take away the empty ones.
1176
+ */
1177
+ function newFolders(dir) {
1178
+ const root = path.resolve(dir);
1179
+ const chosen = new Map();
1180
+ const into = (wanted, made = []) => {
1181
+ const [top, ...rest] = path.relative(root, wanted).split(path.sep);
1182
+ if (!rest.length) {
1183
+ return wanted; // a file at the top: place() numbers it when its name is taken
1184
+ }
1185
+ let name = chosen.get(top);
1186
+ if (name === undefined) {
1187
+ const { name: base, ext } = path.parse(top);
1188
+ fs.mkdirSync(root, { recursive: true });
1189
+ name = top;
1190
+ for (let n = 2;; n++) {
1191
+ try {
1192
+ fs.mkdirSync(path.join(root, name));
1193
+ break;
1194
+ }
1195
+ catch (e) {
1196
+ // Not into one that was there, whatever its name, or one that was made since.
1197
+ if (e?.code !== 'EEXIST') {
1198
+ throw e;
1199
+ }
1200
+ }
1201
+ if (n >= 10000) {
1202
+ throw new Error(`no free name for a folder like ${JSON.stringify(top)}`);
1203
+ }
1204
+ name = `${base} ${n}${ext}`;
1205
+ }
1206
+ chosen.set(top, name);
1207
+ made.push(path.join(root, name));
1208
+ into.made.push(path.join(root, name));
1209
+ }
1210
+ return path.join(root, name, ...rest);
1211
+ };
1212
+ into.made = [];
1213
+ return into;
1214
+ }
1215
+ /** `file.txt`, or `file 2.txt`, `file 3.txt`… if taken. For names of the plugin's own choosing (screenshots). */
341
1216
  function uniquePath(target) {
342
1217
  if (!fs.existsSync(target)) {
343
1218
  return target;
@@ -350,4 +1225,121 @@ function uniquePath(target) {
350
1225
  }
351
1226
  }
352
1227
  }
353
- //# sourceMappingURL=fileTransfer.js.map
1228
+ /**
1229
+ * Makes the folders from `dir` down to `folder` (which savePath put under it), one at a time, and returns the
1230
+ * folder's real path; those it made are added to `made`. An existing component may be a link only if it stays inside
1231
+ * `dir`: folder names come from the remote, and a link in Downloads with a matching name would otherwise take the
1232
+ * files wherever it points.
1233
+ */
1234
+ async function foldersInside(dir, folder, made = []) {
1235
+ await fs.promises.mkdir(dir, { recursive: true });
1236
+ const root = await fs.promises.realpath(dir);
1237
+ let at = root;
1238
+ for (const part of path.relative(path.resolve(dir), folder).split(path.sep).filter(Boolean)) {
1239
+ const next = path.join(at, part);
1240
+ const created = await fs.promises.mkdir(next).then(() => true, (e) => {
1241
+ if (e?.code !== 'EEXIST') {
1242
+ throw e;
1243
+ }
1244
+ return false;
1245
+ });
1246
+ at = await fs.promises.realpath(next);
1247
+ if (at !== root && !at.startsWith(root + path.sep)) {
1248
+ throw new Error(`refused to save through a link that leaves the folder: ${JSON.stringify(part)}`);
1249
+ }
1250
+ if (created) {
1251
+ made.push(at);
1252
+ }
1253
+ }
1254
+ return at;
1255
+ }
1256
+ /**
1257
+ * Puts a download at `wanted` under `dir` (or `name 2.ext`, `name 3.ext`… when taken) and returns where it went.
1258
+ * The file is created there and never replaces or follows what exists: a name can be taken, or made a link,
1259
+ * between looking and writing. Folders made for it are added to `made`. A file that fails to be written whole (the
1260
+ * disk filled up, say) isn't left there part written, and not marked as downloaded (see saveAll); a file this call
1261
+ * didn't make isn't removed, whatever fails.
1262
+ */
1263
+ async function place(dir, wanted, blob, made = []) {
1264
+ const folder = await foldersInside(dir, path.dirname(wanted), made);
1265
+ const { name, ext } = path.parse(wanted);
1266
+ const temporary = blob[DISK_PATH];
1267
+ // Writes the file at `target`, a name that was free just before: false if it has been taken since (EEXIST).
1268
+ const write = async (target) => {
1269
+ if (temporary) {
1270
+ // The copy makes the file only where the name is free (EXCL), and tries to remove what it made when it
1271
+ // fails part way: what is there after any other failure isn't this call's to remove, but is said, in case
1272
+ // it is the beginning of this file, which the copy couldn't remove.
1273
+ try {
1274
+ await fs.promises.copyFile(temporary, target, fs.constants.COPYFILE_EXCL);
1275
+ return true;
1276
+ }
1277
+ catch (e) {
1278
+ if (e?.code === 'EEXIST') {
1279
+ return false;
1280
+ }
1281
+ const left = await fs.promises.lstat(target).then(() => ` (${path.basename(target)} is there, perhaps saved in part)`, () => '');
1282
+ throw Object.assign(new Error(`${e?.message ?? e}${left}`), { code: e?.code });
1283
+ }
1284
+ }
1285
+ // Read before anything is made: a blob that can't be read leaves nothing here that is this call's to remove.
1286
+ const data = Buffer.from(await blob.arrayBuffer());
1287
+ let file;
1288
+ try {
1289
+ file = await fs.promises.open(target, 'wx');
1290
+ }
1291
+ catch (e) {
1292
+ if (e?.code === 'EEXIST') {
1293
+ return false;
1294
+ }
1295
+ throw e;
1296
+ }
1297
+ try {
1298
+ await file.writeFile(data);
1299
+ await file.close();
1300
+ return true;
1301
+ }
1302
+ catch (e) {
1303
+ await file.close().catch(() => null);
1304
+ // Made here, and only the beginning of this file: it goes, or, if it can't, the message says it stays.
1305
+ const left = await fs.promises.rm(target, { force: true }).then(() => '', (error) => ` (${path.basename(target)} was saved in part and couldn't be removed: ${error?.message ?? error})`);
1306
+ throw Object.assign(new Error(`${e?.message ?? e}${left}`), { code: e?.code });
1307
+ }
1308
+ };
1309
+ for (let n = 1;; n++) {
1310
+ const target = path.join(folder, n === 1 ? `${name}${ext}` : `${name} ${n}${ext}`);
1311
+ if (!await fs.promises.lstat(target).then(() => true, () => false) && await write(target)) {
1312
+ // Saved, whatever becomes of the temporary file: one that can't go now goes with its folder when the
1313
+ // connection ends (DiskStorage.dispose). Failing the save here would leave the file unmarked (saveAll).
1314
+ if (temporary) {
1315
+ await fs.promises.rm(temporary, { force: true }).catch(() => null);
1316
+ }
1317
+ return target;
1318
+ }
1319
+ if (n >= 10000) {
1320
+ throw Object.assign(new Error(`${path.basename(wanted)}: no free name for it in ${folder}`), { code: 'EEXIST' });
1321
+ }
1322
+ }
1323
+ }
1324
+ /**
1325
+ * Marks a file (or folder) saved from the remote as downloaded, as a browser marks its downloads, so the system
1326
+ * treats it as from elsewhere: macOS's quarantine attribute (Gatekeeper asks before an app, script, installer or
1327
+ * bundle among them first opens), Windows' Mark of the Web (SmartScreen, Office's Protected View). Linux has no such
1328
+ * mark. Resolves with what went wrong when it couldn't be marked (a disk that keeps no such attributes, say); the file
1329
+ * stays either way.
1330
+ */
1331
+ function markDownloaded(file) {
1332
+ if (process.platform === 'darwin') {
1333
+ // As browsers write it: flags (downloaded, not yet opened), when (seconds, in hex), by what. Without a
1334
+ // shell, so the path is an argument as it is.
1335
+ const value = `0081;${Math.floor(Date.now() / 1000).toString(16)};Tabby;`;
1336
+ return new Promise(resolve => (0, child_process_1.execFile)('/usr/bin/xattr', ['-w', 'com.apple.quarantine', value, file], { timeout: 10000 }, (e, _out, err) => {
1337
+ resolve(e ? String(err).trim() || e.message : null);
1338
+ }));
1339
+ }
1340
+ if (process.platform === 'win32') {
1341
+ // The Internet zone, as a browser's download has.
1342
+ return fs.promises.writeFile(`${file}:Zone.Identifier`, '[ZoneTransfer]\r\nZoneId=3\r\n').then(() => null, (e) => e?.message ?? String(e));
1343
+ }
1344
+ return Promise.resolve(null);
1345
+ }