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/session.js
CHANGED
|
@@ -37,6 +37,8 @@ function strings(value, field) {
|
|
|
37
37
|
}
|
|
38
38
|
return value.map((v) => v.trim()).filter(Boolean);
|
|
39
39
|
}
|
|
40
|
+
// A terminal's name, chosen by the page
|
|
41
|
+
const TERMINAL_NAME = /^[\w-]{1,64}$/;
|
|
40
42
|
function subscriptionId(value) {
|
|
41
43
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffffffff) {
|
|
42
44
|
throw new Error('Invalid subscription id');
|
|
@@ -55,10 +57,16 @@ export class Session {
|
|
|
55
57
|
arms = new Map();
|
|
56
58
|
// The behavior tree playbacks, for their controls
|
|
57
59
|
playbacks = new Map();
|
|
60
|
+
// The terminals, for what is typed in them
|
|
61
|
+
terminals = new Map();
|
|
62
|
+
// The page is one of the dashboard's own (see ownPage): only those open
|
|
63
|
+
// terminals
|
|
64
|
+
ownPage;
|
|
58
65
|
lastError = { message: '', at: 0 };
|
|
59
|
-
constructor(ws, services) {
|
|
66
|
+
constructor(ws, services, ownPage) {
|
|
60
67
|
this.ws = ws;
|
|
61
68
|
this.services = services;
|
|
69
|
+
this.ownPage = ownPage;
|
|
62
70
|
ws.on('message', (data, isBinary) => {
|
|
63
71
|
if (!isBinary) {
|
|
64
72
|
this.onMessage(data.toString());
|
|
@@ -98,6 +106,7 @@ export class Session {
|
|
|
98
106
|
this.consumers.clear();
|
|
99
107
|
this.arms.clear();
|
|
100
108
|
this.playbacks.clear();
|
|
109
|
+
this.terminals.clear();
|
|
101
110
|
}
|
|
102
111
|
reportError(message) {
|
|
103
112
|
const now = Date.now();
|
|
@@ -237,6 +246,62 @@ export class Session {
|
|
|
237
246
|
}));
|
|
238
247
|
return;
|
|
239
248
|
}
|
|
249
|
+
case 'diagnostics.subscribe': {
|
|
250
|
+
const id = subscriptionId(message.id);
|
|
251
|
+
this.unsubscribe(id);
|
|
252
|
+
this.subscriptions.set(id, services.diagnostics.watch(string(message.topic, 'topic'), {
|
|
253
|
+
onStatus: (state, text) => this.sendStreamStatus(id, state, text),
|
|
254
|
+
onUpdate: (update) => this.send({ type: 'diagnostics', id, ...update }),
|
|
255
|
+
}));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
case 'terminal.open': {
|
|
259
|
+
const id = subscriptionId(message.id);
|
|
260
|
+
this.unsubscribe(id);
|
|
261
|
+
if (!this.ownPage) {
|
|
262
|
+
// e.g. another site's page, which a browser lets reach the robot
|
|
263
|
+
this.sendStreamStatus(id, 'error', 'Terminals open in the dashboard’s own pages only');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (typeof message.terminal !== 'string' || !TERMINAL_NAME.test(message.terminal)) {
|
|
267
|
+
throw new Error('Invalid terminal name');
|
|
268
|
+
}
|
|
269
|
+
const point = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
|
|
270
|
+
const view = services.terminals.open({
|
|
271
|
+
terminal: message.terminal,
|
|
272
|
+
command: optionalString(message.command),
|
|
273
|
+
folder: optionalString(message.folder) || '~',
|
|
274
|
+
cols: number(message.cols, 2, 1000, 80),
|
|
275
|
+
rows: number(message.rows, 1, 500, 24),
|
|
276
|
+
started: point(message.started),
|
|
277
|
+
offset: point(message.offset),
|
|
278
|
+
}, {
|
|
279
|
+
onOutput: (output) => this.send({ type: 'terminal.output', id, ...output }),
|
|
280
|
+
onExit: (exit) => this.send({ type: 'terminal.exit', id, ...exit }),
|
|
281
|
+
onStatus: (state, text) => this.sendStreamStatus(id, state, text),
|
|
282
|
+
});
|
|
283
|
+
this.terminals.set(id, view);
|
|
284
|
+
this.subscriptions.set(id, () => view.close());
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
case 'terminal.input':
|
|
288
|
+
if (typeof message.data === 'string') {
|
|
289
|
+
this.terminals.get(subscriptionId(message.id))?.input(message.data);
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
case 'terminal.resize':
|
|
293
|
+
this.terminals
|
|
294
|
+
.get(subscriptionId(message.id))
|
|
295
|
+
?.resize(number(message.cols, 2, 1000, 80), number(message.rows, 1, 500, 24));
|
|
296
|
+
return;
|
|
297
|
+
case 'terminal.ack':
|
|
298
|
+
if (typeof message.started === 'number' && typeof message.offset === 'number') {
|
|
299
|
+
this.terminals.get(subscriptionId(message.id))?.ack(message.started, message.offset);
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
case 'terminal.restart':
|
|
303
|
+
this.terminals.get(subscriptionId(message.id))?.restart();
|
|
304
|
+
return;
|
|
240
305
|
case 'arm.subscribe': {
|
|
241
306
|
const id = subscriptionId(message.id);
|
|
242
307
|
this.unsubscribe(id);
|
|
@@ -375,5 +440,6 @@ export class Session {
|
|
|
375
440
|
this.consumers.delete(id);
|
|
376
441
|
this.arms.delete(id);
|
|
377
442
|
this.playbacks.delete(id);
|
|
443
|
+
this.terminals.delete(id);
|
|
378
444
|
}
|
|
379
445
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare class OutputLog {
|
|
2
|
+
private readonly limit;
|
|
3
|
+
private readonly chunks;
|
|
4
|
+
private first;
|
|
5
|
+
private length;
|
|
6
|
+
constructor(limit: number);
|
|
7
|
+
get start(): number;
|
|
8
|
+
get end(): number;
|
|
9
|
+
append(text: string): void;
|
|
10
|
+
since(offset: number): string | null;
|
|
11
|
+
}
|
|
12
|
+
export type NextOutput = {
|
|
13
|
+
data: string;
|
|
14
|
+
offset: number;
|
|
15
|
+
} | 'snapshot' | null;
|
|
16
|
+
export declare class Delivery {
|
|
17
|
+
offset: number;
|
|
18
|
+
private readonly sent;
|
|
19
|
+
private size;
|
|
20
|
+
constructor(offset: number);
|
|
21
|
+
get pending(): number;
|
|
22
|
+
next(log: OutputLog, window: number): NextOutput;
|
|
23
|
+
record(offset: number, size: number): void;
|
|
24
|
+
ack(offset: number): void;
|
|
25
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// A terminal's output as its pages get it (see terminals.ts): numbered by
|
|
2
|
+
// offset, in characters (UTF-16 code units) since its program started, the
|
|
3
|
+
// latest kept for the pages that missed some; and for each page, what it was
|
|
4
|
+
// sent and has not written yet.
|
|
5
|
+
// Small chunks (echoes of what is typed) are joined into the last one, up to
|
|
6
|
+
// this many characters
|
|
7
|
+
const JOIN_BELOW = 16 * 1024;
|
|
8
|
+
export class OutputLog {
|
|
9
|
+
// At least `limit` characters, the latest, in whole chunks
|
|
10
|
+
limit;
|
|
11
|
+
chunks = [];
|
|
12
|
+
// Of the first chunk
|
|
13
|
+
first = 0;
|
|
14
|
+
length = 0;
|
|
15
|
+
constructor(limit) {
|
|
16
|
+
this.limit = limit;
|
|
17
|
+
}
|
|
18
|
+
// The oldest output kept
|
|
19
|
+
get start() {
|
|
20
|
+
return this.first;
|
|
21
|
+
}
|
|
22
|
+
get end() {
|
|
23
|
+
return this.first + this.length;
|
|
24
|
+
}
|
|
25
|
+
append(text) {
|
|
26
|
+
if (!text) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const last = this.chunks.length - 1;
|
|
30
|
+
if (last >= 0 && this.chunks[last].length < JOIN_BELOW) {
|
|
31
|
+
this.chunks[last] += text;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
this.chunks.push(text);
|
|
35
|
+
}
|
|
36
|
+
this.length += text.length;
|
|
37
|
+
while (this.chunks.length > 1 && this.length - this.chunks[0].length >= this.limit) {
|
|
38
|
+
const dropped = this.chunks.shift();
|
|
39
|
+
this.first += dropped.length;
|
|
40
|
+
this.length -= dropped.length;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// The output from offset on; null if it is no longer kept, or not yet
|
|
44
|
+
since(offset) {
|
|
45
|
+
if (offset < this.first || offset > this.end) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
let skip = offset - this.first;
|
|
49
|
+
let index = 0;
|
|
50
|
+
while (index < this.chunks.length && skip >= this.chunks[index].length) {
|
|
51
|
+
skip -= this.chunks[index].length;
|
|
52
|
+
index++;
|
|
53
|
+
}
|
|
54
|
+
return index === this.chunks.length ? '' : this.chunks[index].slice(skip) + this.chunks.slice(index + 1).join('');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function isHighSurrogate(code) {
|
|
58
|
+
return code >= 0xd800 && code <= 0xdbff;
|
|
59
|
+
}
|
|
60
|
+
// What a page was sent of the output, and has not written yet: at most a
|
|
61
|
+
// window of characters, so that a slow page (or one hidden, which writes
|
|
62
|
+
// nothing) does not pile output up on the way, and gets the screen instead
|
|
63
|
+
// once it lacks too much
|
|
64
|
+
export class Delivery {
|
|
65
|
+
// Where the page's output goes once it wrote what it was sent
|
|
66
|
+
offset;
|
|
67
|
+
// What it was sent: the offset after each message, and its size
|
|
68
|
+
sent = [];
|
|
69
|
+
size = 0;
|
|
70
|
+
constructor(offset) {
|
|
71
|
+
this.offset = offset;
|
|
72
|
+
}
|
|
73
|
+
// Characters sent and not written yet
|
|
74
|
+
get pending() {
|
|
75
|
+
return this.size;
|
|
76
|
+
}
|
|
77
|
+
// What the page should get next: the output it lacks, as much as the window
|
|
78
|
+
// leaves; the screen instead if it lacks more than a window of it, or what
|
|
79
|
+
// it lacks is no longer kept; null if up to date, or its window is full
|
|
80
|
+
next(log, window) {
|
|
81
|
+
const room = window - this.size;
|
|
82
|
+
if (this.offset === log.end || room <= 0) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const data = log.end - this.offset > window ? null : log.since(this.offset);
|
|
86
|
+
if (data === null) {
|
|
87
|
+
return 'snapshot';
|
|
88
|
+
}
|
|
89
|
+
let part = data.length > room ? data.slice(0, room) : data;
|
|
90
|
+
if (part.length < data.length && isHighSurrogate(part.charCodeAt(part.length - 1))) {
|
|
91
|
+
part = part.slice(0, -1);
|
|
92
|
+
}
|
|
93
|
+
return part ? { data: part, offset: this.offset + part.length } : null;
|
|
94
|
+
}
|
|
95
|
+
// Sent: the output up to offset, in a message of size characters
|
|
96
|
+
record(offset, size) {
|
|
97
|
+
this.offset = offset;
|
|
98
|
+
this.sent.push({ offset, size });
|
|
99
|
+
this.size += size;
|
|
100
|
+
}
|
|
101
|
+
// The page wrote the output up to offset: its messages are acknowledged in
|
|
102
|
+
// the order they were sent, whose offsets never go back
|
|
103
|
+
ack(offset) {
|
|
104
|
+
while (this.sent.length > 0 && this.sent[0].offset <= offset) {
|
|
105
|
+
this.size -= this.sent.shift().size;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type TerminalExit, type TerminalOutput } from '../shared/protocol.ts';
|
|
2
|
+
import type { StreamState } from './frame_stream.ts';
|
|
3
|
+
import { type Logger } from './util.ts';
|
|
4
|
+
export interface TerminalRequest {
|
|
5
|
+
terminal: string;
|
|
6
|
+
command: string;
|
|
7
|
+
folder: string;
|
|
8
|
+
cols: number;
|
|
9
|
+
rows: number;
|
|
10
|
+
started: number | null;
|
|
11
|
+
offset: number | null;
|
|
12
|
+
}
|
|
13
|
+
export interface TerminalWatcher {
|
|
14
|
+
onOutput: (output: Omit<TerminalOutput, 'type' | 'id'>) => void;
|
|
15
|
+
onExit: (exit: Omit<TerminalExit, 'type' | 'id'>) => void;
|
|
16
|
+
onStatus: (state: StreamState, message: string) => void;
|
|
17
|
+
}
|
|
18
|
+
export interface TerminalView {
|
|
19
|
+
input(data: string): void;
|
|
20
|
+
resize(cols: number, rows: number): void;
|
|
21
|
+
ack(started: number, offset: number): void;
|
|
22
|
+
restart(): void;
|
|
23
|
+
close(): void;
|
|
24
|
+
}
|
|
25
|
+
export declare class Terminals {
|
|
26
|
+
private readonly logger;
|
|
27
|
+
private readonly terminals;
|
|
28
|
+
constructor(logger: Logger);
|
|
29
|
+
open(request: TerminalRequest, watcher: TerminalWatcher): TerminalView;
|
|
30
|
+
stopAll(): void;
|
|
31
|
+
private hangUpUnseen;
|
|
32
|
+
}
|
|
@@ -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>;
|