tassello 0.2.0 → 0.4.0
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 +53 -0
- package/README.md +175 -8
- package/dist/server/behavior_tree_files.d.ts +0 -1
- package/dist/server/behavior_tree_files.js +2 -25
- package/dist/server/diagnostics.d.ts +16 -0
- package/dist/server/diagnostics.js +200 -0
- package/dist/server/diagnostics_state.d.ts +23 -0
- package/dist/server/diagnostics_state.js +146 -0
- package/dist/server/disks.d.ts +14 -0
- package/dist/server/disks.js +152 -0
- package/dist/server/file_manager.d.ts +25 -0
- package/dist/server/file_manager.js +717 -0
- package/dist/server/http.js +248 -5
- package/dist/server/netpbm.d.ts +3 -0
- package/dist/server/netpbm.js +69 -0
- package/dist/server/networking.d.ts +41 -0
- package/dist/server/networking.js +508 -0
- package/dist/server/networking_format.d.ts +38 -0
- package/dist/server/networking_format.js +651 -0
- package/dist/server/pty.d.ts +26 -0
- package/dist/server/pty.js +150 -0
- package/dist/server/server.js +13 -2
- package/dist/server/session.d.ts +10 -1
- package/dist/server/session.js +88 -1
- package/dist/server/terminal_output.d.ts +25 -0
- package/dist/server/terminal_output.js +108 -0
- package/dist/server/terminals.d.ts +32 -0
- package/dist/server/terminals.js +366 -0
- package/dist/server/util.d.ts +4 -0
- package/dist/server/util.js +45 -1
- package/dist/shared/file_manager.d.ts +130 -0
- package/dist/shared/file_manager.js +7 -0
- package/dist/shared/networking.d.ts +178 -0
- package/dist/shared/networking.js +248 -0
- package/dist/shared/protocol.d.ts +103 -2
- package/dist/shared/protocol.js +8 -0
- package/dist/types/client/api.d.ts +22 -0
- package/dist/types/client/connection.d.ts +30 -1
- package/dist/types/client/icons.d.ts +33 -0
- package/dist/types/client/sdk/index.d.ts +5 -3
- package/dist/types/shared/file_manager.d.ts +130 -0
- package/dist/types/shared/networking.d.ts +178 -0
- package/dist/types/shared/protocol.d.ts +103 -2
- package/dist/www/assets/Joystick.js +1 -1
- package/dist/www/assets/RobotModelView.js +2 -2
- package/dist/www/assets/TerminalView.css +1 -0
- package/dist/www/assets/TerminalView.js +17 -0
- package/dist/www/assets/dialogs.js +1 -0
- package/dist/www/assets/icons.js +1 -1
- package/dist/www/assets/index.css +1 -1
- package/dist/www/assets/index.js +59 -19
- package/dist/www/assets/keys.js +3 -0
- package/dist/www/assets/shared/tassello-sdk.js +1 -1
- package/dist/www/index.html +2 -0
- package/docs/plugins.md +22 -0
- package/package.json +6 -2
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// The terminal widgets' terminals: the user's login shell, or a command it
|
|
2
|
+
// runs, on a pseudo-terminal (pty.ts), as the server's user.
|
|
3
|
+
//
|
|
4
|
+
// A terminal outlives its pages: a page that reloads or reconnects opens it
|
|
5
|
+
// again by its name and gets the output it missed. That output is also
|
|
6
|
+
// written to a terminal of the server's own (xterm.js, headless), whose
|
|
7
|
+
// screen and scrollback a page gets instead when it shows none of it yet, or
|
|
8
|
+
// lacks more than a window of it (TERMINAL_WINDOW). The server's terminal
|
|
9
|
+
// paces the program, which waits while it is behind; the pages do not: a slow
|
|
10
|
+
// page, or a hidden one, gets the screen later, never holding the program up.
|
|
11
|
+
//
|
|
12
|
+
// A terminal that no page shows is hung up after LINGER_MS, as is the one
|
|
13
|
+
// unseen the longest when MAX_TERMINALS are open and another is opened.
|
|
14
|
+
import { statSync } from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import serializeModule from '@xterm/addon-serialize';
|
|
17
|
+
import headlessModule from '@xterm/headless';
|
|
18
|
+
import { TERMINAL_SCROLLBACK, TERMINAL_WINDOW, } from '../shared/protocol.js';
|
|
19
|
+
import { resolvePath } from './file_manager.js';
|
|
20
|
+
import { Pty } from './pty.js';
|
|
21
|
+
import { Delivery, OutputLog } from './terminal_output.js';
|
|
22
|
+
import { errorMessage } from './util.js';
|
|
23
|
+
// CommonJS modules, without named exports for Node
|
|
24
|
+
const { SerializeAddon } = serializeModule;
|
|
25
|
+
const { Terminal: Screen } = headlessModule;
|
|
26
|
+
const LINGER_MS = 60 * 60_000;
|
|
27
|
+
const MAX_TERMINALS = 16;
|
|
28
|
+
// Output kept for the pages that missed some
|
|
29
|
+
const LOG_LIMIT = 1024 * 1024;
|
|
30
|
+
// The program waits while the server's terminal has more than this to write,
|
|
31
|
+
// until it has less than RESUME_BELOW
|
|
32
|
+
const PAUSE_ABOVE = 128 * 1024;
|
|
33
|
+
const RESUME_BELOW = 32 * 1024;
|
|
34
|
+
// Output goes out at most this often, gathered
|
|
35
|
+
const FLUSH_MS = 5;
|
|
36
|
+
const MAX_COLS = 1000;
|
|
37
|
+
const MAX_ROWS = 500;
|
|
38
|
+
function isFile(file) {
|
|
39
|
+
try {
|
|
40
|
+
return statSync(file).isFile();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// The user's, unless it lets no one in (a service's user)
|
|
47
|
+
function loginShell() {
|
|
48
|
+
for (const shell of [process.env.SHELL, os.userInfo().shell]) {
|
|
49
|
+
if (shell && !/\/(nologin|false)$/.test(shell) && isFile(shell)) {
|
|
50
|
+
return shell;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return '/bin/bash';
|
|
54
|
+
}
|
|
55
|
+
// The folder to start in: the home folder if it cannot be, and why
|
|
56
|
+
function workingFolder(folder) {
|
|
57
|
+
const home = os.homedir();
|
|
58
|
+
try {
|
|
59
|
+
const cwd = resolvePath(folder || '~');
|
|
60
|
+
return statSync(cwd).isDirectory() ? { cwd, problem: null } : { cwd: home, problem: 'not a folder' };
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
return { cwd: home, problem: e.code === 'ENOENT' ? 'not found' : errorMessage(e) };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function programEnvironment() {
|
|
67
|
+
const env = {
|
|
68
|
+
...process.env,
|
|
69
|
+
TERM: 'xterm-256color',
|
|
70
|
+
COLORTERM: 'truecolor',
|
|
71
|
+
TERM_PROGRAM: 'tassello',
|
|
72
|
+
};
|
|
73
|
+
// The server's own, as a systemd service: not the terminal's
|
|
74
|
+
for (const name of ['NOTIFY_SOCKET', 'JOURNAL_STREAM', 'INVOCATION_ID', 'COLUMNS', 'LINES']) {
|
|
75
|
+
delete env[name];
|
|
76
|
+
}
|
|
77
|
+
return env;
|
|
78
|
+
}
|
|
79
|
+
// One run of a terminal's program: the program, its output and its screen
|
|
80
|
+
class Run {
|
|
81
|
+
started;
|
|
82
|
+
pty;
|
|
83
|
+
screen;
|
|
84
|
+
serializer = new SerializeAddon();
|
|
85
|
+
log = new OutputLog(LOG_LIMIT);
|
|
86
|
+
// Of the output: received, and written to the screen
|
|
87
|
+
written = 0;
|
|
88
|
+
parsed = 0;
|
|
89
|
+
paused = false;
|
|
90
|
+
title = '';
|
|
91
|
+
// undefined while it runs
|
|
92
|
+
exitCode = undefined;
|
|
93
|
+
// Why it could not start
|
|
94
|
+
error = null;
|
|
95
|
+
constructor(started, pty, cols, rows) {
|
|
96
|
+
this.started = started;
|
|
97
|
+
this.pty = pty;
|
|
98
|
+
this.screen = new Screen({ cols, rows, scrollback: TERMINAL_SCROLLBACK, allowProposedApi: true });
|
|
99
|
+
// The addon is typed for the page's terminal, which the headless one is
|
|
100
|
+
// made of
|
|
101
|
+
this.screen.loadAddon(this.serializer);
|
|
102
|
+
this.screen.onTitleChange((title) => {
|
|
103
|
+
this.title = title;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// The screen and its scrollback, as of the output parsed
|
|
107
|
+
snapshot() {
|
|
108
|
+
return this.serializer.serialize({ scrollback: TERMINAL_SCROLLBACK });
|
|
109
|
+
}
|
|
110
|
+
dispose() {
|
|
111
|
+
this.pty.hangUp();
|
|
112
|
+
this.screen.dispose();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
class Terminal {
|
|
116
|
+
name;
|
|
117
|
+
command;
|
|
118
|
+
folder;
|
|
119
|
+
// While no page shows it: since when
|
|
120
|
+
hiddenSince = null;
|
|
121
|
+
// The pages showing it, and what each was sent
|
|
122
|
+
watchers = new Map();
|
|
123
|
+
logger;
|
|
124
|
+
onEnd;
|
|
125
|
+
run;
|
|
126
|
+
cols;
|
|
127
|
+
rows;
|
|
128
|
+
flushTimer = null;
|
|
129
|
+
lingerTimer = null;
|
|
130
|
+
constructor(request, logger, onEnd) {
|
|
131
|
+
this.name = request.terminal;
|
|
132
|
+
this.command = request.command;
|
|
133
|
+
this.folder = request.folder;
|
|
134
|
+
this.cols = request.cols;
|
|
135
|
+
this.rows = request.rows;
|
|
136
|
+
this.logger = logger;
|
|
137
|
+
this.onEnd = onEnd;
|
|
138
|
+
this.start();
|
|
139
|
+
}
|
|
140
|
+
attach(watcher, started, offset, cols, rows) {
|
|
141
|
+
if (this.lingerTimer) {
|
|
142
|
+
clearTimeout(this.lingerTimer);
|
|
143
|
+
this.lingerTimer = null;
|
|
144
|
+
}
|
|
145
|
+
this.hiddenSince = null;
|
|
146
|
+
this.resize(cols, rows);
|
|
147
|
+
const { run } = this;
|
|
148
|
+
const delivery = new Delivery(0);
|
|
149
|
+
this.watchers.set(watcher, delivery);
|
|
150
|
+
watcher.onStatus(run.error ? 'error' : 'streaming', run.error ?? '');
|
|
151
|
+
if (started === run.started && offset !== null && run.log.since(offset) !== null) {
|
|
152
|
+
// The page missed some: what follows
|
|
153
|
+
delivery.offset = offset;
|
|
154
|
+
}
|
|
155
|
+
else if (run.written === 0) {
|
|
156
|
+
// A new program: the page keeps what it shows of another
|
|
157
|
+
watcher.onOutput({ started: run.started, offset: 0, reset: false, data: '', title: null });
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
this.sendSnapshot(watcher, delivery);
|
|
161
|
+
}
|
|
162
|
+
this.deliver(watcher, delivery);
|
|
163
|
+
if (run.exitCode !== undefined) {
|
|
164
|
+
watcher.onExit({ started: run.started, exitCode: run.exitCode });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
detach(watcher) {
|
|
168
|
+
if (!this.watchers.delete(watcher) || this.watchers.size > 0) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.hiddenSince = Date.now();
|
|
172
|
+
this.lingerTimer = setTimeout(() => {
|
|
173
|
+
this.logger.info(`Terminal ${this.name}: not shown for ${LINGER_MS / 60_000} minutes, hung up`);
|
|
174
|
+
this.end();
|
|
175
|
+
}, LINGER_MS);
|
|
176
|
+
}
|
|
177
|
+
input(data) {
|
|
178
|
+
if (this.run.exitCode === undefined) {
|
|
179
|
+
this.run.pty.write(data);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
resize(cols, rows) {
|
|
183
|
+
cols = Math.min(MAX_COLS, Math.max(2, Math.round(cols)));
|
|
184
|
+
rows = Math.min(MAX_ROWS, Math.max(1, Math.round(rows)));
|
|
185
|
+
if (cols === this.cols && rows === this.rows) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.cols = cols;
|
|
189
|
+
this.rows = rows;
|
|
190
|
+
this.run.pty.resize(cols, rows);
|
|
191
|
+
this.run.screen.resize(cols, rows);
|
|
192
|
+
}
|
|
193
|
+
ack(watcher, started, offset) {
|
|
194
|
+
const delivery = this.watchers.get(watcher);
|
|
195
|
+
if (delivery && started === this.run.started) {
|
|
196
|
+
delivery.ack(offset);
|
|
197
|
+
this.deliver(watcher, delivery);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Its program anew, from a clean screen; with another command or folder
|
|
201
|
+
restart(command = this.command, folder = this.folder) {
|
|
202
|
+
this.command = command;
|
|
203
|
+
this.folder = folder;
|
|
204
|
+
this.run.dispose();
|
|
205
|
+
this.start();
|
|
206
|
+
for (const watcher of this.watchers.keys()) {
|
|
207
|
+
this.watchers.set(watcher, new Delivery(0));
|
|
208
|
+
watcher.onStatus(this.run.error ? 'error' : 'streaming', this.run.error ?? '');
|
|
209
|
+
watcher.onOutput({ started: this.run.started, offset: 0, reset: false, data: '', title: null });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
end() {
|
|
213
|
+
if (this.flushTimer) {
|
|
214
|
+
clearTimeout(this.flushTimer);
|
|
215
|
+
}
|
|
216
|
+
if (this.lingerTimer) {
|
|
217
|
+
clearTimeout(this.lingerTimer);
|
|
218
|
+
}
|
|
219
|
+
this.run.dispose();
|
|
220
|
+
this.onEnd();
|
|
221
|
+
}
|
|
222
|
+
start() {
|
|
223
|
+
const { cwd, problem } = workingFolder(this.folder);
|
|
224
|
+
const shell = loginShell();
|
|
225
|
+
const args = this.command ? ['-l', '-c', this.command] : ['-l'];
|
|
226
|
+
const pty = new Pty({ file: shell, args, cwd, env: programEnvironment(), cols: this.cols, rows: this.rows });
|
|
227
|
+
// Unique to this terminal: what tells the pages it is another program
|
|
228
|
+
const started = Math.max(Date.now(), (this.run?.started ?? 0) + 1);
|
|
229
|
+
const run = new Run(started, pty, this.cols, this.rows);
|
|
230
|
+
this.run = run;
|
|
231
|
+
const what = this.command ? `\`${this.command}\`` : shell;
|
|
232
|
+
this.logger.info(`Terminal ${this.name}: ${what} in ${cwd} (pid ${pty.pid ?? '-'})`);
|
|
233
|
+
if (problem) {
|
|
234
|
+
this.output(run, `\x1b[33mtassello: cannot start in ${this.folder} (${problem}): starting in ${cwd}\x1b[0m\r\n`);
|
|
235
|
+
}
|
|
236
|
+
pty.on('data', (data) => this.output(run, data));
|
|
237
|
+
pty.on('exit', (exitCode) => {
|
|
238
|
+
if (run !== this.run) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
run.exitCode = exitCode;
|
|
242
|
+
this.logger.info(`Terminal ${this.name}: ${what} ${exitCode === null ? 'hung up' : `exited with ${exitCode}`}`);
|
|
243
|
+
this.flush();
|
|
244
|
+
for (const watcher of this.watchers.keys()) {
|
|
245
|
+
watcher.onExit({ started: run.started, exitCode });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
pty.on('error', (e) => {
|
|
249
|
+
if (run !== this.run) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
run.exitCode = null;
|
|
253
|
+
run.error =
|
|
254
|
+
e.code === 'ENOENT'
|
|
255
|
+
? 'Cannot open a terminal: python3 is not installed (ROS 2 comes with it)'
|
|
256
|
+
: `Cannot open a terminal: ${errorMessage(e)}`;
|
|
257
|
+
this.logger.error(`Terminal ${this.name}: ${run.error}`);
|
|
258
|
+
for (const watcher of this.watchers.keys()) {
|
|
259
|
+
watcher.onStatus('error', run.error);
|
|
260
|
+
watcher.onExit({ started: run.started, exitCode: null });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
output(run, data) {
|
|
265
|
+
if (run !== this.run) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
run.log.append(data);
|
|
269
|
+
run.written += data.length;
|
|
270
|
+
run.screen.write(data, () => {
|
|
271
|
+
run.parsed += data.length;
|
|
272
|
+
if (run.paused && run.written - run.parsed < RESUME_BELOW) {
|
|
273
|
+
run.paused = false;
|
|
274
|
+
run.pty.resume();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
if (!run.paused && run.written - run.parsed > PAUSE_ABOVE) {
|
|
278
|
+
run.paused = true;
|
|
279
|
+
run.pty.pause();
|
|
280
|
+
}
|
|
281
|
+
this.flushTimer ??= setTimeout(() => {
|
|
282
|
+
this.flushTimer = null;
|
|
283
|
+
this.flush();
|
|
284
|
+
}, FLUSH_MS);
|
|
285
|
+
}
|
|
286
|
+
flush() {
|
|
287
|
+
for (const [watcher, delivery] of this.watchers) {
|
|
288
|
+
this.deliver(watcher, delivery);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
deliver(watcher, delivery) {
|
|
292
|
+
const { run } = this;
|
|
293
|
+
const next = delivery.next(run.log, TERMINAL_WINDOW);
|
|
294
|
+
if (next === 'snapshot') {
|
|
295
|
+
this.sendSnapshot(watcher, delivery);
|
|
296
|
+
}
|
|
297
|
+
else if (next) {
|
|
298
|
+
delivery.record(next.offset, next.data.length);
|
|
299
|
+
watcher.onOutput({ started: run.started, offset: next.offset, reset: false, data: next.data, title: null });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// The screen as of the output it has written: the page gets the rest after
|
|
303
|
+
sendSnapshot(watcher, delivery) {
|
|
304
|
+
const { run } = this;
|
|
305
|
+
const data = run.snapshot();
|
|
306
|
+
delivery.record(run.parsed, data.length);
|
|
307
|
+
watcher.onOutput({ started: run.started, offset: run.parsed, reset: true, data, title: run.title });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const IDLE_VIEW = { input() { }, resize() { }, ack() { }, restart() { }, close() { } };
|
|
311
|
+
export class Terminals {
|
|
312
|
+
logger;
|
|
313
|
+
terminals = new Map();
|
|
314
|
+
constructor(logger) {
|
|
315
|
+
this.logger = logger;
|
|
316
|
+
}
|
|
317
|
+
open(request, watcher) {
|
|
318
|
+
let terminal = this.terminals.get(request.terminal);
|
|
319
|
+
if (terminal && (terminal.command !== request.command || terminal.folder !== request.folder)) {
|
|
320
|
+
// Its widget's properties changed
|
|
321
|
+
terminal.restart(request.command, request.folder);
|
|
322
|
+
}
|
|
323
|
+
if (!terminal) {
|
|
324
|
+
if (this.terminals.size >= MAX_TERMINALS && !this.hangUpUnseen()) {
|
|
325
|
+
watcher.onStatus('error', `${MAX_TERMINALS} terminals are open on the robot already: close one`);
|
|
326
|
+
return IDLE_VIEW;
|
|
327
|
+
}
|
|
328
|
+
const opened = new Terminal(request, this.logger, () => {
|
|
329
|
+
if (this.terminals.get(request.terminal) === opened) {
|
|
330
|
+
this.terminals.delete(request.terminal);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
this.terminals.set(request.terminal, opened);
|
|
334
|
+
terminal = opened;
|
|
335
|
+
}
|
|
336
|
+
const shown = terminal;
|
|
337
|
+
shown.attach(watcher, request.started, request.offset, request.cols, request.rows);
|
|
338
|
+
return {
|
|
339
|
+
input: (data) => shown.input(data),
|
|
340
|
+
resize: (cols, rows) => shown.resize(cols, rows),
|
|
341
|
+
ack: (started, offset) => shown.ack(watcher, started, offset),
|
|
342
|
+
restart: () => shown.restart(),
|
|
343
|
+
close: () => shown.detach(watcher),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
// The server is shutting down
|
|
347
|
+
stopAll() {
|
|
348
|
+
for (const terminal of [...this.terminals.values()]) {
|
|
349
|
+
terminal.end();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// The terminal unseen the longest, if any is: hung up to open another
|
|
353
|
+
hangUpUnseen() {
|
|
354
|
+
let oldest = null;
|
|
355
|
+
for (const terminal of this.terminals.values()) {
|
|
356
|
+
if (terminal.hiddenSince !== null && (oldest === null || terminal.hiddenSince < oldest.hiddenSince)) {
|
|
357
|
+
oldest = terminal;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (oldest) {
|
|
361
|
+
this.logger.info(`Terminal ${oldest.name}: hung up, to open another`);
|
|
362
|
+
oldest.end();
|
|
363
|
+
}
|
|
364
|
+
return oldest !== null;
|
|
365
|
+
}
|
|
366
|
+
}
|
package/dist/server/util.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders } from 'node:http';
|
|
1
2
|
export interface Logger {
|
|
2
3
|
debug(message: string): unknown;
|
|
3
4
|
info(message: string): unknown;
|
|
@@ -8,6 +9,9 @@ export declare function packageShareDirectory(name: string): string | null;
|
|
|
8
9
|
export declare function defaultPanelsDirectory(): string;
|
|
9
10
|
export declare function tasselloRoot(): string;
|
|
10
11
|
export declare function absoluteTopic(topic: string): string;
|
|
12
|
+
export declare function ownPage(headers: IncomingHttpHeaders): boolean;
|
|
11
13
|
export declare function errorMessage(e: unknown): string;
|
|
12
14
|
export declare function stripAnsi(text: string): string;
|
|
13
15
|
export declare function toJson(value: unknown): unknown;
|
|
16
|
+
export declare function contentVersion(text: string): string;
|
|
17
|
+
export declare function replaceFile(file: string, text: string): Promise<void>;
|
package/dist/server/util.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, promises as fs } from 'node:fs';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
// The share directory of a ROS package, from the ament index of the sourced
|
|
@@ -26,6 +27,27 @@ export function absoluteTopic(topic) {
|
|
|
26
27
|
const trimmed = topic.trim().replace(/\/+$/, '');
|
|
27
28
|
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
28
29
|
}
|
|
30
|
+
// Whether the page that opened a WebSocket is one of the dashboard's own.
|
|
31
|
+
// Browsers say which site a page is from (Origin), and another site's page
|
|
32
|
+
// cannot change it; the dashboard's is the host the page reached: the Host
|
|
33
|
+
// header, or the X-Forwarded-Host of a reverse proxy. Programs other than
|
|
34
|
+
// browsers send no Origin.
|
|
35
|
+
export function ownPage(headers) {
|
|
36
|
+
if (headers.origin === undefined) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
let host;
|
|
40
|
+
try {
|
|
41
|
+
host = new URL(headers.origin).host.toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// "null": a sandboxed frame, a file
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const forwarded = headers['x-forwarded-host'];
|
|
48
|
+
const proxied = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(',')[0].trim();
|
|
49
|
+
return [headers.host, proxied].some((reached) => reached?.toLowerCase() === host);
|
|
50
|
+
}
|
|
29
51
|
export function errorMessage(e) {
|
|
30
52
|
return e instanceof Error ? e.message : String(e);
|
|
31
53
|
}
|
|
@@ -52,3 +74,25 @@ export function toJson(value) {
|
|
|
52
74
|
}
|
|
53
75
|
return value;
|
|
54
76
|
}
|
|
77
|
+
// Of a file's content
|
|
78
|
+
export function contentVersion(text) {
|
|
79
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
80
|
+
}
|
|
81
|
+
// Writes the file anew, at once: a temporary file renamed over it, keeping
|
|
82
|
+
// its mode; in place if that cannot be (a folder the server cannot write in,
|
|
83
|
+
// a file of another user's)
|
|
84
|
+
export async function replaceFile(file, text) {
|
|
85
|
+
const stat = await fs.stat(file);
|
|
86
|
+
if (stat.uid === process.getuid?.()) {
|
|
87
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomBytes(4).toString('hex')}.tmp`);
|
|
88
|
+
try {
|
|
89
|
+
await fs.writeFile(temporary, text, { mode: stat.mode & 0o7777, flag: 'wx' });
|
|
90
|
+
await fs.rename(temporary, file);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
await fs.rm(temporary, { force: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
await fs.writeFile(file, text);
|
|
98
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export interface FsEntry {
|
|
2
|
+
name: string;
|
|
3
|
+
kind: 'dir' | 'file' | 'other';
|
|
4
|
+
link: string | null;
|
|
5
|
+
size: number;
|
|
6
|
+
modified: number;
|
|
7
|
+
mode: number;
|
|
8
|
+
owner: string;
|
|
9
|
+
group: string;
|
|
10
|
+
readable: boolean;
|
|
11
|
+
writable: boolean;
|
|
12
|
+
bag: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface FsSpace {
|
|
15
|
+
mount: string;
|
|
16
|
+
total: number;
|
|
17
|
+
free: number;
|
|
18
|
+
}
|
|
19
|
+
export interface FsListing {
|
|
20
|
+
path: string;
|
|
21
|
+
parent: string | null;
|
|
22
|
+
entries: FsEntry[];
|
|
23
|
+
truncated: number;
|
|
24
|
+
writable: boolean;
|
|
25
|
+
version: string;
|
|
26
|
+
space: FsSpace | null;
|
|
27
|
+
}
|
|
28
|
+
export interface FsPlaces {
|
|
29
|
+
home: string;
|
|
30
|
+
user: string;
|
|
31
|
+
ros: {
|
|
32
|
+
name: string;
|
|
33
|
+
path: string;
|
|
34
|
+
}[];
|
|
35
|
+
}
|
|
36
|
+
export interface FsDetails extends FsEntry {
|
|
37
|
+
path: string;
|
|
38
|
+
realPath: string | null;
|
|
39
|
+
created: number | null;
|
|
40
|
+
accessed: number;
|
|
41
|
+
changed: number;
|
|
42
|
+
inode: number;
|
|
43
|
+
links: number;
|
|
44
|
+
items: number | null;
|
|
45
|
+
hidden: number | null;
|
|
46
|
+
space: FsSpace | null;
|
|
47
|
+
}
|
|
48
|
+
export interface FsDisk extends FsSpace {
|
|
49
|
+
device: string;
|
|
50
|
+
type: string;
|
|
51
|
+
readOnly: boolean;
|
|
52
|
+
used: number;
|
|
53
|
+
inodes: {
|
|
54
|
+
total: number;
|
|
55
|
+
free: number;
|
|
56
|
+
} | null;
|
|
57
|
+
}
|
|
58
|
+
export interface FsAnalysis {
|
|
59
|
+
path: string;
|
|
60
|
+
size: number;
|
|
61
|
+
disk: number;
|
|
62
|
+
files: number;
|
|
63
|
+
folders: number;
|
|
64
|
+
links: number;
|
|
65
|
+
children: {
|
|
66
|
+
name: string;
|
|
67
|
+
kind: FsEntry['kind'];
|
|
68
|
+
size: number;
|
|
69
|
+
files: number;
|
|
70
|
+
}[];
|
|
71
|
+
types: {
|
|
72
|
+
type: string;
|
|
73
|
+
files: number;
|
|
74
|
+
size: number;
|
|
75
|
+
}[];
|
|
76
|
+
largest: {
|
|
77
|
+
path: string;
|
|
78
|
+
size: number;
|
|
79
|
+
}[];
|
|
80
|
+
recent: {
|
|
81
|
+
path: string;
|
|
82
|
+
modified: number;
|
|
83
|
+
}[];
|
|
84
|
+
partial: string | null;
|
|
85
|
+
unreadable: number;
|
|
86
|
+
mounts: number;
|
|
87
|
+
elapsedMs: number;
|
|
88
|
+
}
|
|
89
|
+
export interface FsText {
|
|
90
|
+
path: string;
|
|
91
|
+
text: string;
|
|
92
|
+
version: string;
|
|
93
|
+
stamp: string;
|
|
94
|
+
readOnly: string | null;
|
|
95
|
+
}
|
|
96
|
+
export interface FsSaved {
|
|
97
|
+
version: string;
|
|
98
|
+
stamp: string;
|
|
99
|
+
conflict: boolean;
|
|
100
|
+
}
|
|
101
|
+
export type FsConflict = 'ask' | 'skip' | 'keep-both' | 'replace';
|
|
102
|
+
export type FsOperation = {
|
|
103
|
+
op: 'mkdir';
|
|
104
|
+
path: string;
|
|
105
|
+
} | {
|
|
106
|
+
op: 'create';
|
|
107
|
+
path: string;
|
|
108
|
+
} | {
|
|
109
|
+
op: 'rename';
|
|
110
|
+
path: string;
|
|
111
|
+
name: string;
|
|
112
|
+
} | {
|
|
113
|
+
op: 'move' | 'copy';
|
|
114
|
+
paths: string[];
|
|
115
|
+
to: string;
|
|
116
|
+
conflict: FsConflict;
|
|
117
|
+
} | {
|
|
118
|
+
op: 'delete';
|
|
119
|
+
paths: string[];
|
|
120
|
+
} | {
|
|
121
|
+
op: 'chmod';
|
|
122
|
+
path: string;
|
|
123
|
+
mode: number;
|
|
124
|
+
};
|
|
125
|
+
export interface FsResult {
|
|
126
|
+
paths: string[];
|
|
127
|
+
conflicts: string[];
|
|
128
|
+
}
|
|
129
|
+
export declare const FS_MAX_ENTRIES = 5000;
|
|
130
|
+
export declare const FS_MAX_TEXT_BYTES: number;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The robot's files, as the file manager widgets see them: the REST API of
|
|
2
|
+
// /api/fs (see src/server/http.ts). The server acts as its user, anywhere that
|
|
3
|
+
// user can: see the README's Security.
|
|
4
|
+
// Of a folder's entries, at most, listed
|
|
5
|
+
export const FS_MAX_ENTRIES = 5000;
|
|
6
|
+
// Of the files edited as text
|
|
7
|
+
export const FS_MAX_TEXT_BYTES = 5 * 1024 * 1024;
|