tassello 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +175 -8
- package/dist/server/behavior_tree_files.d.ts +0 -1
- package/dist/server/behavior_tree_files.js +2 -25
- package/dist/server/diagnostics.d.ts +16 -0
- package/dist/server/diagnostics.js +200 -0
- package/dist/server/diagnostics_state.d.ts +23 -0
- package/dist/server/diagnostics_state.js +146 -0
- package/dist/server/disks.d.ts +14 -0
- package/dist/server/disks.js +152 -0
- package/dist/server/file_manager.d.ts +25 -0
- package/dist/server/file_manager.js +717 -0
- package/dist/server/http.js +248 -5
- package/dist/server/netpbm.d.ts +3 -0
- package/dist/server/netpbm.js +69 -0
- package/dist/server/networking.d.ts +41 -0
- package/dist/server/networking.js +508 -0
- package/dist/server/networking_format.d.ts +38 -0
- package/dist/server/networking_format.js +651 -0
- package/dist/server/pty.d.ts +26 -0
- package/dist/server/pty.js +150 -0
- package/dist/server/server.js +13 -2
- package/dist/server/session.d.ts +10 -1
- package/dist/server/session.js +88 -1
- package/dist/server/terminal_output.d.ts +25 -0
- package/dist/server/terminal_output.js +108 -0
- package/dist/server/terminals.d.ts +32 -0
- package/dist/server/terminals.js +366 -0
- package/dist/server/util.d.ts +4 -0
- package/dist/server/util.js +45 -1
- package/dist/shared/file_manager.d.ts +130 -0
- package/dist/shared/file_manager.js +7 -0
- package/dist/shared/networking.d.ts +178 -0
- package/dist/shared/networking.js +248 -0
- package/dist/shared/protocol.d.ts +103 -2
- package/dist/shared/protocol.js +8 -0
- package/dist/types/client/api.d.ts +22 -0
- package/dist/types/client/connection.d.ts +30 -1
- package/dist/types/client/icons.d.ts +33 -0
- package/dist/types/client/sdk/index.d.ts +5 -3
- package/dist/types/shared/file_manager.d.ts +130 -0
- package/dist/types/shared/networking.d.ts +178 -0
- package/dist/types/shared/protocol.d.ts +103 -2
- package/dist/www/assets/Joystick.js +1 -1
- package/dist/www/assets/RobotModelView.js +2 -2
- package/dist/www/assets/TerminalView.css +1 -0
- package/dist/www/assets/TerminalView.js +17 -0
- package/dist/www/assets/dialogs.js +1 -0
- package/dist/www/assets/icons.js +1 -1
- package/dist/www/assets/index.css +1 -1
- package/dist/www/assets/index.js +59 -19
- package/dist/www/assets/keys.js +3 -0
- package/dist/www/assets/shared/tassello-sdk.js +1 -1
- package/dist/www/index.html +2 -0
- package/docs/plugins.md +22 -0
- package/package.json +6 -2
|
@@ -0,0 +1,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,7 +15,9 @@ 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';
|
|
20
|
+
import { Networking } from './networking.js';
|
|
19
21
|
import { PanelStore } from './panels.js';
|
|
20
22
|
import { Paths } from './paths.js';
|
|
21
23
|
import { findPlugins, PluginHost, readPlugin } from './plugins.js';
|
|
@@ -26,9 +28,10 @@ import { Session } from './session.js';
|
|
|
26
28
|
import { FrameStreams } from './streams.js';
|
|
27
29
|
import { SystemdServices } from './systemd.js';
|
|
28
30
|
import { Teleop } from './teleop.js';
|
|
31
|
+
import { Terminals } from './terminals.js';
|
|
29
32
|
import { Tf } from './tf.js';
|
|
30
33
|
import { Topics } from './topics.js';
|
|
31
|
-
import { defaultPanelsDirectory, errorMessage, tasselloRoot } from './util.js';
|
|
34
|
+
import { defaultPanelsDirectory, errorMessage, ownPage, tasselloRoot } from './util.js';
|
|
32
35
|
// Connections that miss a ping for this long are dropped: a page whose
|
|
33
36
|
// network vanished never closes its socket by itself
|
|
34
37
|
const HEARTBEAT_MS = 10_000;
|
|
@@ -88,6 +91,9 @@ export async function startServer(options) {
|
|
|
88
91
|
const arm = new Arm(node, topics, logger);
|
|
89
92
|
const ros = new Ros(node);
|
|
90
93
|
const rosout = new Rosout(node, logger);
|
|
94
|
+
const diagnostics = new Diagnostics(node, logger);
|
|
95
|
+
const terminals = new Terminals(logger);
|
|
96
|
+
const networking = new Networking(logger);
|
|
91
97
|
// Next to the panels
|
|
92
98
|
const uploadsDir = path.join(path.dirname(panelsDir), 'images');
|
|
93
99
|
const behaviorTrees = new BehaviorTrees(node, logger, uploadsDir);
|
|
@@ -133,11 +139,14 @@ export async function startServer(options) {
|
|
|
133
139
|
arm,
|
|
134
140
|
ros,
|
|
135
141
|
rosout,
|
|
142
|
+
diagnostics,
|
|
143
|
+
terminals,
|
|
144
|
+
networking,
|
|
136
145
|
behaviorTrees,
|
|
137
146
|
behaviorTreeFiles,
|
|
138
147
|
plugins: host,
|
|
139
148
|
hostname: os.hostname(),
|
|
140
|
-
});
|
|
149
|
+
}, ownPage(req.headers), req.socket.localAddress ?? null);
|
|
141
150
|
sessions.add(session);
|
|
142
151
|
logger.debug(`Page connected from ${req.socket.remoteAddress}`);
|
|
143
152
|
let alive = true;
|
|
@@ -169,6 +178,8 @@ export async function startServer(options) {
|
|
|
169
178
|
stopping = true;
|
|
170
179
|
commands.stopAll();
|
|
171
180
|
systemd.stopAll();
|
|
181
|
+
terminals.stopAll();
|
|
182
|
+
networking.stopAll();
|
|
172
183
|
// Stops the robot if a page was driving it, while the node still runs
|
|
173
184
|
for (const session of sessions) {
|
|
174
185
|
session.close();
|
package/dist/server/session.d.ts
CHANGED
|
@@ -5,7 +5,9 @@ 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';
|
|
10
|
+
import type { Networking } from './networking.ts';
|
|
9
11
|
import type { Poses } from './poses.ts';
|
|
10
12
|
import type { Paths } from './paths.ts';
|
|
11
13
|
import type { PluginHost } from './plugins.ts';
|
|
@@ -14,6 +16,7 @@ import type { Rosout } from './rosout.ts';
|
|
|
14
16
|
import type { FrameStreams } from './streams.ts';
|
|
15
17
|
import type { SystemdServices } from './systemd.ts';
|
|
16
18
|
import type { Teleop } from './teleop.ts';
|
|
19
|
+
import type { Terminals } from './terminals.ts';
|
|
17
20
|
import type { Tf } from './tf.ts';
|
|
18
21
|
import type { Topics } from './topics.ts';
|
|
19
22
|
export interface SessionServices {
|
|
@@ -28,6 +31,9 @@ export interface SessionServices {
|
|
|
28
31
|
arm: Arm;
|
|
29
32
|
ros: Ros;
|
|
30
33
|
rosout: Rosout;
|
|
34
|
+
diagnostics: Diagnostics;
|
|
35
|
+
terminals: Terminals;
|
|
36
|
+
networking: Networking;
|
|
31
37
|
behaviorTrees: BehaviorTrees;
|
|
32
38
|
behaviorTreeFiles: BehaviorTreeFiles;
|
|
33
39
|
plugins: PluginHost;
|
|
@@ -41,8 +47,11 @@ export declare class Session implements FrameSink, PluginSession {
|
|
|
41
47
|
private readonly consumers;
|
|
42
48
|
private readonly arms;
|
|
43
49
|
private readonly playbacks;
|
|
50
|
+
private readonly terminals;
|
|
51
|
+
private readonly ownPage;
|
|
52
|
+
private readonly localAddress;
|
|
44
53
|
private lastError;
|
|
45
|
-
constructor(ws: WebSocket, services: SessionServices);
|
|
54
|
+
constructor(ws: WebSocket, services: SessionServices, ownPage: boolean, localAddress: string | null);
|
|
46
55
|
send(message: ServerMessage): void;
|
|
47
56
|
sendStreamStatus(id: number, state: StreamState, message: string): void;
|
|
48
57
|
sendFrame(id: number, frame: EncodedFrame): void;
|
package/dist/server/session.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { WebSocket } from 'ws';
|
|
6
6
|
import { FRAME_HEADER_BYTES, } from '../shared/protocol.js';
|
|
7
|
+
import { checkRequest, deviceOfAddress } from './networking_format.js';
|
|
7
8
|
import { errorMessage, toJson } from './util.js';
|
|
8
9
|
// The same error is reported to the page at most this often: a teleop widget
|
|
9
10
|
// publishing on an invalid topic would otherwise report it 10 times a second
|
|
@@ -37,6 +38,8 @@ function strings(value, field) {
|
|
|
37
38
|
}
|
|
38
39
|
return value.map((v) => v.trim()).filter(Boolean);
|
|
39
40
|
}
|
|
41
|
+
// A terminal's name, chosen by the page
|
|
42
|
+
const TERMINAL_NAME = /^[\w-]{1,64}$/;
|
|
40
43
|
function subscriptionId(value) {
|
|
41
44
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffffffff) {
|
|
42
45
|
throw new Error('Invalid subscription id');
|
|
@@ -55,10 +58,19 @@ export class Session {
|
|
|
55
58
|
arms = new Map();
|
|
56
59
|
// The behavior tree playbacks, for their controls
|
|
57
60
|
playbacks = new Map();
|
|
61
|
+
// The terminals, for what is typed in them
|
|
62
|
+
terminals = new Map();
|
|
63
|
+
// The page is one of the dashboard's own (see ownPage): only those open
|
|
64
|
+
// terminals and change the networks
|
|
65
|
+
ownPage;
|
|
66
|
+
// The server's address the page reached: the interface it comes through
|
|
67
|
+
localAddress;
|
|
58
68
|
lastError = { message: '', at: 0 };
|
|
59
|
-
constructor(ws, services) {
|
|
69
|
+
constructor(ws, services, ownPage, localAddress) {
|
|
60
70
|
this.ws = ws;
|
|
61
71
|
this.services = services;
|
|
72
|
+
this.ownPage = ownPage;
|
|
73
|
+
this.localAddress = localAddress;
|
|
62
74
|
ws.on('message', (data, isBinary) => {
|
|
63
75
|
if (!isBinary) {
|
|
64
76
|
this.onMessage(data.toString());
|
|
@@ -98,6 +110,7 @@ export class Session {
|
|
|
98
110
|
this.consumers.clear();
|
|
99
111
|
this.arms.clear();
|
|
100
112
|
this.playbacks.clear();
|
|
113
|
+
this.terminals.clear();
|
|
101
114
|
}
|
|
102
115
|
reportError(message) {
|
|
103
116
|
const now = Date.now();
|
|
@@ -237,6 +250,79 @@ export class Session {
|
|
|
237
250
|
}));
|
|
238
251
|
return;
|
|
239
252
|
}
|
|
253
|
+
case 'diagnostics.subscribe': {
|
|
254
|
+
const id = subscriptionId(message.id);
|
|
255
|
+
this.unsubscribe(id);
|
|
256
|
+
this.subscriptions.set(id, services.diagnostics.watch(string(message.topic, 'topic'), {
|
|
257
|
+
onStatus: (state, text) => this.sendStreamStatus(id, state, text),
|
|
258
|
+
onUpdate: (update) => this.send({ type: 'diagnostics', id, ...update }),
|
|
259
|
+
}));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
case 'terminal.open': {
|
|
263
|
+
const id = subscriptionId(message.id);
|
|
264
|
+
this.unsubscribe(id);
|
|
265
|
+
if (!this.ownPage) {
|
|
266
|
+
// e.g. another site's page, which a browser lets reach the robot
|
|
267
|
+
this.sendStreamStatus(id, 'error', 'Terminals open in the dashboard’s own pages only');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (typeof message.terminal !== 'string' || !TERMINAL_NAME.test(message.terminal)) {
|
|
271
|
+
throw new Error('Invalid terminal name');
|
|
272
|
+
}
|
|
273
|
+
const point = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
|
|
274
|
+
const view = services.terminals.open({
|
|
275
|
+
terminal: message.terminal,
|
|
276
|
+
command: optionalString(message.command),
|
|
277
|
+
folder: optionalString(message.folder) || '~',
|
|
278
|
+
cols: number(message.cols, 2, 1000, 80),
|
|
279
|
+
rows: number(message.rows, 1, 500, 24),
|
|
280
|
+
started: point(message.started),
|
|
281
|
+
offset: point(message.offset),
|
|
282
|
+
}, {
|
|
283
|
+
onOutput: (output) => this.send({ type: 'terminal.output', id, ...output }),
|
|
284
|
+
onExit: (exit) => this.send({ type: 'terminal.exit', id, ...exit }),
|
|
285
|
+
onStatus: (state, text) => this.sendStreamStatus(id, state, text),
|
|
286
|
+
});
|
|
287
|
+
this.terminals.set(id, view);
|
|
288
|
+
this.subscriptions.set(id, () => view.close());
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
case 'terminal.input':
|
|
292
|
+
if (typeof message.data === 'string') {
|
|
293
|
+
this.terminals.get(subscriptionId(message.id))?.input(message.data);
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
case 'terminal.resize':
|
|
297
|
+
this.terminals
|
|
298
|
+
.get(subscriptionId(message.id))
|
|
299
|
+
?.resize(number(message.cols, 2, 1000, 80), number(message.rows, 1, 500, 24));
|
|
300
|
+
return;
|
|
301
|
+
case 'terminal.ack':
|
|
302
|
+
if (typeof message.started === 'number' && typeof message.offset === 'number') {
|
|
303
|
+
this.terminals.get(subscriptionId(message.id))?.ack(message.started, message.offset);
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
case 'terminal.restart':
|
|
307
|
+
this.terminals.get(subscriptionId(message.id))?.restart();
|
|
308
|
+
return;
|
|
309
|
+
case 'network.subscribe': {
|
|
310
|
+
const id = subscriptionId(message.id);
|
|
311
|
+
this.unsubscribe(id);
|
|
312
|
+
this.subscriptions.set(id, services.networking.watch((state) => this.send({
|
|
313
|
+
type: 'network',
|
|
314
|
+
id,
|
|
315
|
+
...state,
|
|
316
|
+
via: deviceOfAddress(state.devices, this.localAddress),
|
|
317
|
+
canChange: this.ownPage,
|
|
318
|
+
})));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
case 'network.action':
|
|
322
|
+
this.answer(message.id, this.ownPage
|
|
323
|
+
? Promise.resolve(message.request).then((request) => services.networking.act(checkRequest(request)))
|
|
324
|
+
: Promise.reject(new Error('The networks are changed from the dashboard’s own pages only')));
|
|
325
|
+
return;
|
|
240
326
|
case 'arm.subscribe': {
|
|
241
327
|
const id = subscriptionId(message.id);
|
|
242
328
|
this.unsubscribe(id);
|
|
@@ -375,5 +461,6 @@ export class Session {
|
|
|
375
461
|
this.consumers.delete(id);
|
|
376
462
|
this.arms.delete(id);
|
|
377
463
|
this.playbacks.delete(id);
|
|
464
|
+
this.terminals.delete(id);
|
|
378
465
|
}
|
|
379
466
|
}
|
|
@@ -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
|
+
}
|