tabby-rdp 0.5.0 → 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.
- package/CHANGELOG.md +490 -0
- package/README.md +177 -48
- package/dist/accountForm.js +0 -1
- package/dist/accounts.js +0 -1
- 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 +1442 -244
- package/dist/desktopForm.js +51 -17
- package/dist/desktops.js +232 -24
- package/dist/drives.js +271 -30
- package/dist/fileTransfer.js +958 -83
- package/dist/gateway.js +216 -57
- package/dist/header.js +76 -5
- package/dist/help.js +34 -13
- package/dist/hyperv.js +65 -15
- package/dist/index.js +2 -2
- 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/probe.js +15 -3
- package/dist/rdcleanpath.js +319 -44
- package/dist/rdpFile.js +22 -7
- package/dist/rdpProfile.js +39 -21
- package/dist/remoteSetup.js +103 -23
- package/dist/sessionLog.js +53 -0
- package/dist/settingsPage.js +65 -29
- package/dist/signin.js +407 -40
- package/dist/ssh.js +99 -18
- package/dist/sshChannelStream.js +40 -6
- package/dist/targets.js +185 -39
- package/dist/ui.js +88 -20
- package/dist/unshowable.js +41 -0
- package/dist/updates.js +37 -2
- package/dist/vms.js +17 -3
- package/dist/wake.js +15 -8
- package/package.json +4 -3
- 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,77 @@
|
|
|
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.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;
|
|
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
|
-
|
|
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;
|
|
14
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
|
+
}
|
|
15
75
|
/**
|
|
16
76
|
* Files copied in Finder (Explorer, Files): their paths, from the local clipboard. Empty when it holds no files.
|
|
17
77
|
*/
|
|
@@ -47,60 +107,298 @@ function clipboardPaths() {
|
|
|
47
107
|
const list = read('x-special/gnome-copied-files') || read('text/uri-list');
|
|
48
108
|
return list.split(/\r?\n/).filter(l => l.startsWith('file://')).map(l => decodeURIComponent(new URL(l).pathname));
|
|
49
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);
|
|
50
116
|
/**
|
|
51
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
|
|
52
|
-
* 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).
|
|
53
120
|
*/
|
|
54
121
|
class DiskFile {
|
|
55
|
-
constructor(file,
|
|
122
|
+
constructor(file, stat, name) {
|
|
56
123
|
this.file = file;
|
|
57
124
|
this.name = name;
|
|
58
|
-
this.size = size;
|
|
59
|
-
this.lastModified = lastModified;
|
|
60
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
|
+
}
|
|
61
182
|
}
|
|
62
183
|
slice(start = 0, end = this.size) {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
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);
|
|
66
193
|
try {
|
|
194
|
+
this.check(fs.fstatSync(fd));
|
|
67
195
|
let done = 0;
|
|
68
196
|
while (done < length) {
|
|
69
197
|
const n = fs.readSync(fd, buffer, done, length - done, start + done);
|
|
70
198
|
if (!n) {
|
|
71
|
-
|
|
199
|
+
this.shorter();
|
|
72
200
|
}
|
|
73
201
|
done += n;
|
|
74
202
|
}
|
|
75
|
-
return new Blob([buffer
|
|
203
|
+
return new Blob([buffer]);
|
|
76
204
|
}
|
|
77
205
|
finally {
|
|
78
206
|
fs.closeSync(fd);
|
|
79
207
|
}
|
|
80
208
|
}
|
|
81
209
|
}
|
|
82
|
-
/**
|
|
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
|
+
*/
|
|
83
297
|
function entriesFor(paths) {
|
|
84
298
|
const entries = [];
|
|
85
|
-
|
|
86
|
-
|
|
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;
|
|
87
311
|
return;
|
|
88
312
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
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;
|
|
93
319
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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);
|
|
99
372
|
}
|
|
100
373
|
}
|
|
101
374
|
};
|
|
102
|
-
|
|
103
|
-
|
|
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 });
|
|
393
|
+
if (stat?.isFile()) {
|
|
394
|
+
const name = path.basename(p);
|
|
395
|
+
entries.push({ file: new DiskFile(real, stat, name), name, size: stat.size, lastModified: stat.mtimeMs, path: undefined });
|
|
396
|
+
}
|
|
397
|
+
else if (stat?.isDirectory()) {
|
|
398
|
+
folder(path.basename(p), real, stat, real, [], undefined, 0);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return { entries, leftOut, cut };
|
|
104
402
|
}
|
|
105
403
|
/** On a download's result: the temporary file holding it (see DiskStorage). */
|
|
106
404
|
const DISK_PATH = Symbol('tabby-rdp download');
|
|
@@ -161,17 +459,31 @@ exports.STYLE = `
|
|
|
161
459
|
.trd-overlay.trd-drop-target::after { content: 'Drop to copy to the remote desktop, then paste there'; position: absolute; inset: 0;
|
|
162
460
|
display: flex; align-items: center; justify-content: center; background: rgba(40, 90, 160, 0.35); color: #fff; font-size: 15px;
|
|
163
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); }
|
|
164
464
|
`;
|
|
165
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
|
+
}
|
|
166
471
|
/**
|
|
167
472
|
* Files through the clipboard, with IronRDP's RdpFileTransferProvider: files dropped on the desktop (or
|
|
168
473
|
* 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.
|
|
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.
|
|
170
476
|
*/
|
|
171
477
|
class FileTransfer {
|
|
172
|
-
|
|
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) {
|
|
173
483
|
this.overlay = overlay;
|
|
174
484
|
this.log = log;
|
|
485
|
+
this.ways = ways;
|
|
486
|
+
this.flow = flow;
|
|
175
487
|
this.toastEl = null;
|
|
176
488
|
/** Files the remote offered last (for tests: saveAll). */
|
|
177
489
|
this.offered = [];
|
|
@@ -181,20 +493,87 @@ class FileTransfer {
|
|
|
181
493
|
this.pendingPaste = null;
|
|
182
494
|
/** Resolves when the remote has taken in the files last offered (its Format List Response): true if it accepted them. */
|
|
183
495
|
this.offerTaken = null;
|
|
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;
|
|
184
525
|
/**
|
|
185
526
|
* Takes the overlay's drop listeners off again: the overlay outlives this (a reconnect makes a new FileTransfer
|
|
186
527
|
* on it). With removeEventListener, not an AbortSignal: zone.js keeps one native listener per event and its own
|
|
187
528
|
* list of handlers, and a signal removes the native one behind its back, so the next handlers never get attached.
|
|
188
529
|
*/
|
|
189
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;
|
|
190
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
|
+
}
|
|
191
570
|
this.provider.on('files-available', (files) => this.offer(files));
|
|
192
571
|
this.provider.on('error', (e) => {
|
|
193
572
|
this.log(`files: ${e?.message ?? JSON.stringify(e)}`);
|
|
194
573
|
this.toast(`File transfer failed: ${e?.message ?? 'unknown error'}`);
|
|
195
574
|
});
|
|
196
575
|
// Drop files on the desktop to send them. Handled here so Tabby doesn't paste their paths into
|
|
197
|
-
// the console under the desktop.
|
|
576
|
+
// the console under the desktop, also where they don't go to the desktop (the layer says so).
|
|
198
577
|
const listen = (type, handler, capture = false) => {
|
|
199
578
|
overlay.addEventListener(type, handler, capture);
|
|
200
579
|
this.stopListening.push(() => overlay.removeEventListener(type, handler, capture));
|
|
@@ -204,34 +583,299 @@ class FileTransfer {
|
|
|
204
583
|
event.preventDefault();
|
|
205
584
|
event.stopPropagation();
|
|
206
585
|
overlay.classList.add('trd-drop-target');
|
|
586
|
+
overlay.classList.toggle('trd-drop-refused', !this.ways().toRemote);
|
|
207
587
|
}
|
|
208
588
|
});
|
|
209
589
|
listen('dragleave', event => {
|
|
210
590
|
if (!overlay.contains(event.relatedTarget)) {
|
|
211
|
-
overlay.classList.remove('trd-drop-target');
|
|
591
|
+
overlay.classList.remove('trd-drop-target', 'trd-drop-refused');
|
|
212
592
|
}
|
|
213
593
|
});
|
|
214
594
|
listen('drop', event => {
|
|
215
|
-
overlay.classList.remove('trd-drop-target');
|
|
595
|
+
overlay.classList.remove('trd-drop-target', 'trd-drop-refused');
|
|
216
596
|
if (!event.dataTransfer?.types.includes('Files')) {
|
|
217
597
|
return;
|
|
218
598
|
}
|
|
219
599
|
event.preventDefault();
|
|
220
600
|
event.stopPropagation();
|
|
601
|
+
if (!this.mayGoThere()) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
221
604
|
this.provider.handleDrop(event).then((dropped) => this.send(dropped), (e) => {
|
|
222
605
|
this.log(`files: drop: ${e?.message ?? e}`);
|
|
223
606
|
this.toast(`Couldn't read the dropped files: ${e?.message ?? e}`);
|
|
224
607
|
});
|
|
225
608
|
}, true);
|
|
226
609
|
}
|
|
227
|
-
/**
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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()) {
|
|
231
873
|
return null;
|
|
232
874
|
}
|
|
875
|
+
const note = leftOutNote(leftOut, cut);
|
|
233
876
|
try {
|
|
234
877
|
// The remote answers the new clipboard before it can paste it; a paste sent sooner gets what it had.
|
|
878
|
+
this.nothingOffered = false;
|
|
235
879
|
this.offerTaken = new Promise(resolve => {
|
|
236
880
|
const answered = (ok) => {
|
|
237
881
|
this.provider.off('format-list-response', answered);
|
|
@@ -239,10 +883,22 @@ class FileTransfer {
|
|
|
239
883
|
};
|
|
240
884
|
this.provider.on('format-list-response', answered);
|
|
241
885
|
});
|
|
242
|
-
|
|
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);
|
|
243
899
|
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}`); });
|
|
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}`); });
|
|
246
902
|
}
|
|
247
903
|
catch (e) {
|
|
248
904
|
this.log(`files: not offered: ${e?.message ?? e}`);
|
|
@@ -276,26 +932,57 @@ class FileTransfer {
|
|
|
276
932
|
/**
|
|
277
933
|
* ⌘V (Ctrl+V) on the desktop with files copied here (Finder, Explorer, Files): offers them on the remote clipboard
|
|
278
934
|
* 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
|
|
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.
|
|
280
941
|
*/
|
|
281
942
|
pasteClipboardFiles() {
|
|
282
|
-
|
|
283
|
-
if (!paths.length) {
|
|
284
|
-
return false;
|
|
285
|
-
}
|
|
286
|
-
const key = paths.join('\n');
|
|
287
|
-
if (this.pendingPaste === key) {
|
|
943
|
+
if (!this.ways().toRemote) {
|
|
288
944
|
return false;
|
|
289
945
|
}
|
|
290
|
-
let
|
|
946
|
+
let key;
|
|
947
|
+
let found;
|
|
291
948
|
try {
|
|
292
|
-
|
|
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);
|
|
293
960
|
}
|
|
294
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}`);
|
|
295
964
|
this.toast(`Couldn't read the copied files: ${e?.message ?? e}`);
|
|
296
|
-
|
|
965
|
+
this.offerTaken = Promise.resolve(false);
|
|
966
|
+
this.nothingOffered = true;
|
|
967
|
+
return true;
|
|
297
968
|
}
|
|
298
|
-
|
|
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);
|
|
299
986
|
if (!done) {
|
|
300
987
|
return false;
|
|
301
988
|
}
|
|
@@ -304,44 +991,87 @@ class FileTransfer {
|
|
|
304
991
|
this.pendingPaste = null; });
|
|
305
992
|
return true;
|
|
306
993
|
}
|
|
307
|
-
/** Picks files on the Mac to send. */
|
|
994
|
+
/** Picks files on the Mac to send (not even asked for where they wouldn't go). */
|
|
308
995
|
async pickAndSend() {
|
|
996
|
+
if (!this.mayGoThere()) {
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
309
999
|
const files = await this.provider.showFilePicker({ multiple: true });
|
|
310
1000
|
this.send(files);
|
|
311
1001
|
}
|
|
312
|
-
/** 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. */
|
|
313
1003
|
offer(files) {
|
|
314
|
-
this.offered = files;
|
|
315
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;
|
|
316
1010
|
this.log(`files: the remote copied ${count}`);
|
|
317
1011
|
if (count) {
|
|
318
1012
|
this.toast(`${plural(count, 'file')} copied on the remote desktop`, [{ label: 'Save to Downloads', run: () => this.saveAll() }], 15000);
|
|
319
1013
|
}
|
|
320
1014
|
}
|
|
321
|
-
/**
|
|
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
|
+
*/
|
|
322
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
|
+
}
|
|
323
1027
|
const files = this.offered;
|
|
324
1028
|
const saved = [];
|
|
1029
|
+
let unmarked = 0;
|
|
325
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);
|
|
326
1039
|
try {
|
|
327
1040
|
for (const [index, file] of files.entries()) {
|
|
328
1041
|
if (file.isDirectory) {
|
|
329
1042
|
continue;
|
|
330
1043
|
}
|
|
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.
|
|
331
1054
|
const wanted = savePath(dir, file.path, file.name);
|
|
332
1055
|
const blob = await this.provider.downloadFile(file, index).completion;
|
|
333
|
-
|
|
1056
|
+
const made = [];
|
|
1057
|
+
const target = await place(dir, into(wanted, made), blob, made);
|
|
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
|
+
}
|
|
334
1065
|
}
|
|
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
|
-
}] : []);
|
|
1066
|
+
const note = done();
|
|
1067
|
+
this.toast(`Saved ${plural(saved.length, 'file')} to ${path.basename(dir)}${note}`, show());
|
|
341
1068
|
}
|
|
342
1069
|
catch (e) {
|
|
343
1070
|
this.log(`files: save: ${e?.message ?? e}`);
|
|
344
|
-
this
|
|
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());
|
|
345
1075
|
}
|
|
346
1076
|
return saved;
|
|
347
1077
|
}
|
|
@@ -374,8 +1104,12 @@ class FileTransfer {
|
|
|
374
1104
|
}
|
|
375
1105
|
}
|
|
376
1106
|
dispose() {
|
|
1107
|
+
this.disposed = true;
|
|
1108
|
+
this.waiting = [];
|
|
1109
|
+
this.dropReading();
|
|
1110
|
+
this.served.clear();
|
|
377
1111
|
this.stopListening.splice(0).forEach(stop => stop());
|
|
378
|
-
this.overlay.classList.remove('trd-drop-target');
|
|
1112
|
+
this.overlay.classList.remove('trd-drop-target', 'trd-drop-refused');
|
|
379
1113
|
clearTimeout(this.toastTimer);
|
|
380
1114
|
this.toastEl?.remove();
|
|
381
1115
|
try {
|
|
@@ -385,16 +1119,44 @@ class FileTransfer {
|
|
|
385
1119
|
}
|
|
386
1120
|
}
|
|
387
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
|
+
}
|
|
388
1149
|
/**
|
|
389
1150
|
* Where a file the remote offered goes under `dir`, keeping its folder structure. The names come from the remote
|
|
390
1151
|
* (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
|
|
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.
|
|
393
1155
|
*/
|
|
394
1156
|
function savePath(dir, folder, name) {
|
|
395
1157
|
const parts = [folder ?? '', name]
|
|
396
1158
|
.flatMap(p => String(p).split(/[\\/]+/))
|
|
397
|
-
.map(p => p.replace(
|
|
1159
|
+
.map(p => safeName(p.replace(/^[A-Za-z]:$/, '')))
|
|
398
1160
|
.filter(p => p && p !== '.' && p !== '..');
|
|
399
1161
|
const root = path.resolve(dir);
|
|
400
1162
|
const target = path.resolve(root, ...parts.length ? parts : ['file']);
|
|
@@ -403,6 +1165,53 @@ function savePath(dir, folder, name) {
|
|
|
403
1165
|
}
|
|
404
1166
|
return target;
|
|
405
1167
|
}
|
|
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
|
+
}
|
|
406
1215
|
/** `file.txt`, or `file 2.txt`, `file 3.txt`… if taken. For names of the plugin's own choosing (screenshots). */
|
|
407
1216
|
function uniquePath(target) {
|
|
408
1217
|
if (!fs.existsSync(target)) {
|
|
@@ -418,53 +1227,119 @@ function uniquePath(target) {
|
|
|
418
1227
|
}
|
|
419
1228
|
/**
|
|
420
1229
|
* 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
|
|
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.
|
|
423
1233
|
*/
|
|
424
|
-
async function foldersInside(dir, folder) {
|
|
1234
|
+
async function foldersInside(dir, folder, made = []) {
|
|
425
1235
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
426
1236
|
const root = await fs.promises.realpath(dir);
|
|
427
1237
|
let at = root;
|
|
428
1238
|
for (const part of path.relative(path.resolve(dir), folder).split(path.sep).filter(Boolean)) {
|
|
429
1239
|
const next = path.join(at, part);
|
|
430
|
-
await fs.promises.mkdir(next).
|
|
1240
|
+
const created = await fs.promises.mkdir(next).then(() => true, (e) => {
|
|
431
1241
|
if (e?.code !== 'EEXIST') {
|
|
432
1242
|
throw e;
|
|
433
1243
|
}
|
|
1244
|
+
return false;
|
|
434
1245
|
});
|
|
435
1246
|
at = await fs.promises.realpath(next);
|
|
436
1247
|
if (at !== root && !at.startsWith(root + path.sep)) {
|
|
437
1248
|
throw new Error(`refused to save through a link that leaves the folder: ${JSON.stringify(part)}`);
|
|
438
1249
|
}
|
|
1250
|
+
if (created) {
|
|
1251
|
+
made.push(at);
|
|
1252
|
+
}
|
|
439
1253
|
}
|
|
440
1254
|
return at;
|
|
441
1255
|
}
|
|
442
1256
|
/**
|
|
443
1257
|
* Puts a download at `wanted` under `dir` (or `name 2.ext`, `name 3.ext`… when taken) and returns where it went.
|
|
444
1258
|
* 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.
|
|
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.
|
|
446
1262
|
*/
|
|
447
|
-
async function place(dir, wanted, blob) {
|
|
448
|
-
const folder = await foldersInside(dir, path.dirname(wanted));
|
|
1263
|
+
async function place(dir, wanted, blob, made = []) {
|
|
1264
|
+
const folder = await foldersInside(dir, path.dirname(wanted), made);
|
|
449
1265
|
const { name, ext } = path.parse(wanted);
|
|
450
1266
|
const temporary = blob[DISK_PATH];
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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 {
|
|
455
1274
|
await fs.promises.copyFile(temporary, target, fs.constants.COPYFILE_EXCL);
|
|
456
|
-
|
|
1275
|
+
return true;
|
|
457
1276
|
}
|
|
458
|
-
|
|
459
|
-
|
|
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 });
|
|
460
1283
|
}
|
|
461
|
-
|
|
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');
|
|
462
1290
|
}
|
|
463
1291
|
catch (e) {
|
|
464
|
-
if (e?.code
|
|
465
|
-
|
|
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);
|
|
466
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' });
|
|
467
1321
|
}
|
|
468
1322
|
}
|
|
469
1323
|
}
|
|
470
|
-
|
|
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
|
+
}
|