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.
Files changed (49) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +112 -8
  3. package/dist/server/behavior_tree_files.d.ts +0 -1
  4. package/dist/server/behavior_tree_files.js +2 -25
  5. package/dist/server/diagnostics.d.ts +16 -0
  6. package/dist/server/diagnostics.js +200 -0
  7. package/dist/server/diagnostics_state.d.ts +23 -0
  8. package/dist/server/diagnostics_state.js +146 -0
  9. package/dist/server/disks.d.ts +14 -0
  10. package/dist/server/disks.js +152 -0
  11. package/dist/server/file_manager.d.ts +25 -0
  12. package/dist/server/file_manager.js +717 -0
  13. package/dist/server/http.js +248 -5
  14. package/dist/server/netpbm.d.ts +3 -0
  15. package/dist/server/netpbm.js +69 -0
  16. package/dist/server/pty.d.ts +26 -0
  17. package/dist/server/pty.js +150 -0
  18. package/dist/server/server.js +9 -2
  19. package/dist/server/session.d.ts +7 -1
  20. package/dist/server/session.js +67 -1
  21. package/dist/server/terminal_output.d.ts +25 -0
  22. package/dist/server/terminal_output.js +108 -0
  23. package/dist/server/terminals.d.ts +32 -0
  24. package/dist/server/terminals.js +366 -0
  25. package/dist/server/util.d.ts +4 -0
  26. package/dist/server/util.js +45 -1
  27. package/dist/shared/file_manager.d.ts +130 -0
  28. package/dist/shared/file_manager.js +7 -0
  29. package/dist/shared/protocol.d.ts +87 -2
  30. package/dist/shared/protocol.js +8 -0
  31. package/dist/types/client/api.d.ts +22 -0
  32. package/dist/types/client/connection.d.ts +22 -1
  33. package/dist/types/client/icons.d.ts +27 -0
  34. package/dist/types/client/sdk/index.d.ts +4 -3
  35. package/dist/types/shared/file_manager.d.ts +130 -0
  36. package/dist/types/shared/protocol.d.ts +87 -2
  37. package/dist/www/assets/Joystick.js +1 -1
  38. package/dist/www/assets/RobotModelView.js +2 -2
  39. package/dist/www/assets/TerminalView.css +1 -0
  40. package/dist/www/assets/TerminalView.js +17 -0
  41. package/dist/www/assets/dialogs.js +1 -0
  42. package/dist/www/assets/icons.js +1 -1
  43. package/dist/www/assets/index.css +1 -1
  44. package/dist/www/assets/index.js +59 -19
  45. package/dist/www/assets/keys.js +3 -0
  46. package/dist/www/assets/shared/tassello-sdk.js +1 -1
  47. package/dist/www/index.html +2 -0
  48. package/docs/plugins.md +20 -0
  49. package/package.json +6 -2
@@ -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 { fileKind, isUploadName, listDirectory, MAX_UPLOAD_BYTES, resolveImagePath, saveUpload } from './files.js';
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 = { 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox" };
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, MAX_BODY_BYTES)).toString('utf8'));
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,3 @@
1
+ import type { Pixels } from './image_encoding.ts';
2
+ export declare const NETPBM_EXTENSIONS: Set<string>;
3
+ export declare function decodeNetpbm(data: Buffer): Pixels;
@@ -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
+ }
@@ -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();
@@ -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;