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
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,41 @@
|
|
|
1
|
+
import { type NetworkRequest, type NetworkResult, type NetworkSecrets, type NetworkState } from '../shared/networking.ts';
|
|
2
|
+
import { type Logger } from './util.ts';
|
|
3
|
+
type NetworkListener = (state: NetworkState) => void;
|
|
4
|
+
export declare class Networking {
|
|
5
|
+
private readonly logger;
|
|
6
|
+
private readonly listeners;
|
|
7
|
+
private latest;
|
|
8
|
+
private latestJson;
|
|
9
|
+
private timer;
|
|
10
|
+
private reading;
|
|
11
|
+
private again;
|
|
12
|
+
private monitor;
|
|
13
|
+
private monitorTimer;
|
|
14
|
+
private debounce;
|
|
15
|
+
private settings;
|
|
16
|
+
private settingsKey;
|
|
17
|
+
private settingsAt;
|
|
18
|
+
private permissions;
|
|
19
|
+
private permissionsAt;
|
|
20
|
+
private activity;
|
|
21
|
+
private nextActivity;
|
|
22
|
+
private scannedAt;
|
|
23
|
+
private scanning;
|
|
24
|
+
private scanTimer;
|
|
25
|
+
constructor(logger: Logger);
|
|
26
|
+
watch(listener: NetworkListener): () => void;
|
|
27
|
+
act(request: NetworkRequest): Promise<NetworkResult | NetworkSecrets>;
|
|
28
|
+
stopAll(): void;
|
|
29
|
+
private connection;
|
|
30
|
+
private track;
|
|
31
|
+
private rescan;
|
|
32
|
+
private setScanning;
|
|
33
|
+
private secrets;
|
|
34
|
+
private publish;
|
|
35
|
+
private emit;
|
|
36
|
+
private refresh;
|
|
37
|
+
private read;
|
|
38
|
+
private startMonitor;
|
|
39
|
+
private stop;
|
|
40
|
+
}
|
|
41
|
+
export {};
|