tassello 0.2.0 → 0.3.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 +38 -0
- package/README.md +112 -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/pty.d.ts +26 -0
- package/dist/server/pty.js +150 -0
- package/dist/server/server.js +9 -2
- package/dist/server/session.d.ts +7 -1
- package/dist/server/session.js +67 -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/protocol.d.ts +87 -2
- package/dist/shared/protocol.js +8 -0
- package/dist/types/client/api.d.ts +22 -0
- package/dist/types/client/connection.d.ts +22 -1
- package/dist/types/client/icons.d.ts +27 -0
- package/dist/types/client/sdk/index.d.ts +4 -3
- package/dist/types/shared/file_manager.d.ts +130 -0
- package/dist/types/shared/protocol.d.ts +87 -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 +20 -0
- package/package.json +6 -2
package/dist/server/http.js
CHANGED
|
@@ -17,13 +17,32 @@
|
|
|
17
17
|
// POST /api/uploads an image or a behavior tree (the body) -> {name}
|
|
18
18
|
// GET /api/uploads/<name> an uploaded file
|
|
19
19
|
// GET /api/plugins [PluginInfo] of the plugins loaded
|
|
20
|
+
// /api/fs/... the file manager's: any file of the
|
|
21
|
+
// robot (see src/shared/file_manager.ts), changed only by the
|
|
22
|
+
// dashboard's pages
|
|
23
|
+
// GET places FsPlaces: home, the ROS workspaces
|
|
24
|
+
// GET list?path=&known= FsListing, 204 while still the known version
|
|
25
|
+
// GET details?path= FsDetails
|
|
26
|
+
// GET disks [FsDisk] of the robot's file systems
|
|
27
|
+
// GET analysis?path= FsAnalysis: what takes a folder's space
|
|
28
|
+
// GET text?path=&stamp= FsText, 204 while still of that stamp
|
|
29
|
+
// PUT text {path, text, base, create} -> FsSaved
|
|
30
|
+
// POST op FsOperation -> FsResult
|
|
31
|
+
// POST upload?path=&name=&conflict= the file (the body) -> FsResult
|
|
32
|
+
// GET download?path=[&path=...] a file, or folders and files as a .tar.gz
|
|
33
|
+
// GET view?path= an image, PGM and PPM ones as PNG
|
|
20
34
|
// GET /plugins/<name>/<path> a file of the plugin's client folder
|
|
35
|
+
import { spawn } from 'node:child_process';
|
|
21
36
|
import { createReadStream, promises as fs } from 'node:fs';
|
|
22
37
|
import path from 'node:path';
|
|
23
38
|
import { promisify } from 'node:util';
|
|
24
39
|
import { gzip } from 'node:zlib';
|
|
40
|
+
import sharp from 'sharp';
|
|
25
41
|
import { DASHBOARD_CLIENT_HEADER } from '../shared/protocol.js';
|
|
26
|
-
import {
|
|
42
|
+
import { listDisks, spaceOf } from './disks.js';
|
|
43
|
+
import { analyzeFolder, archiveArguments, changeMode, deleteEntries, entryDetails, fsError, listFolder, listPlaces, makeFile, makeFolder, readText, receiveFile, renameEntry, resolvePath, saveText, transfer, uploadTarget, } from './file_manager.js';
|
|
44
|
+
import { fileKind, IMAGE_EXTENSIONS, isUploadName, listDirectory, MAX_UPLOAD_BYTES, resolveImagePath, saveUpload, } from './files.js';
|
|
45
|
+
import { decodeNetpbm, NETPBM_EXTENSIONS } from './netpbm.js';
|
|
27
46
|
import { HttpError } from './panels.js';
|
|
28
47
|
import { errorMessage, packageShareDirectory } from './util.js';
|
|
29
48
|
// By lowercase extension: meshes come as .STL as often as .stl
|
|
@@ -50,12 +69,24 @@ const CONTENT_TYPES = {
|
|
|
50
69
|
// Worth compressing: text, and meshes, often tens of MB of CAD exports
|
|
51
70
|
const COMPRESSIBLE = new Set(['.html', '.js', '.css', '.json', '.map', '.svg', '.stl', '.dae', '.obj']);
|
|
52
71
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
72
|
+
// A text file saved by the file manager, 5 MB, as JSON
|
|
73
|
+
const MAX_TEXT_BODY_BYTES = 12 * 1024 * 1024;
|
|
74
|
+
// Of a file uploaded by the file manager: rosbags are large
|
|
75
|
+
const MAX_FS_UPLOAD_BYTES = 64 * 1024 ** 3;
|
|
76
|
+
// PGM and PPM images converted to PNG
|
|
77
|
+
const MAX_PREVIEW_BYTES = 256 * 1024 * 1024;
|
|
78
|
+
// Analyses at once: each reads a whole tree of folders
|
|
79
|
+
const MAX_ANALYSES = 2;
|
|
80
|
+
const CONFLICTS = ['ask', 'skip', 'keep-both', 'replace'];
|
|
53
81
|
// Compressed files are kept, the most recent first, within this size
|
|
54
82
|
const GZIP_CACHE_BYTES = 64 * 1024 * 1024;
|
|
55
83
|
const PACKAGE_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
56
84
|
// For the images of the robot and of the users: an SVG opened by itself must
|
|
57
85
|
// not run scripts in the dashboard's origin
|
|
58
|
-
const SANDBOXED = {
|
|
86
|
+
const SANDBOXED = {
|
|
87
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
88
|
+
'X-Content-Type-Options': 'nosniff',
|
|
89
|
+
};
|
|
59
90
|
export function createRequestHandler(services) {
|
|
60
91
|
return (req, res) => {
|
|
61
92
|
handle(services, req, res).catch((e) => {
|
|
@@ -93,7 +124,7 @@ async function handle(services, req, res) {
|
|
|
93
124
|
await serveFile(services.wwwDir, pathname === '/' ? 'index.html' : decodeURIComponent(pathname), req, res);
|
|
94
125
|
}
|
|
95
126
|
}
|
|
96
|
-
async function api({ store, topics, uploadsDir, plugins }, req, res, route) {
|
|
127
|
+
async function api({ store, topics, uploadsDir, plugins, logger }, req, res, route) {
|
|
97
128
|
const origin = req.headers[DASHBOARD_CLIENT_HEADER.toLowerCase()];
|
|
98
129
|
const author = typeof origin === 'string' && origin ? origin : null;
|
|
99
130
|
const [resource, id, ...rest] = route;
|
|
@@ -149,6 +180,9 @@ async function api({ store, topics, uploadsDir, plugins }, req, res, route) {
|
|
|
149
180
|
}
|
|
150
181
|
throw new HttpError(404, `No API at ${req.url}`);
|
|
151
182
|
}
|
|
183
|
+
else if (resource === 'fs' && id && rest.length === 0) {
|
|
184
|
+
return fsApi(req, res, `${method} ${id}`, logger);
|
|
185
|
+
}
|
|
152
186
|
else if (resource === 'uploads' && id === undefined && method === 'POST') {
|
|
153
187
|
return sendJson(res, 201, { name: await saveUpload(uploadsDir, await readBody(req, MAX_UPLOAD_BYTES)) });
|
|
154
188
|
}
|
|
@@ -184,8 +218,217 @@ async function readBody(req, limit) {
|
|
|
184
218
|
}
|
|
185
219
|
return Buffer.concat(chunks);
|
|
186
220
|
}
|
|
187
|
-
async function readJson(req) {
|
|
188
|
-
return JSON.parse((await readBody(req,
|
|
221
|
+
async function readJson(req, limit = MAX_BODY_BYTES) {
|
|
222
|
+
return JSON.parse((await readBody(req, limit)).toString('utf8'));
|
|
223
|
+
}
|
|
224
|
+
let analyses = 0;
|
|
225
|
+
// The file manager's API (see file_manager.ts): route is the method and the
|
|
226
|
+
// action, "GET list"
|
|
227
|
+
async function fsApi(req, res, route, logger) {
|
|
228
|
+
const query = new URL(req.url ?? '/', 'http://localhost').searchParams;
|
|
229
|
+
if (!route.startsWith('GET ') && typeof req.headers[DASHBOARD_CLIENT_HEADER.toLowerCase()] !== 'string') {
|
|
230
|
+
// Another site's page cannot send the header: its browser would first
|
|
231
|
+
// ask the server, which does not allow it
|
|
232
|
+
throw new HttpError(403, 'The robot’s files are changed by the dashboard’s pages only');
|
|
233
|
+
}
|
|
234
|
+
switch (route) {
|
|
235
|
+
case 'GET places':
|
|
236
|
+
return sendJson(res, 200, listPlaces());
|
|
237
|
+
case 'GET list': {
|
|
238
|
+
const listing = await listFolder(resolvePath(query.get('path') ?? '~'));
|
|
239
|
+
return listing.version === query.get('known') ? sendNothing(res) : sendJson(res, 200, listing);
|
|
240
|
+
}
|
|
241
|
+
case 'GET details':
|
|
242
|
+
return sendJson(res, 200, await entryDetails(resolvePath(query.get('path'))));
|
|
243
|
+
case 'GET disks':
|
|
244
|
+
return sendJson(res, 200, await listDisks());
|
|
245
|
+
case 'GET analysis': {
|
|
246
|
+
if (analyses >= MAX_ANALYSES) {
|
|
247
|
+
throw new HttpError(429, 'Other folders are being analyzed: try again in a moment');
|
|
248
|
+
}
|
|
249
|
+
analyses++;
|
|
250
|
+
try {
|
|
251
|
+
return sendJson(res, 200, await analyzeFolder(resolvePath(query.get('path'))));
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
analyses--;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
case 'GET text': {
|
|
258
|
+
const text = await readText(resolvePath(query.get('path')), query.get('stamp'));
|
|
259
|
+
return text ? sendJson(res, 200, text) : sendNothing(res);
|
|
260
|
+
}
|
|
261
|
+
case 'PUT text': {
|
|
262
|
+
const body = (await readJson(req, MAX_TEXT_BODY_BYTES));
|
|
263
|
+
if (typeof body.text !== 'string') {
|
|
264
|
+
throw new HttpError(400, 'text must be a string');
|
|
265
|
+
}
|
|
266
|
+
const file = resolvePath(body.path);
|
|
267
|
+
const saved = await saveText(file, body.text, typeof body.base === 'string' ? body.base : null, body.create === true);
|
|
268
|
+
if (!saved.conflict) {
|
|
269
|
+
logger.info(`File saved: ${file}`);
|
|
270
|
+
}
|
|
271
|
+
return sendJson(res, 200, saved);
|
|
272
|
+
}
|
|
273
|
+
case 'POST op': {
|
|
274
|
+
const result = await operate((await readJson(req)), logger);
|
|
275
|
+
return sendJson(res, 200, result);
|
|
276
|
+
}
|
|
277
|
+
case 'POST upload': {
|
|
278
|
+
const folder = resolvePath(query.get('path'));
|
|
279
|
+
const name = query.get('name') ?? '';
|
|
280
|
+
const conflict = CONFLICTS.find((value) => value === query.get('conflict')) ?? 'ask';
|
|
281
|
+
const length = Number(req.headers['content-length']);
|
|
282
|
+
const space = Number.isFinite(length) ? await spaceOf(folder) : null;
|
|
283
|
+
if (space && length > space.free) {
|
|
284
|
+
throw new HttpError(507, `Not enough space on ${space.mount}: ${(space.free / 1e9).toFixed(1)} GB free`);
|
|
285
|
+
}
|
|
286
|
+
const target = await uploadTarget(folder, name, conflict);
|
|
287
|
+
if (!target) {
|
|
288
|
+
req.resume();
|
|
289
|
+
return sendJson(res, 200, { paths: [], conflicts: [name] });
|
|
290
|
+
}
|
|
291
|
+
const size = await receiveFile(req, target, MAX_FS_UPLOAD_BYTES);
|
|
292
|
+
logger.info(`File uploaded: ${target} (${size} bytes)`);
|
|
293
|
+
return sendJson(res, 201, { paths: [target], conflicts: [] });
|
|
294
|
+
}
|
|
295
|
+
case 'GET download': {
|
|
296
|
+
const files = query.getAll('path').map((value) => resolvePath(value));
|
|
297
|
+
if (files.length === 0) {
|
|
298
|
+
throw new HttpError(400, 'No path');
|
|
299
|
+
}
|
|
300
|
+
const stat = files.length === 1
|
|
301
|
+
? await fs.stat(files[0]).catch((e) => {
|
|
302
|
+
throw fsError(e, `Cannot download ${path.basename(files[0])}`);
|
|
303
|
+
})
|
|
304
|
+
: null;
|
|
305
|
+
return stat?.isFile() ? sendDownload(res, files[0], stat) : sendArchive(res, files, logger);
|
|
306
|
+
}
|
|
307
|
+
case 'GET view': {
|
|
308
|
+
const file = resolvePath(query.get('path'));
|
|
309
|
+
const extension = path.extname(file).toLowerCase();
|
|
310
|
+
if (!NETPBM_EXTENSIONS.has(extension)) {
|
|
311
|
+
if (!IMAGE_EXTENSIONS.has(extension) && extension !== '.ico') {
|
|
312
|
+
throw new HttpError(415, `Not an image: ${path.basename(file)}`);
|
|
313
|
+
}
|
|
314
|
+
return serveFile(path.dirname(file), path.basename(file), req, res, SANDBOXED);
|
|
315
|
+
}
|
|
316
|
+
const stat = await fs.stat(file).catch((e) => {
|
|
317
|
+
throw fsError(e, `Cannot read ${path.basename(file)}`);
|
|
318
|
+
});
|
|
319
|
+
if (stat.size > MAX_PREVIEW_BYTES) {
|
|
320
|
+
throw new HttpError(413, `Too large to preview: ${path.basename(file)}`);
|
|
321
|
+
}
|
|
322
|
+
let pixels;
|
|
323
|
+
try {
|
|
324
|
+
pixels = decodeNetpbm(await fs.readFile(file));
|
|
325
|
+
}
|
|
326
|
+
catch (e) {
|
|
327
|
+
throw e instanceof Error && !('code' in e) ? new HttpError(415, e.message) : fsError(e, `Cannot read ${path.basename(file)}`);
|
|
328
|
+
}
|
|
329
|
+
const { data, width, height, channels } = pixels;
|
|
330
|
+
const png = await sharp(data, { raw: { width, height, channels } }).png().toBuffer();
|
|
331
|
+
res.writeHead(200, {
|
|
332
|
+
...SANDBOXED,
|
|
333
|
+
'Content-Type': 'image/png',
|
|
334
|
+
'Content-Length': png.length,
|
|
335
|
+
'Cache-Control': 'no-cache',
|
|
336
|
+
});
|
|
337
|
+
res.end(png);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
throw new HttpError(404, `No API at ${req.url}`);
|
|
342
|
+
}
|
|
343
|
+
async function operate(body, logger) {
|
|
344
|
+
const paths = () => {
|
|
345
|
+
if (!Array.isArray(body.paths) || body.paths.length === 0) {
|
|
346
|
+
throw new HttpError(400, 'paths must be a list of paths');
|
|
347
|
+
}
|
|
348
|
+
return body.paths.map((value) => resolvePath(value));
|
|
349
|
+
};
|
|
350
|
+
const log = (what, files) => logger.info(`File manager: ${what} ${files.length === 1 ? files[0] : `${files.length} entries (${files[0]}, …)`}`);
|
|
351
|
+
switch (body.op) {
|
|
352
|
+
case 'mkdir':
|
|
353
|
+
return makeFolder(resolvePath(body.path));
|
|
354
|
+
case 'create':
|
|
355
|
+
return makeFile(resolvePath(body.path));
|
|
356
|
+
case 'rename': {
|
|
357
|
+
const file = resolvePath(body.path);
|
|
358
|
+
const result = await renameEntry(file, body.name);
|
|
359
|
+
log(`renamed to ${body.name}:`, [file]);
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
case 'move':
|
|
363
|
+
case 'copy': {
|
|
364
|
+
const sources = paths();
|
|
365
|
+
const conflict = CONFLICTS.find((value) => value === body.conflict) ?? 'ask';
|
|
366
|
+
const result = await transfer(body.op, sources, resolvePath(body.to), conflict);
|
|
367
|
+
if (result.paths.length > 0) {
|
|
368
|
+
log(`${body.op === 'move' ? 'moved' : 'copied'} into ${resolvePath(body.to)}:`, sources);
|
|
369
|
+
}
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
case 'delete': {
|
|
373
|
+
const files = paths();
|
|
374
|
+
const result = await deleteEntries(files);
|
|
375
|
+
log('deleted', files);
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
case 'chmod':
|
|
379
|
+
return changeMode(resolvePath(body.path), body.mode);
|
|
380
|
+
default:
|
|
381
|
+
throw new HttpError(400, `Unknown operation ${JSON.stringify(body.op)}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function sendNothing(res) {
|
|
385
|
+
res.writeHead(204, { 'Cache-Control': 'no-store' }).end();
|
|
386
|
+
}
|
|
387
|
+
// attachment; filename="…": an ASCII name for old browsers, the name whole
|
|
388
|
+
// for the others (RFC 6266)
|
|
389
|
+
function contentDisposition(name) {
|
|
390
|
+
const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, '_');
|
|
391
|
+
const encoded = encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
392
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
|
393
|
+
}
|
|
394
|
+
// A file of any size, streamed as it is
|
|
395
|
+
function sendDownload(res, file, stat) {
|
|
396
|
+
res.writeHead(200, {
|
|
397
|
+
...SANDBOXED,
|
|
398
|
+
'Content-Type': CONTENT_TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
|
|
399
|
+
'Content-Length': stat.size,
|
|
400
|
+
'Content-Disposition': contentDisposition(path.basename(file)),
|
|
401
|
+
'Cache-Control': 'no-store',
|
|
402
|
+
});
|
|
403
|
+
createReadStream(file)
|
|
404
|
+
.on('error', () => res.destroy())
|
|
405
|
+
.pipe(res);
|
|
406
|
+
}
|
|
407
|
+
// Entries of one folder, as tar archives them, gzipped, as it goes
|
|
408
|
+
async function sendArchive(res, files, logger) {
|
|
409
|
+
const folder = path.dirname(files[0]);
|
|
410
|
+
const name = files.length === 1 ? path.basename(files[0]) : `${path.basename(folder) || 'root'}-${files.length}-items`;
|
|
411
|
+
const tar = spawn('tar', archiveArguments(files), { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
412
|
+
await new Promise((resolve, reject) => {
|
|
413
|
+
tar.once('spawn', resolve);
|
|
414
|
+
tar.once('error', (e) => reject(new HttpError(500, `Cannot archive: tar ${e.code ?? errorMessage(e)}`)));
|
|
415
|
+
});
|
|
416
|
+
let errors = '';
|
|
417
|
+
tar.stderr.on('data', (data) => (errors = (errors + data.toString()).slice(-2000)));
|
|
418
|
+
tar.on('close', (code) => {
|
|
419
|
+
if (code !== 0 && errors.trim()) {
|
|
420
|
+
logger.warn(`Download of ${files.length === 1 ? files[0] : folder}: ${errors.trim()}`);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
// The page gave up: so does tar
|
|
424
|
+
res.on('close', () => tar.exitCode === null && tar.kill());
|
|
425
|
+
res.writeHead(200, {
|
|
426
|
+
...SANDBOXED,
|
|
427
|
+
'Content-Type': 'application/gzip',
|
|
428
|
+
'Content-Disposition': contentDisposition(`${name}.tar.gz`),
|
|
429
|
+
'Cache-Control': 'no-store',
|
|
430
|
+
});
|
|
431
|
+
tar.stdout.pipe(res);
|
|
189
432
|
}
|
|
190
433
|
const gzipAsync = promisify(gzip);
|
|
191
434
|
// By file path: the compressed content of the file at that size and time
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Netpbm images, PGM and PPM, which browsers do not show: the maps of
|
|
2
|
+
// nav2_map_server and map_server are PGM. Decoded into pixels for sharp to
|
|
3
|
+
// make PNGs of, for the file manager's previews.
|
|
4
|
+
export const NETPBM_EXTENSIONS = new Set(['.pgm', '.ppm', '.pnm']);
|
|
5
|
+
// 10,000 × 10,000: a map of a km² at 10 cm
|
|
6
|
+
const MAX_PIXELS = 100_000_000;
|
|
7
|
+
// P2 and P5 gray, P3 and P6 colour; text (P2, P3) or binary (P5, P6), 8 or
|
|
8
|
+
// 16 bit (big endian), scaled to 8 bit
|
|
9
|
+
export function decodeNetpbm(data) {
|
|
10
|
+
const magic = data.subarray(0, 2).toString('latin1');
|
|
11
|
+
if (!['P2', 'P3', 'P5', 'P6'].includes(magic)) {
|
|
12
|
+
throw new Error(`Not a PGM nor PPM image (${JSON.stringify(magic)})`);
|
|
13
|
+
}
|
|
14
|
+
const channels = magic === 'P3' || magic === 'P6' ? 3 : 1;
|
|
15
|
+
const binary = magic === 'P5' || magic === 'P6';
|
|
16
|
+
// The header's numbers, after comments (# to the end of the line)
|
|
17
|
+
let offset = 2;
|
|
18
|
+
const number = () => {
|
|
19
|
+
for (;;) {
|
|
20
|
+
// Tab, line feed, vertical tab, form feed, carriage return, space
|
|
21
|
+
while (offset < data.length && (data[offset] === 0x20 || (data[offset] >= 0x09 && data[offset] <= 0x0d))) {
|
|
22
|
+
offset++;
|
|
23
|
+
}
|
|
24
|
+
if (data[offset] !== 0x23) {
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
while (offset < data.length && data[offset] !== 0x0a) {
|
|
28
|
+
offset++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const start = offset;
|
|
32
|
+
while (offset < data.length && data[offset] >= 0x30 && data[offset] <= 0x39) {
|
|
33
|
+
offset++;
|
|
34
|
+
}
|
|
35
|
+
if (offset === start) {
|
|
36
|
+
throw new Error('Malformed PGM or PPM header');
|
|
37
|
+
}
|
|
38
|
+
return Number(data.subarray(start, offset).toString('latin1'));
|
|
39
|
+
};
|
|
40
|
+
const width = number();
|
|
41
|
+
const height = number();
|
|
42
|
+
const max = number();
|
|
43
|
+
if (width < 1 || height < 1 || max < 1 || max > 65535) {
|
|
44
|
+
throw new Error(`Malformed PGM or PPM header: ${width}×${height}, max ${max}`);
|
|
45
|
+
}
|
|
46
|
+
if (width * height > MAX_PIXELS) {
|
|
47
|
+
throw new Error(`Too large to preview: ${width}×${height}`);
|
|
48
|
+
}
|
|
49
|
+
const count = width * height * channels;
|
|
50
|
+
const pixels = Buffer.alloc(count);
|
|
51
|
+
const scale = (value) => (max === 255 ? value : Math.round((Math.min(value, max) * 255) / max));
|
|
52
|
+
if (binary) {
|
|
53
|
+
// One whitespace after the header
|
|
54
|
+
offset++;
|
|
55
|
+
const bytes = max > 255 ? 2 : 1;
|
|
56
|
+
if (data.length - offset < count * bytes) {
|
|
57
|
+
throw new Error('Truncated PGM or PPM image');
|
|
58
|
+
}
|
|
59
|
+
for (let i = 0; i < count; i++) {
|
|
60
|
+
pixels[i] = scale(bytes === 2 ? data.readUInt16BE(offset + i * 2) : data[offset + i]);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
for (let i = 0; i < count; i++) {
|
|
65
|
+
pixels[i] = scale(number());
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { data: pixels, width, height, channels };
|
|
69
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
export interface PtyOptions {
|
|
3
|
+
file: string;
|
|
4
|
+
args: string[];
|
|
5
|
+
cwd: string;
|
|
6
|
+
env: NodeJS.ProcessEnv;
|
|
7
|
+
cols: number;
|
|
8
|
+
rows: number;
|
|
9
|
+
}
|
|
10
|
+
interface PtyEvents {
|
|
11
|
+
data: [string];
|
|
12
|
+
exit: [number | null];
|
|
13
|
+
error: [Error];
|
|
14
|
+
}
|
|
15
|
+
export declare class Pty extends EventEmitter<PtyEvents> {
|
|
16
|
+
private readonly child;
|
|
17
|
+
private exited;
|
|
18
|
+
constructor(options: PtyOptions);
|
|
19
|
+
get pid(): number | undefined;
|
|
20
|
+
write(data: string): void;
|
|
21
|
+
resize(cols: number, rows: number): void;
|
|
22
|
+
pause(): void;
|
|
23
|
+
resume(): void;
|
|
24
|
+
hangUp(): void;
|
|
25
|
+
}
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Pseudo-terminals for the terminal widgets, without a native module: Node
|
|
2
|
+
// cannot open one, the Python 3 that ROS 2 ships with can. A small bridge
|
|
3
|
+
// (BRIDGE) opens it, starts the program on it as the session leader, and
|
|
4
|
+
// relays its standard input to the terminal and the terminal's output to its
|
|
5
|
+
// standard output; lines on fd 3 resize it. When the bridge goes, the
|
|
6
|
+
// terminal hangs up, as when a terminal window is closed.
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { EventEmitter } from 'node:events';
|
|
9
|
+
// argv: columns, rows, then the program and its arguments. Exits with the
|
|
10
|
+
// program's exit code, 128 + the signal that killed it.
|
|
11
|
+
const BRIDGE = `
|
|
12
|
+
import fcntl, os, select, signal, struct, sys, termios
|
|
13
|
+
|
|
14
|
+
CONTROL = 3
|
|
15
|
+
|
|
16
|
+
def size(fd, cols, rows):
|
|
17
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
|
|
18
|
+
|
|
19
|
+
def send(data):
|
|
20
|
+
while data:
|
|
21
|
+
data = data[os.write(1, data):]
|
|
22
|
+
|
|
23
|
+
cols, rows, argv = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3:]
|
|
24
|
+
master, slave = os.openpty()
|
|
25
|
+
size(slave, cols, rows)
|
|
26
|
+
attrs = termios.tcgetattr(slave)
|
|
27
|
+
attrs[0] |= getattr(termios, 'IUTF8', 0o40000)
|
|
28
|
+
termios.tcsetattr(slave, termios.TCSANOW, attrs)
|
|
29
|
+
pid = os.fork()
|
|
30
|
+
if pid == 0:
|
|
31
|
+
try:
|
|
32
|
+
os.setsid()
|
|
33
|
+
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
|
|
34
|
+
for fd in (0, 1, 2):
|
|
35
|
+
os.dup2(slave, fd)
|
|
36
|
+
for fd in (master, slave, CONTROL):
|
|
37
|
+
os.close(fd)
|
|
38
|
+
# Python ignores them, and what it ignores stays ignored after exec
|
|
39
|
+
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
|
40
|
+
signal.signal(signal.SIGXFSZ, signal.SIG_DFL)
|
|
41
|
+
os.execvp(argv[0], argv)
|
|
42
|
+
except BaseException as e:
|
|
43
|
+
os.write(2, ('tassello: cannot run %s: %s\\n' % (argv[0], e)).encode())
|
|
44
|
+
os._exit(127)
|
|
45
|
+
|
|
46
|
+
os.close(slave)
|
|
47
|
+
os.set_blocking(master, False)
|
|
48
|
+
inputs = [master, 0, CONTROL]
|
|
49
|
+
pending = b''
|
|
50
|
+
control = b''
|
|
51
|
+
status = None
|
|
52
|
+
while status is None:
|
|
53
|
+
readable, writable, _ = select.select(inputs, [master] if pending else [], [], 0.5)
|
|
54
|
+
if master in readable:
|
|
55
|
+
try:
|
|
56
|
+
data = os.read(master, 65536)
|
|
57
|
+
except BlockingIOError:
|
|
58
|
+
data = None
|
|
59
|
+
except OSError:
|
|
60
|
+
data = b''
|
|
61
|
+
if data == b'':
|
|
62
|
+
break
|
|
63
|
+
if data:
|
|
64
|
+
send(data)
|
|
65
|
+
if writable:
|
|
66
|
+
try:
|
|
67
|
+
pending = pending[os.write(master, pending):]
|
|
68
|
+
except BlockingIOError:
|
|
69
|
+
pass
|
|
70
|
+
if 0 in readable:
|
|
71
|
+
data = os.read(0, 65536)
|
|
72
|
+
if not data:
|
|
73
|
+
sys.exit(0)
|
|
74
|
+
pending += data
|
|
75
|
+
if CONTROL in readable:
|
|
76
|
+
data = os.read(CONTROL, 4096)
|
|
77
|
+
if not data:
|
|
78
|
+
inputs.remove(CONTROL)
|
|
79
|
+
control += data
|
|
80
|
+
while b'\\n' in control:
|
|
81
|
+
line, control = control.split(b'\\n', 1)
|
|
82
|
+
size(master, *map(int, line.split()))
|
|
83
|
+
# Gone, while what it left in the background keeps the terminal open:
|
|
84
|
+
# the rest of its output, then done
|
|
85
|
+
done, result = os.waitpid(pid, os.WNOHANG)
|
|
86
|
+
if done:
|
|
87
|
+
status = result
|
|
88
|
+
try:
|
|
89
|
+
data = os.read(master, 65536)
|
|
90
|
+
while data:
|
|
91
|
+
send(data)
|
|
92
|
+
data = os.read(master, 65536)
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
if status is None:
|
|
97
|
+
status = os.waitpid(pid, 0)[1]
|
|
98
|
+
sys.exit(os.WEXITSTATUS(status) if os.WIFEXITED(status) else 128 + os.WTERMSIG(status))
|
|
99
|
+
`;
|
|
100
|
+
export class Pty extends EventEmitter {
|
|
101
|
+
child;
|
|
102
|
+
exited = false;
|
|
103
|
+
constructor(options) {
|
|
104
|
+
super();
|
|
105
|
+
// -I: none of the robot's PYTHONPATH and the like, which the program
|
|
106
|
+
// still gets
|
|
107
|
+
this.child = spawn('python3', ['-I', '-c', BRIDGE, String(options.cols), String(options.rows), options.file, ...options.args], { cwd: options.cwd, env: options.env, stdio: ['pipe', 'pipe', 'inherit', 'pipe'] });
|
|
108
|
+
this.child.stdout?.setEncoding('utf8').on('data', (chunk) => this.emit('data', chunk));
|
|
109
|
+
// Writes after the program is gone
|
|
110
|
+
this.child.stdin?.on('error', () => { });
|
|
111
|
+
this.child.stdio[3]?.on('error', () => { });
|
|
112
|
+
this.child.on('error', (e) => {
|
|
113
|
+
this.exited = true;
|
|
114
|
+
this.emit('error', e);
|
|
115
|
+
});
|
|
116
|
+
// 'close': after the last of its output
|
|
117
|
+
this.child.on('close', (code) => {
|
|
118
|
+
if (!this.exited) {
|
|
119
|
+
this.exited = true;
|
|
120
|
+
this.emit('exit', code);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
get pid() {
|
|
125
|
+
return this.child.pid;
|
|
126
|
+
}
|
|
127
|
+
write(data) {
|
|
128
|
+
if (!this.exited) {
|
|
129
|
+
this.child.stdin?.write(data);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
resize(cols, rows) {
|
|
133
|
+
if (!this.exited) {
|
|
134
|
+
this.child.stdio[3]?.write(`${cols} ${rows}\n`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Backpressure: the program blocks on its output while paused
|
|
138
|
+
pause() {
|
|
139
|
+
this.child.stdout?.pause();
|
|
140
|
+
}
|
|
141
|
+
resume() {
|
|
142
|
+
this.child.stdout?.resume();
|
|
143
|
+
}
|
|
144
|
+
// As a terminal window closed: the program gets SIGHUP
|
|
145
|
+
hangUp() {
|
|
146
|
+
if (!this.exited) {
|
|
147
|
+
this.child.kill('SIGHUP');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/server/server.js
CHANGED
|
@@ -15,6 +15,7 @@ import { Arm } from './arm.js';
|
|
|
15
15
|
import { BehaviorTreeFiles } from './behavior_tree_files.js';
|
|
16
16
|
import { BehaviorTrees } from './behavior_trees.js';
|
|
17
17
|
import { CommandRunner } from './commands.js';
|
|
18
|
+
import { Diagnostics } from './diagnostics.js';
|
|
18
19
|
import { createRequestHandler } from './http.js';
|
|
19
20
|
import { PanelStore } from './panels.js';
|
|
20
21
|
import { Paths } from './paths.js';
|
|
@@ -26,9 +27,10 @@ import { Session } from './session.js';
|
|
|
26
27
|
import { FrameStreams } from './streams.js';
|
|
27
28
|
import { SystemdServices } from './systemd.js';
|
|
28
29
|
import { Teleop } from './teleop.js';
|
|
30
|
+
import { Terminals } from './terminals.js';
|
|
29
31
|
import { Tf } from './tf.js';
|
|
30
32
|
import { Topics } from './topics.js';
|
|
31
|
-
import { defaultPanelsDirectory, errorMessage, tasselloRoot } from './util.js';
|
|
33
|
+
import { defaultPanelsDirectory, errorMessage, ownPage, tasselloRoot } from './util.js';
|
|
32
34
|
// Connections that miss a ping for this long are dropped: a page whose
|
|
33
35
|
// network vanished never closes its socket by itself
|
|
34
36
|
const HEARTBEAT_MS = 10_000;
|
|
@@ -88,6 +90,8 @@ export async function startServer(options) {
|
|
|
88
90
|
const arm = new Arm(node, topics, logger);
|
|
89
91
|
const ros = new Ros(node);
|
|
90
92
|
const rosout = new Rosout(node, logger);
|
|
93
|
+
const diagnostics = new Diagnostics(node, logger);
|
|
94
|
+
const terminals = new Terminals(logger);
|
|
91
95
|
// Next to the panels
|
|
92
96
|
const uploadsDir = path.join(path.dirname(panelsDir), 'images');
|
|
93
97
|
const behaviorTrees = new BehaviorTrees(node, logger, uploadsDir);
|
|
@@ -133,11 +137,13 @@ export async function startServer(options) {
|
|
|
133
137
|
arm,
|
|
134
138
|
ros,
|
|
135
139
|
rosout,
|
|
140
|
+
diagnostics,
|
|
141
|
+
terminals,
|
|
136
142
|
behaviorTrees,
|
|
137
143
|
behaviorTreeFiles,
|
|
138
144
|
plugins: host,
|
|
139
145
|
hostname: os.hostname(),
|
|
140
|
-
});
|
|
146
|
+
}, ownPage(req.headers));
|
|
141
147
|
sessions.add(session);
|
|
142
148
|
logger.debug(`Page connected from ${req.socket.remoteAddress}`);
|
|
143
149
|
let alive = true;
|
|
@@ -169,6 +175,7 @@ export async function startServer(options) {
|
|
|
169
175
|
stopping = true;
|
|
170
176
|
commands.stopAll();
|
|
171
177
|
systemd.stopAll();
|
|
178
|
+
terminals.stopAll();
|
|
172
179
|
// Stops the robot if a page was driving it, while the node still runs
|
|
173
180
|
for (const session of sessions) {
|
|
174
181
|
session.close();
|
package/dist/server/session.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Arm } from './arm.ts';
|
|
|
5
5
|
import type { BehaviorTreeFiles } from './behavior_tree_files.ts';
|
|
6
6
|
import type { BehaviorTrees } from './behavior_trees.ts';
|
|
7
7
|
import type { CommandRunner } from './commands.ts';
|
|
8
|
+
import type { Diagnostics } from './diagnostics.ts';
|
|
8
9
|
import type { EncodedFrame, FrameSink, StreamState } from './frame_stream.ts';
|
|
9
10
|
import type { Poses } from './poses.ts';
|
|
10
11
|
import type { Paths } from './paths.ts';
|
|
@@ -14,6 +15,7 @@ import type { Rosout } from './rosout.ts';
|
|
|
14
15
|
import type { FrameStreams } from './streams.ts';
|
|
15
16
|
import type { SystemdServices } from './systemd.ts';
|
|
16
17
|
import type { Teleop } from './teleop.ts';
|
|
18
|
+
import type { Terminals } from './terminals.ts';
|
|
17
19
|
import type { Tf } from './tf.ts';
|
|
18
20
|
import type { Topics } from './topics.ts';
|
|
19
21
|
export interface SessionServices {
|
|
@@ -28,6 +30,8 @@ export interface SessionServices {
|
|
|
28
30
|
arm: Arm;
|
|
29
31
|
ros: Ros;
|
|
30
32
|
rosout: Rosout;
|
|
33
|
+
diagnostics: Diagnostics;
|
|
34
|
+
terminals: Terminals;
|
|
31
35
|
behaviorTrees: BehaviorTrees;
|
|
32
36
|
behaviorTreeFiles: BehaviorTreeFiles;
|
|
33
37
|
plugins: PluginHost;
|
|
@@ -41,8 +45,10 @@ export declare class Session implements FrameSink, PluginSession {
|
|
|
41
45
|
private readonly consumers;
|
|
42
46
|
private readonly arms;
|
|
43
47
|
private readonly playbacks;
|
|
48
|
+
private readonly terminals;
|
|
49
|
+
private readonly ownPage;
|
|
44
50
|
private lastError;
|
|
45
|
-
constructor(ws: WebSocket, services: SessionServices);
|
|
51
|
+
constructor(ws: WebSocket, services: SessionServices, ownPage: boolean);
|
|
46
52
|
send(message: ServerMessage): void;
|
|
47
53
|
sendStreamStatus(id: number, state: StreamState, message: string): void;
|
|
48
54
|
sendFrame(id: number, frame: EncodedFrame): void;
|