herdr-remote 0.2.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/LICENSE +21 -0
- package/README.md +155 -0
- package/bin/herdr-remote.js +225 -0
- package/config.example.json +19 -0
- package/dist/tui.mjs +3982 -0
- package/herdr-plugin.toml +61 -0
- package/package.json +60 -0
- package/src/config.js +411 -0
- package/src/exit-codes.js +14 -0
- package/src/herdr-command.js +12 -0
- package/src/herdr-plugin.js +122 -0
- package/src/host-connector.js +280 -0
- package/src/i18n/en.js +219 -0
- package/src/i18n/index.js +56 -0
- package/src/i18n/zh.js +218 -0
- package/src/keepalive.js +404 -0
- package/src/lifecycle.js +58 -0
- package/src/net-interfaces.js +76 -0
- package/src/pty-session.js +92 -0
- package/src/service.js +492 -0
- package/src/settings-model.js +265 -0
- package/src/socket-discovery.js +55 -0
- package/src/state.js +47 -0
- package/src/supervisor.js +242 -0
- package/src/terminal-palette.js +325 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads the colors of the terminal the workstation is actually looking at.
|
|
5
|
+
*
|
|
6
|
+
* A browser cannot know what a Herdr session looks like on the host: the PTY
|
|
7
|
+
* only carries color *indices*, and whoever renders them decides what "red"
|
|
8
|
+
* or "the default background" means. So the host asks its own terminal, once,
|
|
9
|
+
* with the standard OSC color queries every mainstream emulator answers:
|
|
10
|
+
*
|
|
11
|
+
* OSC 10 ; ? — default foreground
|
|
12
|
+
* OSC 11 ; ? — default background
|
|
13
|
+
* OSC 12 ; ? — cursor
|
|
14
|
+
* OSC 4 ; n ; ? — ANSI slot n (0-15)
|
|
15
|
+
*
|
|
16
|
+
* The answers travel to the browser through `host_hello`, and xterm renders
|
|
17
|
+
* with them. Nothing here guesses: without a terminal to ask, the result is
|
|
18
|
+
* `null` and the browser keeps xterm's own defaults.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const { spawnSync } = require('node:child_process');
|
|
23
|
+
// One definition of what a palette may contain, shared with the relay so the
|
|
24
|
+
// host cannot report a shape the wire rejects.
|
|
25
|
+
const { ANSI_PALETTE_KEYS, sanitizeTerminalPalette } = require('herdr-remote-relay/protocol');
|
|
26
|
+
const { runtimeStatePath, stateDir } = require('./config');
|
|
27
|
+
const { ensureDir, readJson, writeJsonAtomic } = require('./state');
|
|
28
|
+
|
|
29
|
+
const ANSI_SLOTS = ANSI_PALETTE_KEYS.length;
|
|
30
|
+
/**
|
|
31
|
+
* VTIME in tenths of a second: the terminal driver returns from a read after
|
|
32
|
+
* this long even with nothing to read, which is what bounds a silent terminal
|
|
33
|
+
* without burning CPU in a poll loop.
|
|
34
|
+
*/
|
|
35
|
+
const READ_TIMEOUT_TENTHS = '3';
|
|
36
|
+
/**
|
|
37
|
+
* Two empty reads in a row mean this query will not be answered.
|
|
38
|
+
*
|
|
39
|
+
* The resulting ~600ms per query is not padding: with a multiplexer between
|
|
40
|
+
* this process and the emulator, the emulator's answer measurably arrives
|
|
41
|
+
* later than 400ms, and giving up sooner makes the multiplexer answer from its
|
|
42
|
+
* own configuration instead — a palette that is not the one on screen.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_IDLE_READS = 2;
|
|
45
|
+
/**
|
|
46
|
+
* The whole probe is bounded: a start must never wait on a terminal that
|
|
47
|
+
* answers slowly, or not at all. Sized for the nineteen queries a fully
|
|
48
|
+
* answering terminal replies to in milliseconds, with room for the mixed case
|
|
49
|
+
* where the ANSI ramp is reported but the default colors are not.
|
|
50
|
+
*/
|
|
51
|
+
const TOTAL_TIMEOUT_MS = 3000;
|
|
52
|
+
|
|
53
|
+
const ANSI_KEYS = ANSI_PALETTE_KEYS;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `rgb:RRRR/GGGG/BBBB` (and the 1/2/3-digit variants) to `#rrggbb`.
|
|
57
|
+
* Terminals answer in 16-bit-per-channel notation; the top byte is the color.
|
|
58
|
+
*/
|
|
59
|
+
function parseXColor(value) {
|
|
60
|
+
if (typeof value !== 'string') return null;
|
|
61
|
+
const match = /^rgba?:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i.exec(value.trim());
|
|
62
|
+
if (!match) return null;
|
|
63
|
+
const channels = match.slice(1, 4).map((raw) => {
|
|
64
|
+
const width = raw.length;
|
|
65
|
+
if (width === 0 || width > 4) return null;
|
|
66
|
+
const scaled = Math.round((parseInt(raw, 16) / (16 ** width - 1)) * 255);
|
|
67
|
+
return Math.max(0, Math.min(255, scaled));
|
|
68
|
+
});
|
|
69
|
+
if (channels.some((channel) => channel === null)) return null;
|
|
70
|
+
return `#${channels.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Locates one complete OSC reply, e.g. `ESC ] 11 ; rgb:2222/2222/2626 ESC \`.
|
|
75
|
+
*
|
|
76
|
+
* A reply is only complete once its terminator arrives, so a half-read answer
|
|
77
|
+
* waits for the rest of the bytes instead of being parsed out of a fragment.
|
|
78
|
+
*/
|
|
79
|
+
function findOscColorReply(text, expectedPrefix) {
|
|
80
|
+
if (typeof text !== 'string') return null;
|
|
81
|
+
const start = text.indexOf(`${expectedPrefix};`);
|
|
82
|
+
if (start === -1) return null;
|
|
83
|
+
|
|
84
|
+
const stringTerminator = text.indexOf('\x1b\\', start);
|
|
85
|
+
const bell = text.indexOf('\x07', start);
|
|
86
|
+
let bodyEnd = -1;
|
|
87
|
+
let end = -1;
|
|
88
|
+
if (stringTerminator !== -1 && (bell === -1 || stringTerminator < bell)) {
|
|
89
|
+
bodyEnd = stringTerminator;
|
|
90
|
+
end = stringTerminator + 2;
|
|
91
|
+
} else if (bell !== -1) {
|
|
92
|
+
bodyEnd = bell;
|
|
93
|
+
end = bell + 1;
|
|
94
|
+
} else {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const color = parseXColor(text.slice(start + expectedPrefix.length + 1, bodyEnd).trim());
|
|
99
|
+
return color ? { color, start, end } : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Pulls the color out of one OSC reply. */
|
|
103
|
+
function parseOscColorReply(reply, expectedPrefix) {
|
|
104
|
+
return findOscColorReply(reply, expectedPrefix)?.color ?? null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A palette is only usable if every field it claims to have is a real color. */
|
|
108
|
+
const sanitizePalette = sanitizeTerminalPalette;
|
|
109
|
+
|
|
110
|
+
/** Reads a palette a parent process already captured, so children never re-probe. */
|
|
111
|
+
function paletteFromEnvironment(env = process.env) {
|
|
112
|
+
const raw = env.HERDR_TERM_PALETTE_JSON;
|
|
113
|
+
if (!raw) return null;
|
|
114
|
+
try {
|
|
115
|
+
return sanitizePalette(JSON.parse(raw));
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function runStty(args, ttyPath) {
|
|
122
|
+
return spawnSync('stty', [...args, '-F', ttyPath], { encoding: 'utf8', timeout: 1000 });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Takes one reply out of the buffered bytes, leaving the rest.
|
|
127
|
+
*
|
|
128
|
+
* One read can carry several answers, and a terminal is free to volunteer
|
|
129
|
+
* bytes of its own; dropping everything on a match would throw away replies
|
|
130
|
+
* the next query is still waiting for.
|
|
131
|
+
*/
|
|
132
|
+
function takeOscColorReply(state, expectedPrefix) {
|
|
133
|
+
const found = findOscColorReply(state.pending, expectedPrefix);
|
|
134
|
+
if (!found) return null;
|
|
135
|
+
state.pending = state.pending.slice(0, found.start) + state.pending.slice(found.end);
|
|
136
|
+
return found.color;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Runs the color conversation over an injected `ask(query, prefix)`.
|
|
141
|
+
*
|
|
142
|
+
* Order is not cosmetic here, and it was settled by measurement rather than by
|
|
143
|
+
* reasoning. Asking the default colors (OSC 10/11/12) first and the ANSI ramp
|
|
144
|
+
* afterwards makes a multiplexer between this process and the emulator pass
|
|
145
|
+
* the whole conversation through, and all nineteen answers then come from the
|
|
146
|
+
* emulator that is actually painting the screen — one coherent palette, in a
|
|
147
|
+
* few milliseconds. Asking the ramp first instead had the multiplexer answer
|
|
148
|
+
* OSC 4 from its own configuration while the default colors went unanswered:
|
|
149
|
+
* sixteen colors from one source, no background from the other.
|
|
150
|
+
*
|
|
151
|
+
* The ramp is asked for unconditionally, so a terminal that reports its colors
|
|
152
|
+
* but not its defaults (or the reverse) still contributes what it knows. A
|
|
153
|
+
* terminal that answers none of the first four questions is left alone rather
|
|
154
|
+
* than asked fifteen more times.
|
|
155
|
+
*/
|
|
156
|
+
function collectPalette(ask) {
|
|
157
|
+
const palette = {};
|
|
158
|
+
|
|
159
|
+
const foreground = ask('\x1b]10;?\x1b\\', '\x1b]10');
|
|
160
|
+
if (foreground) palette.foreground = foreground;
|
|
161
|
+
const background = ask('\x1b]11;?\x1b\\', '\x1b]11');
|
|
162
|
+
if (background) palette.background = background;
|
|
163
|
+
const cursor = ask('\x1b]12;?\x1b\\', '\x1b]12');
|
|
164
|
+
if (cursor) palette.cursor = cursor;
|
|
165
|
+
|
|
166
|
+
const ansi = {};
|
|
167
|
+
for (let slot = 0; slot < ANSI_SLOTS; slot += 1) {
|
|
168
|
+
const color = ask(`\x1b]4;${slot};?\x1b\\`, `\x1b]4;${slot}`);
|
|
169
|
+
if (!color) break;
|
|
170
|
+
ansi[ANSI_KEYS[slot]] = color;
|
|
171
|
+
}
|
|
172
|
+
if (Object.keys(ansi).length === ANSI_SLOTS) palette.ansi = ansi;
|
|
173
|
+
|
|
174
|
+
return sanitizePalette(palette);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Asks one question and waits for that answer, on an already-raw terminal.
|
|
179
|
+
* Anything the terminal volunteers in the meantime is kept in `pending` so a
|
|
180
|
+
* late reply is still matched to the query it belongs to.
|
|
181
|
+
*/
|
|
182
|
+
function askTerminal(fd, query, expectedPrefix, state) {
|
|
183
|
+
fs.writeSync(fd, query);
|
|
184
|
+
const buffer = Buffer.alloc(256);
|
|
185
|
+
let idleReads = 0;
|
|
186
|
+
|
|
187
|
+
while (idleReads < MAX_IDLE_READS && Date.now() < state.overallDeadline) {
|
|
188
|
+
const matched = takeOscColorReply(state, expectedPrefix);
|
|
189
|
+
if (matched) return matched;
|
|
190
|
+
let bytesRead = 0;
|
|
191
|
+
try {
|
|
192
|
+
// Blocking, but bounded by VTIME: it returns 0 when the terminal is quiet.
|
|
193
|
+
bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error.code === 'EAGAIN') {
|
|
196
|
+
idleReads += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
if (bytesRead > 0) {
|
|
202
|
+
state.pending += buffer.toString('latin1', 0, bytesRead);
|
|
203
|
+
idleReads = 0;
|
|
204
|
+
} else {
|
|
205
|
+
idleReads += 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return takeOscColorReply(state, expectedPrefix);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Queries the controlling terminal for its colors.
|
|
214
|
+
*
|
|
215
|
+
* Returns `null` — never a guess — when there is no terminal, when the
|
|
216
|
+
* terminal stays silent, or when anything about the exchange goes wrong.
|
|
217
|
+
*/
|
|
218
|
+
function probeTerminalPalette({ ttyPath = '/dev/tty', timeoutMs = TOTAL_TIMEOUT_MS } = {}) {
|
|
219
|
+
if (process.platform === 'win32') return null;
|
|
220
|
+
|
|
221
|
+
let fd = null;
|
|
222
|
+
let savedMode = null;
|
|
223
|
+
try {
|
|
224
|
+
fd = fs.openSync(ttyPath, 'r+');
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const saved = runStty(['-g'], ttyPath);
|
|
231
|
+
if (saved.status !== 0) return null;
|
|
232
|
+
savedMode = saved.stdout.trim();
|
|
233
|
+
// The reply is written to the terminal, not echoed by a line discipline:
|
|
234
|
+
// raw mode is what lets this process read it instead of the user's shell.
|
|
235
|
+
if (runStty(['raw', '-echo', 'min', '0', 'time', READ_TIMEOUT_TENTHS], ttyPath).status !== 0) return null;
|
|
236
|
+
|
|
237
|
+
const state = { pending: '', overallDeadline: Date.now() + timeoutMs };
|
|
238
|
+
return collectPalette((query, prefix) => askTerminal(fd, query, prefix, state));
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
} finally {
|
|
242
|
+
if (savedMode) runStty([savedMode], ttyPath);
|
|
243
|
+
if (fd !== null) {
|
|
244
|
+
try { fs.closeSync(fd); } catch {}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Remembers a palette for the next start that has no terminal to ask.
|
|
251
|
+
*
|
|
252
|
+
* A service manager launches the services with no terminal at all, and the
|
|
253
|
+
* workstation has not changed color just because systemd, rather than a
|
|
254
|
+
* person, started it this time.
|
|
255
|
+
*/
|
|
256
|
+
function rememberTerminalPalette(palette) {
|
|
257
|
+
const clean = sanitizePalette(palette);
|
|
258
|
+
if (!clean) return null;
|
|
259
|
+
try {
|
|
260
|
+
ensureDir(stateDir());
|
|
261
|
+
const state = readJson(runtimeStatePath(), {});
|
|
262
|
+
writeJsonAtomic(runtimeStatePath(), { ...state, terminalPalette: clean });
|
|
263
|
+
} catch {
|
|
264
|
+
// A palette is a nicety; failing to remember it must not break a start.
|
|
265
|
+
}
|
|
266
|
+
return clean;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The palette a previous start captured from a terminal, if any. */
|
|
270
|
+
function rememberedTerminalPalette() {
|
|
271
|
+
try {
|
|
272
|
+
return sanitizePalette(readJson(runtimeStatePath(), {}).terminalPalette);
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The palette a service start should hand to its children: whatever a parent
|
|
280
|
+
* already captured, otherwise a fresh probe of this process's terminal.
|
|
281
|
+
*/
|
|
282
|
+
function resolveHostPalette({ env = process.env, probe = probeTerminalPalette } = {}) {
|
|
283
|
+
return paletteFromEnvironment(env) || probe() || null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Capture the terminal's colors at the entry point, before anything else takes
|
|
288
|
+
* the screen.
|
|
289
|
+
*
|
|
290
|
+
* The configuration TUI owns the terminal once it starts, and a probe issued
|
|
291
|
+
* underneath it would race its input handling for the reply. Asking here, at
|
|
292
|
+
* the very start of the command, means every later caller in this process tree
|
|
293
|
+
* simply inherits the answer through the environment.
|
|
294
|
+
*/
|
|
295
|
+
function captureTerminalPalette({ env = process.env, probe = probeTerminalPalette } = {}) {
|
|
296
|
+
if (env.HERDR_TERM_PALETTE_JSON) return paletteFromEnvironment(env);
|
|
297
|
+
// Without both ends on a terminal there is nobody to answer the query.
|
|
298
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
|
|
299
|
+
|
|
300
|
+
const palette = probe();
|
|
301
|
+
if (!palette) return null;
|
|
302
|
+
|
|
303
|
+
env.HERDR_TERM_PALETTE_JSON = JSON.stringify(palette);
|
|
304
|
+
// The environment only reaches children of this process. A start handed to
|
|
305
|
+
// systemd or launchd runs outside that tree, so the answer is written down
|
|
306
|
+
// as well and picked up from there.
|
|
307
|
+
rememberTerminalPalette(palette);
|
|
308
|
+
return palette;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = {
|
|
312
|
+
ANSI_KEYS,
|
|
313
|
+
ANSI_SLOTS,
|
|
314
|
+
parseXColor,
|
|
315
|
+
parseOscColorReply,
|
|
316
|
+
takeOscColorReply,
|
|
317
|
+
collectPalette,
|
|
318
|
+
sanitizePalette,
|
|
319
|
+
paletteFromEnvironment,
|
|
320
|
+
probeTerminalPalette,
|
|
321
|
+
resolveHostPalette,
|
|
322
|
+
captureTerminalPalette,
|
|
323
|
+
rememberTerminalPalette,
|
|
324
|
+
rememberedTerminalPalette,
|
|
325
|
+
};
|