tabby-rdp 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +580 -0
- package/README.md +252 -34
- 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 +1763 -204
- package/dist/desktopForm.js +72 -13
- package/dist/desktops.js +243 -19
- package/dist/drives.js +525 -0
- package/dist/fileTransfer.js +1064 -72
- package/dist/gateway.js +651 -0
- package/dist/header.js +76 -5
- package/dist/help.js +34 -13
- package/dist/hyperv.js +129 -0
- package/dist/index.js +2 -2
- package/dist/keyboard.js +0 -1
- package/dist/microphone.js +57 -13
- package/dist/ntlm.js +225 -0
- package/dist/osd.js +56 -15
- package/dist/probe.js +15 -3
- package/dist/rdcleanpath.js +468 -52
- package/dist/rdpFile.js +53 -4
- package/dist/rdpProfile.js +75 -22
- package/dist/remoteSetup.js +103 -23
- package/dist/sessionLog.js +53 -0
- package/dist/settingsPage.js +117 -27
- package/dist/signin.js +429 -54
- package/dist/ssh.js +99 -18
- package/dist/sshChannelStream.js +50 -6
- package/dist/targets.js +185 -39
- package/dist/ui.js +98 -20
- package/dist/unshowable.js +41 -0
- package/dist/updates.js +37 -2
- package/dist/vms.js +36 -5
- package/dist/wake.js +31 -12
- package/package.json +4 -3
- package/remote/trd-pty.py +74 -13
- package/vendor/SHA256SUMS +26 -0
- package/vendor/iron-remote-desktop-rdp.js +418 -390
- 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/fileTransfer.js.map +0 -1
- package/dist/header.js.map +0 -1
- package/dist/help.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/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/drives.js
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SharedDrives = exports.WINDOW_OPEN_FILES = exports.MAX_OPEN_FILES = void 0;
|
|
4
|
+
exports.sharedFolders = sharedFolders;
|
|
5
|
+
exports.driveName = driveName;
|
|
6
|
+
exports.openFilesInWindow = openFilesInWindow;
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const fsp = fs.promises;
|
|
10
|
+
/** The folders configured, tidied: strings and booleans where expected, no duplicate paths, each with a name. */
|
|
11
|
+
function sharedFolders(store) {
|
|
12
|
+
const list = Array.isArray(store?.sharedFolders) ? store.sharedFolders : [];
|
|
13
|
+
const folders = [];
|
|
14
|
+
for (const entry of list) {
|
|
15
|
+
const folder = typeof entry?.path === 'string' ? entry.path.trim() : '';
|
|
16
|
+
if (!folder || folders.some(f => f.path === folder)) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
folders.push({
|
|
20
|
+
path: folder,
|
|
21
|
+
name: driveName(typeof entry.name === 'string' && entry.name.trim() ? entry.name : path.basename(folder) || folder, folders),
|
|
22
|
+
readOnly: entry.readOnly === true,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return folders;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A drive name the remote can show: no characters a Windows file name can't have, not empty, and not another
|
|
29
|
+
* shared folder's name (a number is added).
|
|
30
|
+
*/
|
|
31
|
+
function driveName(wanted, others) {
|
|
32
|
+
const base = wanted.replace(/[\\/:*?"<>|\u0000-\u001f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 64) || 'Folder';
|
|
33
|
+
let name = base;
|
|
34
|
+
for (let n = 2; others.some(o => o.name.toLowerCase() === name.toLowerCase()); n++) {
|
|
35
|
+
name = `${base} ${n}`;
|
|
36
|
+
}
|
|
37
|
+
return name;
|
|
38
|
+
}
|
|
39
|
+
/** An error with a Node-style `code`, which IronRDP maps to the matching NT status for the server. */
|
|
40
|
+
function fail(code, message) {
|
|
41
|
+
const error = new Error(message);
|
|
42
|
+
error.code = code;
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
/** One read's worth: the server asks for 64 KiB or so; the proxy takes messages up to 32 MiB. */
|
|
46
|
+
const READ_LIMIT = 4 * 1024 * 1024;
|
|
47
|
+
/**
|
|
48
|
+
* Files a desktop may have open at once. Each one holds a file descriptor of Tabby's window, which all its tabs and
|
|
49
|
+
* connections share, and the server decides how many it opens and when it closes them: with no limit, a hostile one
|
|
50
|
+
* could take them all, and the window could then open no file or connection, or crash. Programs on the remote keep
|
|
51
|
+
* far fewer open. More are refused (EMFILE) until the server closes some.
|
|
52
|
+
*/
|
|
53
|
+
exports.MAX_OPEN_FILES = 1024;
|
|
54
|
+
/**
|
|
55
|
+
* Files all the desktops in a window may have open at once. The window's descriptors are one process's, so a limit per
|
|
56
|
+
* desktop alone would let several desktops together hold that many times more. The window may have 8192 open on
|
|
57
|
+
* Windows (the C runtime's most); on macOS, Node raises its soft limit to the hard one as it starts, which
|
|
58
|
+
* kern.maxfilesperproc caps (some 138,000 on a Mac measured with Electron 40). This many is a quarter of the smaller of
|
|
59
|
+
* those, which leaves most of them to everything else whatever the desktops do, and one desktop can't take it all
|
|
60
|
+
* (MAX_OPEN_FILES is half of it). On Linux the window's limit wasn't measured (the system's hard limit, often half a
|
|
61
|
+
* million or more, if Node raises it there as on macOS): where it is 4,096 or less, this many is half of it or more.
|
|
62
|
+
*/
|
|
63
|
+
exports.WINDOW_OPEN_FILES = 2 * exports.MAX_OPEN_FILES;
|
|
64
|
+
/**
|
|
65
|
+
* Files open, being opened, or being closed, through every SharedDrives of this window (the plugin is loaded once per
|
|
66
|
+
* window). A file counts until its close has finished: closing waits for what is under way on the file (a slow disk or
|
|
67
|
+
* network folder), and the descriptor is the window's until then.
|
|
68
|
+
*/
|
|
69
|
+
let openInWindow = 0;
|
|
70
|
+
/** Files open, being opened or being closed, on the desktops of this window (see WINDOW_OPEN_FILES). */
|
|
71
|
+
function openFilesInWindow() {
|
|
72
|
+
return openInWindow;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Space a desktop leaves free on the disk a shared file is on when it makes the file larger than the data it sends:
|
|
76
|
+
* a write past the end of a file, or a larger end of file. One request can ask for any size, and where files can't
|
|
77
|
+
* have holes (HFS+, NTFS, exFAT) the space is taken at once, on the whole disk; elsewhere the file looks petabytes
|
|
78
|
+
* long, and grows again with every request. Explorer sets a copy's size before writing it, so sizes aren't capped as
|
|
79
|
+
* such: the part of a file the server sent no data for (its size beyond what it has on disk, see room) may be no
|
|
80
|
+
* larger than the disk's free space less this much (1 GB, or a twentieth of a disk smaller than 20 GB). Data the
|
|
81
|
+
* server does send isn't checked: that takes as long as any copy.
|
|
82
|
+
*/
|
|
83
|
+
function reserve(total) {
|
|
84
|
+
return Math.min(1024 ** 3, Math.floor(total / 20));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Growth on each disk (by device number) in this window, one at a time across its desktops: from the reading of the
|
|
88
|
+
* disk's free space (see room) until the file has grown. A check then never goes by free space another desktop's growth
|
|
89
|
+
* has taken since it was read, so two desktops can't each be told the same free space is theirs. The latest growth's
|
|
90
|
+
* end, which the next one on that disk waits for.
|
|
91
|
+
*/
|
|
92
|
+
const growth = new Map();
|
|
93
|
+
/** Runs `grow` once the growth before it on disk `dev`, in any desktop of this window, has ended (see growth). */
|
|
94
|
+
async function inTurn(dev, grow) {
|
|
95
|
+
const before = growth.get(dev);
|
|
96
|
+
let done = () => { };
|
|
97
|
+
const mine = new Promise(resolve => { done = resolve; });
|
|
98
|
+
growth.set(dev, mine);
|
|
99
|
+
try {
|
|
100
|
+
await before;
|
|
101
|
+
return await grow();
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
done();
|
|
105
|
+
if (growth.get(dev) === mine) {
|
|
106
|
+
growth.delete(dev);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The shared folders as the remote desktop's file system (MS-RDPEFS drive redirection), on Tabby's Node side.
|
|
112
|
+
* IronRDP asks one thing at a time and waits for the answer, so everything here is asynchronous (Node's thread
|
|
113
|
+
* pool): a slow disk or network mount holds up the drive, not Tabby's window.
|
|
114
|
+
*
|
|
115
|
+
* The server's paths are relative to the drive, with backslashes. Each resolves inside its folder, also once
|
|
116
|
+
* symbolic links are followed: a path that leaves the folder, by `..` or through a link, doesn't exist as far as
|
|
117
|
+
* the remote is concerned. Only regular files and folders are served (no devices, pipes or sockets, which could
|
|
118
|
+
* block or do things on being opened). Read-only folders refuse anything that writes, and so does a file with more
|
|
119
|
+
* than one name (a hard link), which may be outside the folder too. What the server opens and how much space it
|
|
120
|
+
* takes without sending data are limited (MAX_OPEN_FILES and WINDOW_OPEN_FILES, reserve).
|
|
121
|
+
*/
|
|
122
|
+
class SharedDrives {
|
|
123
|
+
/** `flow`: the room for what is read for the server (the desktop's proxy, see ServerFlow and read). */
|
|
124
|
+
constructor(folders, log = () => null, flow) {
|
|
125
|
+
this.log = log;
|
|
126
|
+
this.flow = flow;
|
|
127
|
+
this.byId = new Map();
|
|
128
|
+
this.roots = new Map();
|
|
129
|
+
this.handles = new Map();
|
|
130
|
+
this.nextHandle = 1;
|
|
131
|
+
/** Opens under way, which count towards MAX_OPEN_FILES too. */
|
|
132
|
+
this.opening = 0;
|
|
133
|
+
/** Closes under way, which count too: the descriptor is still open until the close has finished. */
|
|
134
|
+
this.closing = 0;
|
|
135
|
+
/** A limit on open files was reached (and logged, once). */
|
|
136
|
+
this.full = false;
|
|
137
|
+
/** The connection ended (see dispose): nothing more is opened, and what was still opening is closed again. */
|
|
138
|
+
this.disposed = false;
|
|
139
|
+
// RDPDR device ids are the session's: the audio device, when there is one, takes 0 in IronRDP.
|
|
140
|
+
this.drives = folders.map((folder, i) => {
|
|
141
|
+
this.byId.set(i + 1, folder);
|
|
142
|
+
return { id: i + 1, name: folder.name };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Closes whatever the server left open (the connection ended), and what it was still opening once that is open.
|
|
147
|
+
* Each counts towards the limits until its close has finished (see closeFile), not before.
|
|
148
|
+
*/
|
|
149
|
+
dispose() {
|
|
150
|
+
this.disposed = true;
|
|
151
|
+
for (const handle of this.handles.values()) {
|
|
152
|
+
this.closeFile(handle.file).catch(() => null);
|
|
153
|
+
}
|
|
154
|
+
this.handles.clear();
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Closes a file that was counted as open (or being opened), and stops counting it once the close has finished,
|
|
158
|
+
* whether it worked or not: closing waits for what is under way on the file, and a slot handed on before then would
|
|
159
|
+
* let more descriptors be open than the limits allow.
|
|
160
|
+
*/
|
|
161
|
+
async closeFile(file) {
|
|
162
|
+
this.closing++;
|
|
163
|
+
try {
|
|
164
|
+
await file.close();
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
this.closing--;
|
|
168
|
+
openInWindow--;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** The folder's own real path (it may be reached through a link itself, as /tmp is on macOS). */
|
|
172
|
+
rootOf(folder) {
|
|
173
|
+
let root = this.roots.get(folder);
|
|
174
|
+
if (!root) {
|
|
175
|
+
root = fsp.realpath(folder.path);
|
|
176
|
+
this.roots.set(folder, root);
|
|
177
|
+
root.catch(() => this.roots.delete(folder)); // not there now: look again next time
|
|
178
|
+
}
|
|
179
|
+
return root;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The place of `remote` on drive `deviceId`. `..` doesn't resolve, and neither does a path whose links lead out
|
|
183
|
+
* of the shared folder (ENOENT either way). With `follow`, the last component's link is followed too (reading,
|
|
184
|
+
* listing, opening); without, it is left alone (removing or renaming a link works on the link, not its target).
|
|
185
|
+
* What is there, if anything, is for the caller to find out.
|
|
186
|
+
*/
|
|
187
|
+
async locate(deviceId, remote, follow) {
|
|
188
|
+
const folder = this.byId.get(deviceId) ?? fail('ENOENT', `no drive ${deviceId}`);
|
|
189
|
+
const parts = [];
|
|
190
|
+
for (const part of remote.split(/[\\/]+/)) {
|
|
191
|
+
if (part === '' || part === '.') {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (part === '..' || part.includes('\0')) {
|
|
195
|
+
fail('ENOENT', `${remote}: not inside ${folder.name}`);
|
|
196
|
+
}
|
|
197
|
+
parts.push(part);
|
|
198
|
+
}
|
|
199
|
+
const root = await this.rootOf(folder);
|
|
200
|
+
if (!parts.length) {
|
|
201
|
+
return { folder, local: root, root: true };
|
|
202
|
+
}
|
|
203
|
+
const inside = (real) => real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
204
|
+
const outside = () => fail('ENOENT', `${remote}: leads out of ${folder.name}`);
|
|
205
|
+
// The folder it is in, links followed: there, and inside.
|
|
206
|
+
const parent = await fsp.realpath(path.join(root, ...parts.slice(0, -1)));
|
|
207
|
+
if (!inside(parent)) {
|
|
208
|
+
outside();
|
|
209
|
+
}
|
|
210
|
+
const local = path.join(parent, parts[parts.length - 1]);
|
|
211
|
+
if (!follow) {
|
|
212
|
+
return { folder, local, root: false };
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const real = await fsp.realpath(local);
|
|
216
|
+
return inside(real) ? { folder, local: real, root: real === root } : outside();
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
if (e?.code !== 'ENOENT') {
|
|
220
|
+
throw e;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Nothing there. A link to nothing isn't a place to make something: it would be made where the link points.
|
|
224
|
+
if (await fsp.lstat(local).then(() => true, () => false)) {
|
|
225
|
+
outside();
|
|
226
|
+
}
|
|
227
|
+
return { folder, local, root: false };
|
|
228
|
+
}
|
|
229
|
+
writable(folder, what) {
|
|
230
|
+
if (folder.readOnly) {
|
|
231
|
+
fail('EACCES', `${folder.name} is shared read-only: ${what}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
handle(id) {
|
|
235
|
+
return this.handles.get(id) ?? fail('EBADF', `no open file ${id}`);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Makes sure the disk of an open file (`now`: its stat) can take the file being `end` bytes long without the server
|
|
239
|
+
* sending data for what that adds (see reserve). What counts is the part of the file that isn't on disk, from what
|
|
240
|
+
* the file has there, not from its size: on file systems with holes, growing a file takes no space, so growing it
|
|
241
|
+
* again and again would pass a check of each growth alone. Called in the disk's turn (see grow).
|
|
242
|
+
*/
|
|
243
|
+
async room(handle, now, end) {
|
|
244
|
+
const space = await this.spaceOf(handle, now);
|
|
245
|
+
// 512-byte blocks, as POSIX counts them (and libuv on Windows); a file system that doesn't say has its size.
|
|
246
|
+
const onDisk = Number.isFinite(now.blocks) ? now.blocks * 512 : now.size;
|
|
247
|
+
const unsent = Math.max(0, end - onDisk);
|
|
248
|
+
if (unsent > space.bsize * space.bavail - reserve(space.bsize * space.blocks)) {
|
|
249
|
+
fail('ENOSPC', `${handle.folder.name}: not enough free space for a file ${end} bytes long`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Makes an open file larger than the data the server sends for it, with `change`: `end` bytes long, with no data
|
|
254
|
+
* from `from` (past the end of the file now) on. In its disk's turn (see growth), once room() has checked the space
|
|
255
|
+
* against the file as it is then: another request may have changed it while this one waited its turn.
|
|
256
|
+
*/
|
|
257
|
+
grow(handle, dev, from, end, change) {
|
|
258
|
+
return inTurn(dev, async () => {
|
|
259
|
+
const now = await handle.file.stat();
|
|
260
|
+
if (from > now.size) {
|
|
261
|
+
await this.room(handle, now, end);
|
|
262
|
+
}
|
|
263
|
+
return change();
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The free space of the disk an open file (`now`) is on: that of the folder it was opened in, or else of the shared
|
|
268
|
+
* folder, whichever is on that disk (the same device). A disk mounted somewhere in a shared folder (a USB stick in
|
|
269
|
+
* a shared /Volumes, a disk image in a shared home) has its own free space, which the shared folder's doesn't say.
|
|
270
|
+
* When neither is on it (the file's folder was moved away meanwhile), the space isn't known, and the file isn't
|
|
271
|
+
* grown (ENOSPC).
|
|
272
|
+
*/
|
|
273
|
+
async spaceOf(handle, now) {
|
|
274
|
+
for (const dir of [path.dirname(handle.local), await this.rootOf(handle.folder).catch(() => null)]) {
|
|
275
|
+
if (dir === null) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const [at, space] = await Promise.all([fsp.stat(dir), fsp.statfs(dir)]).catch(() => [null, null]);
|
|
279
|
+
if (at && space && at.dev === now.dev) {
|
|
280
|
+
return space;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return fail('ENOSPC', `${handle.folder.name}: the free space of this file's disk isn't known`);
|
|
284
|
+
}
|
|
285
|
+
async stat(deviceId, remote) {
|
|
286
|
+
let s;
|
|
287
|
+
let folder;
|
|
288
|
+
try {
|
|
289
|
+
const place = await this.locate(deviceId, remote, true);
|
|
290
|
+
folder = place.folder;
|
|
291
|
+
s = await fsp.stat(place.local);
|
|
292
|
+
}
|
|
293
|
+
catch (e) {
|
|
294
|
+
if (e?.code === 'ENOENT' || e?.code === 'ENOTDIR') {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
throw e;
|
|
298
|
+
}
|
|
299
|
+
// Devices, pipes and sockets aren't shown.
|
|
300
|
+
if (!s.isFile() && !s.isDirectory()) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
dir: s.isDirectory(),
|
|
305
|
+
size: s.isDirectory() ? 0 : s.size,
|
|
306
|
+
created: s.birthtimeMs || s.ctimeMs,
|
|
307
|
+
accessed: s.atimeMs,
|
|
308
|
+
modified: s.mtimeMs,
|
|
309
|
+
// A file with other names (hard links) isn't changed through the share (see open), so it shows read-only.
|
|
310
|
+
readonly: folder.readOnly || (s.mode & 0o200) === 0 || (s.isFile() && s.nlink > 1),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
async list(deviceId, remote) {
|
|
314
|
+
const { local } = await this.locate(deviceId, remote, true);
|
|
315
|
+
return fsp.readdir(local);
|
|
316
|
+
}
|
|
317
|
+
async open(deviceId, remote, mode) {
|
|
318
|
+
if (this.disposed) {
|
|
319
|
+
fail('EBADF', `${remote}: the connection has ended`);
|
|
320
|
+
}
|
|
321
|
+
const mine = this.handles.size + this.opening + this.closing >= exports.MAX_OPEN_FILES;
|
|
322
|
+
if (mine || openInWindow >= exports.WINDOW_OPEN_FILES) {
|
|
323
|
+
if (!this.full) {
|
|
324
|
+
this.full = true;
|
|
325
|
+
this.log(mine
|
|
326
|
+
? `drives: the remote has ${exports.MAX_OPEN_FILES} files open, the most a desktop may; more are refused until it closes some`
|
|
327
|
+
: `drives: the desktops in this window have ${exports.WINDOW_OPEN_FILES} files open, the most they may together; more are refused until some are closed`);
|
|
328
|
+
}
|
|
329
|
+
fail('EMFILE', `${remote}: ${mine ? exports.MAX_OPEN_FILES : exports.WINDOW_OPEN_FILES} files are open already`);
|
|
330
|
+
}
|
|
331
|
+
this.opening++;
|
|
332
|
+
openInWindow++;
|
|
333
|
+
let handle;
|
|
334
|
+
try {
|
|
335
|
+
handle = await this.openFile(deviceId, remote, mode);
|
|
336
|
+
}
|
|
337
|
+
catch (e) {
|
|
338
|
+
openInWindow--;
|
|
339
|
+
throw e;
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
this.opening--;
|
|
343
|
+
}
|
|
344
|
+
// The connection ended while the file was being opened: dispose() has closed the others, and nothing would
|
|
345
|
+
// close this one.
|
|
346
|
+
if (this.disposed) {
|
|
347
|
+
await this.closeFile(handle.file).catch(() => null);
|
|
348
|
+
fail('EBADF', `${remote}: the connection has ended`);
|
|
349
|
+
}
|
|
350
|
+
const id = this.nextHandle++;
|
|
351
|
+
this.handles.set(id, handle);
|
|
352
|
+
return id;
|
|
353
|
+
}
|
|
354
|
+
async openFile(deviceId, remote, mode) {
|
|
355
|
+
const { folder, local } = await this.locate(deviceId, remote, true);
|
|
356
|
+
if (mode.write || mode.create || mode.truncate) {
|
|
357
|
+
this.writable(folder, `can't write ${remote}`);
|
|
358
|
+
}
|
|
359
|
+
const existing = await fsp.stat(local).catch(() => null);
|
|
360
|
+
if (existing && !existing.isFile()) {
|
|
361
|
+
fail(existing.isDirectory() ? 'EISDIR' : 'EACCES', `${remote}: not a file`);
|
|
362
|
+
}
|
|
363
|
+
// The path was resolved just now: a link (or a pipe) put in its place meanwhile isn't followed (or waited on).
|
|
364
|
+
// Truncating waits until the file is known to be one the remote may change (below).
|
|
365
|
+
const { O_RDONLY, O_RDWR, O_CREAT, O_EXCL } = fs.constants;
|
|
366
|
+
const guard = (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
|
|
367
|
+
const flags = (mode.write || mode.truncate ? O_RDWR : O_RDONLY) | (mode.create ? O_CREAT : 0) | (mode.exclusive ? O_EXCL : 0) | guard;
|
|
368
|
+
const file = await fsp.open(local, flags, 0o644);
|
|
369
|
+
try {
|
|
370
|
+
const s = await file.stat();
|
|
371
|
+
if (!s.isFile()) {
|
|
372
|
+
fail('EACCES', `${remote}: not a file`);
|
|
373
|
+
}
|
|
374
|
+
// A file with another name (a hard link) is the same file there, which may be outside the shared folder:
|
|
375
|
+
// what the remote writes would change it there too. Reading it is reading what is in the folder.
|
|
376
|
+
if ((mode.write || mode.truncate) && s.nlink > 1) {
|
|
377
|
+
fail('EACCES', `${remote}: has more than one name (a hard link), so it isn't changed from the remote`);
|
|
378
|
+
}
|
|
379
|
+
if (mode.truncate) {
|
|
380
|
+
await file.truncate(0);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch (e) {
|
|
384
|
+
await file.close().catch(() => null);
|
|
385
|
+
throw e;
|
|
386
|
+
}
|
|
387
|
+
return { file, folder, local };
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* A read of the server's: up to READ_LIMIT, as much as it asks for. Its answer is handed to the connection, where
|
|
391
|
+
* it stays, in the window's memory, until the proxy has read it, as the server takes what is sent: a server that
|
|
392
|
+
* keeps asking and reads slowly, or not at all, would otherwise have every answer kept there. So it takes room for
|
|
393
|
+
* it first (see ServerFlow), waiting while there is none, and gives back what it didn't read.
|
|
394
|
+
*
|
|
395
|
+
* How far the room holds the answers back: see ServerFlow (the proxy reading the connection's other messages makes
|
|
396
|
+
* room too).
|
|
397
|
+
*/
|
|
398
|
+
async read(handle, offset, length) {
|
|
399
|
+
const size = Math.min(length, READ_LIMIT);
|
|
400
|
+
while (this.flow && !this.flow.room()) {
|
|
401
|
+
await this.flow.whenRoom();
|
|
402
|
+
}
|
|
403
|
+
const { file } = this.handle(handle);
|
|
404
|
+
this.flow?.spend(size);
|
|
405
|
+
const buffer = Buffer.allocUnsafe(size);
|
|
406
|
+
let read = 0;
|
|
407
|
+
try {
|
|
408
|
+
// A read can come back short before the end of the file: fill what was asked.
|
|
409
|
+
while (read < buffer.length) {
|
|
410
|
+
const { bytesRead } = await file.read(buffer, read, buffer.length - read, offset + read);
|
|
411
|
+
if (bytesRead === 0) {
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
read += bytesRead;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (e) {
|
|
418
|
+
this.flow?.refund(size);
|
|
419
|
+
throw e;
|
|
420
|
+
}
|
|
421
|
+
this.flow?.refund(size - read);
|
|
422
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, read);
|
|
423
|
+
}
|
|
424
|
+
async write(handle, offset, data) {
|
|
425
|
+
const entry = this.handle(handle);
|
|
426
|
+
const { file, folder } = entry;
|
|
427
|
+
this.writable(folder, 'can\'t write');
|
|
428
|
+
const end = offset + data.length;
|
|
429
|
+
if (!(offset >= 0 && Number.isSafeInteger(offset) && Number.isSafeInteger(end))) {
|
|
430
|
+
fail('ENOSPC', `can't write at ${offset}: past what a file can hold`);
|
|
431
|
+
}
|
|
432
|
+
const writeAll = async () => {
|
|
433
|
+
let written = 0;
|
|
434
|
+
while (written < data.length) {
|
|
435
|
+
written += (await file.write(data, written, data.length - written, offset + written)).bytesWritten;
|
|
436
|
+
}
|
|
437
|
+
return written;
|
|
438
|
+
};
|
|
439
|
+
// Past the end of the file, the gap up to the data is made as well, with nothing sent for it (see room).
|
|
440
|
+
if (offset > 0) {
|
|
441
|
+
const now = await file.stat();
|
|
442
|
+
if (offset > now.size) {
|
|
443
|
+
return this.grow(entry, now.dev, offset, end, writeAll);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return writeAll();
|
|
447
|
+
}
|
|
448
|
+
async truncate(handle, size) {
|
|
449
|
+
const entry = this.handle(handle);
|
|
450
|
+
const { file, folder } = entry;
|
|
451
|
+
this.writable(folder, 'can\'t resize');
|
|
452
|
+
if (!(size >= 0 && Number.isSafeInteger(size))) {
|
|
453
|
+
fail('ENOSPC', `can't make a file ${size} bytes long`);
|
|
454
|
+
}
|
|
455
|
+
// A larger end of file: what it adds has no data sent for it (see room).
|
|
456
|
+
const now = await file.stat();
|
|
457
|
+
if (size > now.size) {
|
|
458
|
+
return this.grow(entry, now.dev, size, size, () => file.truncate(size));
|
|
459
|
+
}
|
|
460
|
+
await file.truncate(size);
|
|
461
|
+
}
|
|
462
|
+
async close(handle) {
|
|
463
|
+
const entry = this.handles.get(handle);
|
|
464
|
+
if (entry) {
|
|
465
|
+
this.handles.delete(handle);
|
|
466
|
+
await this.closeFile(entry.file);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
async mkdir(deviceId, remote) {
|
|
470
|
+
const { folder, local } = await this.locate(deviceId, remote, true);
|
|
471
|
+
this.writable(folder, `can't make ${remote}`);
|
|
472
|
+
await fsp.mkdir(local);
|
|
473
|
+
}
|
|
474
|
+
async remove(deviceId, remote) {
|
|
475
|
+
const { folder, local, root } = await this.locate(deviceId, remote, false);
|
|
476
|
+
this.writable(folder, `can't remove ${remote}`);
|
|
477
|
+
if (root) {
|
|
478
|
+
fail('EACCES', `${folder.name}: the shared folder itself stays`);
|
|
479
|
+
}
|
|
480
|
+
// Directories only when empty, as on Windows (ENOTEMPTY otherwise); the server removes contents first. A
|
|
481
|
+
// link is removed itself, whatever it points to.
|
|
482
|
+
if ((await fsp.lstat(local)).isDirectory()) {
|
|
483
|
+
await fsp.rmdir(local);
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
await fsp.unlink(local);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
async rename(deviceId, from, to, replace) {
|
|
490
|
+
const source = await this.locate(deviceId, from, false);
|
|
491
|
+
const target = await this.locate(deviceId, to, false);
|
|
492
|
+
this.writable(source.folder, `can't rename ${from}`);
|
|
493
|
+
if (source.root || target.root) {
|
|
494
|
+
fail('EACCES', `${source.folder.name}: the shared folder itself stays`);
|
|
495
|
+
}
|
|
496
|
+
const moving = await fsp.lstat(source.local);
|
|
497
|
+
const existing = await fsp.lstat(target.local).catch(() => null);
|
|
498
|
+
// Something else in the way: only when asked to replace it. The same file under another spelling of its
|
|
499
|
+
// name (a case-insensitive file system) is a plain rename.
|
|
500
|
+
if (existing && !(existing.dev === moving.dev && existing.ino === moving.ino)) {
|
|
501
|
+
if (!replace) {
|
|
502
|
+
fail('EEXIST', `${to} exists`);
|
|
503
|
+
}
|
|
504
|
+
if (existing.isDirectory() || moving.isDirectory()) {
|
|
505
|
+
fail('EACCES', `${to}: folders aren't replaced`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
await fsp.rename(source.local, target.local);
|
|
509
|
+
}
|
|
510
|
+
async volume(deviceId) {
|
|
511
|
+
const folder = this.byId.get(deviceId) ?? fail('ENOENT', `no drive ${deviceId}`);
|
|
512
|
+
try {
|
|
513
|
+
const s = await fsp.statfs(await this.rootOf(folder));
|
|
514
|
+
const total = s.bsize * s.blocks;
|
|
515
|
+
// Free space less the reserve, which the remote can't take by making files larger (see room): Explorer
|
|
516
|
+
// checks a copy fits before starting it.
|
|
517
|
+
return { total, free: Math.max(0, s.bsize * s.bavail - reserve(total)), label: folder.name };
|
|
518
|
+
}
|
|
519
|
+
catch (e) {
|
|
520
|
+
this.log(`drives: ${folder.name}: no volume information: ${e?.message ?? e}`);
|
|
521
|
+
return { total: 0, free: 0, label: folder.name };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
exports.SharedDrives = SharedDrives;
|