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