herdr-remote 0.2.18 → 0.2.19
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/bin/herdr-remote.js +3 -225
- package/dist/agent-status.js +130 -0
- package/dist/binary-plist.js +157 -0
- package/dist/cli.js +255 -0
- package/dist/config.js +270 -0
- package/dist/connector/agent-watch.js +110 -0
- package/dist/connector/font-server.js +165 -0
- package/dist/connector/host-connector.js +430 -0
- package/dist/connector/lock.js +66 -0
- package/dist/connector/main.js +18 -0
- package/dist/connector/sessions.js +366 -0
- package/dist/connector/update-report.js +67 -0
- package/dist/connector/wire.js +42 -0
- package/{src → dist}/exit-codes.js +1 -4
- package/dist/font-subset.js +110 -0
- package/dist/herdr-api.js +246 -0
- package/dist/herdr-command.js +223 -0
- package/dist/herdr-plugin.js +116 -0
- package/dist/herdr-server.js +189 -0
- package/dist/i18n/en.js +223 -0
- package/dist/i18n/index.js +48 -0
- package/dist/i18n/zh.js +222 -0
- package/dist/keepalive/detached.js +78 -0
- package/dist/keepalive/environment.js +44 -0
- package/dist/keepalive/index.js +110 -0
- package/dist/keepalive/launchd.js +117 -0
- package/dist/keepalive/systemd.js +108 -0
- package/dist/keepalive/types.js +1 -0
- package/dist/lib/process.js +12 -0
- package/dist/lifecycle.js +59 -0
- package/dist/net-interfaces.js +73 -0
- package/dist/pasted-files.js +115 -0
- package/dist/paths.js +62 -0
- package/dist/pty-session.js +96 -0
- package/dist/relay-client.js +106 -0
- package/dist/relay-urls.js +99 -0
- package/dist/runtime.js +106 -0
- package/dist/service.js +260 -0
- package/dist/settings-model.js +298 -0
- package/dist/socket-discovery.js +48 -0
- package/dist/supervisor.js +319 -0
- package/dist/terminal-font/deps.js +73 -0
- package/dist/terminal-font/files.js +354 -0
- package/dist/terminal-font/formats.js +335 -0
- package/dist/terminal-font/index.js +187 -0
- package/dist/terminal-font/readers.js +316 -0
- package/dist/terminal-font/terminals.js +128 -0
- package/dist/terminal-palette.js +339 -0
- package/dist/tui/App.js +195 -0
- package/dist/tui/api.js +79 -0
- package/dist/tui/components/TextField.js +50 -0
- package/dist/tui/components/common.js +80 -0
- package/dist/tui/index.js +36 -0
- package/dist/tui/mouse/index.js +164 -0
- package/dist/tui/mouse/protocol.js +96 -0
- package/dist/tui/mouse/source.js +74 -0
- package/dist/tui/screens/About.js +166 -0
- package/dist/tui/screens/Herdr.js +74 -0
- package/dist/tui/screens/Keepalive.js +84 -0
- package/dist/tui/screens/Overview.js +36 -0
- package/dist/tui/screens/Pair.js +74 -0
- package/dist/tui/screens/Relay.js +242 -0
- package/dist/tui/screens/Services.js +63 -0
- package/dist/tui/screens/Wizard.js +235 -0
- package/dist/tui/terminal.js +39 -0
- package/dist/tui/theme.js +33 -0
- package/dist/updater.js +382 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +8 -8
- package/dist/tui.mjs +0 -5775
- package/src/agent-status.js +0 -139
- package/src/binary-plist.js +0 -146
- package/src/config.js +0 -454
- package/src/font-subset.js +0 -110
- package/src/herdr-api.js +0 -244
- package/src/herdr-command.js +0 -231
- package/src/herdr-plugin.js +0 -130
- package/src/herdr-server.js +0 -201
- package/src/host-connector.js +0 -1069
- package/src/i18n/en.js +0 -240
- package/src/i18n/index.js +0 -56
- package/src/i18n/zh.js +0 -239
- package/src/keepalive.js +0 -460
- package/src/lifecycle.js +0 -62
- package/src/net-interfaces.js +0 -76
- package/src/pasted-files.js +0 -176
- package/src/pty-session.js +0 -92
- package/src/service.js +0 -530
- package/src/settings-model.js +0 -323
- package/src/socket-discovery.js +0 -60
- package/src/state.js +0 -47
- package/src/supervisor.js +0 -250
- package/src/terminal-font.js +0 -1204
- package/src/terminal-palette.js +0 -325
- package/src/updater.js +0 -394
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// The formats terminal settings come in: INI, GVariant, Pango and Qt font
|
|
2
|
+
// strings, fontconfig patterns, JSONC, a TOML subset and X resources.
|
|
3
|
+
import { pointsToPx, positiveNumber } from './deps.js';
|
|
4
|
+
/** `[section]` / `key=value` files: konsolerc, kdeglobals, terminalrc, foot.ini. */
|
|
5
|
+
export function parseIni(text) {
|
|
6
|
+
const sections = { '': {} };
|
|
7
|
+
let current = sections[''];
|
|
8
|
+
for (const rawLine of String(text || '').split(/\r?\n/)) {
|
|
9
|
+
const line = rawLine.trim();
|
|
10
|
+
if (!line || line.startsWith('#') || line.startsWith(';'))
|
|
11
|
+
continue;
|
|
12
|
+
const header = /^\[(.+)\]$/.exec(line);
|
|
13
|
+
if (header) {
|
|
14
|
+
current = sections[header[1].trim()] ||= {};
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const equals = line.indexOf('=');
|
|
18
|
+
if (equals === -1)
|
|
19
|
+
continue;
|
|
20
|
+
current[line.slice(0, equals).trim()] = line.slice(equals + 1).trim();
|
|
21
|
+
}
|
|
22
|
+
return sections;
|
|
23
|
+
}
|
|
24
|
+
/** A GSettings value as printed by `gsettings get`: `'text'`, `true`, `@as []`. */
|
|
25
|
+
export function parseGVariantString(output) {
|
|
26
|
+
const text = String(output || '').trim();
|
|
27
|
+
const quoted = /^'((?:[^'\\]|\\.)*)'$/.exec(text) || /^"((?:[^"\\]|\\.)*)"$/.exec(text);
|
|
28
|
+
if (!quoted)
|
|
29
|
+
return null;
|
|
30
|
+
return quoted[1].replace(/\\(.)/g, '$1');
|
|
31
|
+
}
|
|
32
|
+
const PANGO_STYLE_WORDS = new Set([
|
|
33
|
+
'normal',
|
|
34
|
+
'roman',
|
|
35
|
+
'oblique',
|
|
36
|
+
'italic',
|
|
37
|
+
'small-caps',
|
|
38
|
+
'all-small-caps',
|
|
39
|
+
'petite-caps',
|
|
40
|
+
'all-petite-caps',
|
|
41
|
+
'unicase',
|
|
42
|
+
'title-caps',
|
|
43
|
+
'ultra-condensed',
|
|
44
|
+
'extra-condensed',
|
|
45
|
+
'condensed',
|
|
46
|
+
'semi-condensed',
|
|
47
|
+
'semi-expanded',
|
|
48
|
+
'expanded',
|
|
49
|
+
'extra-expanded',
|
|
50
|
+
'ultra-expanded',
|
|
51
|
+
'thin',
|
|
52
|
+
'ultra-light',
|
|
53
|
+
'extra-light',
|
|
54
|
+
'light',
|
|
55
|
+
'semi-light',
|
|
56
|
+
'demi-light',
|
|
57
|
+
'book',
|
|
58
|
+
'regular',
|
|
59
|
+
'medium',
|
|
60
|
+
'semi-bold',
|
|
61
|
+
'demi-bold',
|
|
62
|
+
'bold',
|
|
63
|
+
'ultra-bold',
|
|
64
|
+
'extra-bold',
|
|
65
|
+
'heavy',
|
|
66
|
+
'black',
|
|
67
|
+
'ultra-black',
|
|
68
|
+
'extra-black',
|
|
69
|
+
'ultra-heavy',
|
|
70
|
+
'extra-heavy',
|
|
71
|
+
'not-rotated',
|
|
72
|
+
'south',
|
|
73
|
+
'upside-down',
|
|
74
|
+
'north',
|
|
75
|
+
'rotated-left',
|
|
76
|
+
'east',
|
|
77
|
+
'rotated-right',
|
|
78
|
+
'west',
|
|
79
|
+
]);
|
|
80
|
+
/**
|
|
81
|
+
* `JetBrainsMono Nerd Font Bold 9` → family and size.
|
|
82
|
+
*
|
|
83
|
+
* Pango writes `FAMILY-LIST [STYLE-OPTIONS] [SIZE[px]] [@VARIATIONS]`: the
|
|
84
|
+
* size and the style words are peeled off the end, and what is left is the
|
|
85
|
+
* family list, whose first entry is the face the terminal asked for.
|
|
86
|
+
*/
|
|
87
|
+
export function parsePangoFontDescription(description) {
|
|
88
|
+
if (typeof description !== 'string')
|
|
89
|
+
return null;
|
|
90
|
+
const words = description.trim().split(/\s+/).filter(Boolean);
|
|
91
|
+
while (words.length && words[words.length - 1].startsWith('@'))
|
|
92
|
+
words.pop();
|
|
93
|
+
let sizePx;
|
|
94
|
+
const size = /^(\d+(?:\.\d+)?)(px)?$/i.exec(words[words.length - 1] || '');
|
|
95
|
+
if (size && words.length > 1) {
|
|
96
|
+
words.pop();
|
|
97
|
+
sizePx = size[2] ? positiveNumber(size[1]) : pointsToPx(size[1]);
|
|
98
|
+
}
|
|
99
|
+
while (words.length > 1) {
|
|
100
|
+
const last = words[words.length - 1].toLowerCase();
|
|
101
|
+
if (!PANGO_STYLE_WORDS.has(last) && !/^weight=\d+$/.test(last))
|
|
102
|
+
break;
|
|
103
|
+
words.pop();
|
|
104
|
+
}
|
|
105
|
+
const family = words.join(' ').split(',')[0].trim();
|
|
106
|
+
return family ? { family, sizePx } : null;
|
|
107
|
+
}
|
|
108
|
+
/** Qt's `QFont::toString()`: `JetBrains Mono,10,-1,5,400,0,...`. */
|
|
109
|
+
export function parseQtFontString(value) {
|
|
110
|
+
if (typeof value !== 'string')
|
|
111
|
+
return null;
|
|
112
|
+
const parts = value.split(',');
|
|
113
|
+
const family = (parts[0] || '').trim();
|
|
114
|
+
if (!family)
|
|
115
|
+
return null;
|
|
116
|
+
const points = Number(parts[1]);
|
|
117
|
+
const pixels = Number(parts[2]);
|
|
118
|
+
const sizePx = points > 0 ? pointsToPx(points) : pixels > 0 ? pixels : undefined;
|
|
119
|
+
return { family, sizePx };
|
|
120
|
+
}
|
|
121
|
+
/** A fontconfig pattern as foot writes it: `JetBrains Mono:size=10:weight=bold`. */
|
|
122
|
+
export function parseFontconfigPattern(value) {
|
|
123
|
+
if (typeof value !== 'string')
|
|
124
|
+
return null;
|
|
125
|
+
const first = value.split(/(?<!\\),/)[0];
|
|
126
|
+
const [rawFamily, ...properties] = first.split(/(?<!\\):/);
|
|
127
|
+
const family = rawFamily.replace(/\\(.)/g, '$1').trim();
|
|
128
|
+
if (!family)
|
|
129
|
+
return null;
|
|
130
|
+
let sizePx;
|
|
131
|
+
for (const property of properties) {
|
|
132
|
+
const [key, raw] = property.split('=');
|
|
133
|
+
if (key === 'size')
|
|
134
|
+
sizePx = pointsToPx(raw);
|
|
135
|
+
else if (key === 'pixelsize')
|
|
136
|
+
sizePx = positiveNumber(raw);
|
|
137
|
+
}
|
|
138
|
+
return { family, sizePx };
|
|
139
|
+
}
|
|
140
|
+
/** The first entry of a CSS font-family list: `'Fira Code', monospace` → `Fira Code`. */
|
|
141
|
+
export function firstCssFamily(value) {
|
|
142
|
+
if (typeof value !== 'string')
|
|
143
|
+
return null;
|
|
144
|
+
const first = value
|
|
145
|
+
.split(',')[0]
|
|
146
|
+
.trim()
|
|
147
|
+
.replace(/^(['"])(.*)\1$/, '$2')
|
|
148
|
+
.trim();
|
|
149
|
+
return first || null;
|
|
150
|
+
}
|
|
151
|
+
/** JSON with comments and trailing commas, as VS Code writes its settings. */
|
|
152
|
+
export function parseJsonc(text) {
|
|
153
|
+
if (typeof text !== 'string')
|
|
154
|
+
return null;
|
|
155
|
+
let out = '';
|
|
156
|
+
let inString = false;
|
|
157
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
158
|
+
const char = text[i];
|
|
159
|
+
if (inString) {
|
|
160
|
+
out += char;
|
|
161
|
+
if (char === '\\') {
|
|
162
|
+
out += text[i + 1] || '';
|
|
163
|
+
i += 1;
|
|
164
|
+
}
|
|
165
|
+
else if (char === '"')
|
|
166
|
+
inString = false;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (char === '"') {
|
|
170
|
+
inString = true;
|
|
171
|
+
out += char;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (char === '/' && text[i + 1] === '/') {
|
|
175
|
+
while (i < text.length && text[i] !== '\n')
|
|
176
|
+
i += 1;
|
|
177
|
+
out += '\n';
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (char === '/' && text[i + 1] === '*') {
|
|
181
|
+
i += 2;
|
|
182
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
|
|
183
|
+
i += 1;
|
|
184
|
+
i += 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
out += char;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Just enough TOML for Alacritty's font keys: tables, dotted keys, strings,
|
|
198
|
+
* numbers, inline tables and (possibly multi-line) string arrays. Returns a
|
|
199
|
+
* flat `dotted.key → value` map.
|
|
200
|
+
*/
|
|
201
|
+
export function parseTomlSubset(text) {
|
|
202
|
+
const values = {};
|
|
203
|
+
const lines = String(text || '').split(/\r?\n/);
|
|
204
|
+
let table = '';
|
|
205
|
+
const unquote = (raw) => {
|
|
206
|
+
const value = raw.trim();
|
|
207
|
+
const basic = /^"((?:[^"\\]|\\.)*)"/.exec(value);
|
|
208
|
+
if (basic)
|
|
209
|
+
return basic[1].replace(/\\(.)/g, '$1');
|
|
210
|
+
const literal = /^'([^']*)'/.exec(value);
|
|
211
|
+
if (literal)
|
|
212
|
+
return literal[1];
|
|
213
|
+
const number = Number(value.replace(/#.*$/, '').trim());
|
|
214
|
+
return Number.isFinite(number) ? number : undefined;
|
|
215
|
+
};
|
|
216
|
+
const keyPath = (raw) => raw.split('.').map((part) => part.trim().replace(/^(['"])(.*)\1$/, '$2'));
|
|
217
|
+
const assign = (prefix, rawKey, rawValue) => {
|
|
218
|
+
const key = [...(prefix ? [prefix] : []), ...keyPath(rawKey)].join('.');
|
|
219
|
+
const value = rawValue.trim();
|
|
220
|
+
if (value.startsWith('{')) {
|
|
221
|
+
const body = value.slice(1, value.lastIndexOf('}'));
|
|
222
|
+
for (const pair of body.split(/,(?=(?:[^"']|"[^"]*"|'[^']*')*$)/)) {
|
|
223
|
+
const equals = pair.indexOf('=');
|
|
224
|
+
if (equals !== -1)
|
|
225
|
+
assign(key, pair.slice(0, equals), pair.slice(equals + 1));
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (value.startsWith('[')) {
|
|
230
|
+
values[key] = [...value.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const parsed = unquote(value);
|
|
234
|
+
if (parsed !== undefined)
|
|
235
|
+
values[key] = parsed;
|
|
236
|
+
};
|
|
237
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
238
|
+
const line = lines[index].trim();
|
|
239
|
+
if (!line || line.startsWith('#'))
|
|
240
|
+
continue;
|
|
241
|
+
const header = /^\[([^[\]]+)\]/.exec(line);
|
|
242
|
+
if (header) {
|
|
243
|
+
table = keyPath(header[1]).join('.');
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (line.startsWith('[[')) {
|
|
247
|
+
table = '\0';
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (table === '\0')
|
|
251
|
+
continue;
|
|
252
|
+
const equals = line.indexOf('=');
|
|
253
|
+
if (equals === -1)
|
|
254
|
+
continue;
|
|
255
|
+
let rawValue = line.slice(equals + 1);
|
|
256
|
+
// An array may span lines; gather it before assigning.
|
|
257
|
+
if (rawValue.trim().startsWith('[') && !rawValue.includes(']')) {
|
|
258
|
+
while (index + 1 < lines.length && !rawValue.includes(']')) {
|
|
259
|
+
index += 1;
|
|
260
|
+
rawValue += ` ${lines[index].trim()}`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
assign(table, line.slice(0, equals), rawValue);
|
|
264
|
+
}
|
|
265
|
+
return values;
|
|
266
|
+
}
|
|
267
|
+
/** The last value given to any of `names` on a command line. */
|
|
268
|
+
export function optionValue(args, names) {
|
|
269
|
+
let value = null;
|
|
270
|
+
for (let i = 0; i < args.length - 1; i += 1) {
|
|
271
|
+
if (names.includes(args[i]))
|
|
272
|
+
value = args[i + 1];
|
|
273
|
+
}
|
|
274
|
+
return value;
|
|
275
|
+
}
|
|
276
|
+
/** `-xrm 'XTerm*faceName: Hack'` entries, in the shape `xResources` returns. */
|
|
277
|
+
export function xrmResources(args) {
|
|
278
|
+
const entries = [];
|
|
279
|
+
for (let i = 0; i < args.length - 1; i += 1) {
|
|
280
|
+
if (args[i] !== '-xrm')
|
|
281
|
+
continue;
|
|
282
|
+
const colon = args[i + 1].indexOf(':');
|
|
283
|
+
if (colon !== -1)
|
|
284
|
+
entries.push([args[i + 1].slice(0, colon).trim(), args[i + 1].slice(colon + 1).trim()]);
|
|
285
|
+
}
|
|
286
|
+
return entries;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The value X would give `<class>.<...>.<resource>`, simplified to what
|
|
290
|
+
* terminal users write: `XTerm*faceName`, `xterm.vt100.faceName`, `*faceName`.
|
|
291
|
+
* A named class wins over the bare wildcard.
|
|
292
|
+
*/
|
|
293
|
+
export function xResource(resources, classes, resource) {
|
|
294
|
+
let named = null;
|
|
295
|
+
let wildcard = null;
|
|
296
|
+
// Later entries win: `-xrm` options are appended after the database.
|
|
297
|
+
for (const [name, value] of resources) {
|
|
298
|
+
const parts = name.split(/[.*]/).filter(Boolean);
|
|
299
|
+
if (parts[parts.length - 1] !== resource || !value)
|
|
300
|
+
continue;
|
|
301
|
+
if (parts.length === 1)
|
|
302
|
+
wildcard = value;
|
|
303
|
+
else if (classes.includes(parts[0]))
|
|
304
|
+
named = value;
|
|
305
|
+
}
|
|
306
|
+
return named ?? wildcard;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* An X terminal's font name: `xft:JetBrains Mono:size=11` (FreeType, any list
|
|
310
|
+
* takes its first entry) or an XLFD `-misc-dejavu sans mono-medium-r-normal--14-…`.
|
|
311
|
+
* A bare core-font alias (`fixed`, `9x15`) names a bitmap font: no answer —
|
|
312
|
+
* except in xterm's `faceName`, which is always a FreeType pattern.
|
|
313
|
+
*/
|
|
314
|
+
export function parseXFontName(value, { bareIsPattern = false } = {}) {
|
|
315
|
+
const first = String(value || '')
|
|
316
|
+
.split(/,(?=\s*(?:xft:|-))/)[0]
|
|
317
|
+
.trim();
|
|
318
|
+
if (/^xft:/i.test(first))
|
|
319
|
+
return parseFontconfigPattern(first.slice(4));
|
|
320
|
+
if (first.startsWith('-')) {
|
|
321
|
+
const fields = first.split('-');
|
|
322
|
+
const family = (fields[2] || '').trim();
|
|
323
|
+
if (!family || family === '*' || /^(fixed|misc)$/i.test(family))
|
|
324
|
+
return null;
|
|
325
|
+
const pixels = Number(fields[7]);
|
|
326
|
+
const decipoints = Number(fields[8]);
|
|
327
|
+
return {
|
|
328
|
+
family,
|
|
329
|
+
sizePx: pixels > 0 ? pixels : decipoints > 0 ? pointsToPx(decipoints / 10) : undefined,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (!first)
|
|
333
|
+
return null;
|
|
334
|
+
return bareIsPattern || first.includes(':') ? parseFontconfigPattern(first) : null;
|
|
335
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the font the workstation's terminal draws with.
|
|
3
|
+
*
|
|
4
|
+
* Colors can be asked for with OSC queries (see terminal-palette.js); a font
|
|
5
|
+
* cannot. No escape sequence that mainstream emulators answer reports the
|
|
6
|
+
* family, so the host works out which terminal it is running in and reads that
|
|
7
|
+
* terminal's own settings — the same settings dialog the user edited.
|
|
8
|
+
*
|
|
9
|
+
* The answer travels to the browser in `host_hello` as a family name, a size,
|
|
10
|
+
* and the font files the browser may fetch by hash, so a phone draws the
|
|
11
|
+
* session in the face the workstation shows even though it has no such font
|
|
12
|
+
* installed. Nothing here guesses: a terminal this module does not know yields
|
|
13
|
+
* `null`, and the browser keeps its own monospace stack.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { sanitizeTerminalFont, TERMINAL_FONT_CHUNK_BYTES, } from 'herdr-remote-relay/protocol';
|
|
17
|
+
import { readRuntime, writeRuntime } from '../runtime.js';
|
|
18
|
+
import { withDefaults } from './deps.js';
|
|
19
|
+
import { concreteFamily, resolveFontFaces, resolveSubsetSources, } from './files.js';
|
|
20
|
+
import { READERS } from './readers.js';
|
|
21
|
+
import { identifyTerminal, processArguments, processLineage, terminalProcess, } from './terminals.js';
|
|
22
|
+
/** Reads one terminal's configured font: `{ source, family, sizePx }` or null. */
|
|
23
|
+
export function readTerminalFont(source, deps = {}) {
|
|
24
|
+
const resolved = withDefaults(deps);
|
|
25
|
+
const reader = READERS[source];
|
|
26
|
+
if (!reader)
|
|
27
|
+
return null;
|
|
28
|
+
let font;
|
|
29
|
+
try {
|
|
30
|
+
font = reader(resolved);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (!font?.family)
|
|
36
|
+
return null;
|
|
37
|
+
const family = concreteFamily(font.family, resolved);
|
|
38
|
+
if (!family)
|
|
39
|
+
return null;
|
|
40
|
+
return sanitizeDetected({ source, family, sizePx: font.sizePx });
|
|
41
|
+
}
|
|
42
|
+
/** Identifies the terminal and reads its font. */
|
|
43
|
+
export function detectTerminalFont({ env = process.env, ancestry, lineage, deps = {}, } = {}) {
|
|
44
|
+
const processes = lineage ||
|
|
45
|
+
(ancestry ? ancestry.map((name) => ({ pid: 0, name })) : processLineage({ run: deps.run }));
|
|
46
|
+
const source = identifyTerminal({ env, ancestry: processes.map((entry) => entry.name) });
|
|
47
|
+
if (!source)
|
|
48
|
+
return null;
|
|
49
|
+
// A terminal configured on its command line (xterm's `-fa`) says so there.
|
|
50
|
+
const emulator = terminalProcess(source, processes);
|
|
51
|
+
const terminalArgs = deps.terminalArgs || (emulator?.pid ? processArguments(emulator.pid) : []);
|
|
52
|
+
return readTerminalFont(source, { ...deps, env, terminalArgs });
|
|
53
|
+
}
|
|
54
|
+
/** What detection reports, before any files are attached. */
|
|
55
|
+
function sanitizeDetected(value) {
|
|
56
|
+
const clean = sanitizeTerminalFont(value);
|
|
57
|
+
if (!clean)
|
|
58
|
+
return null;
|
|
59
|
+
const detected = { source: clean.source || null, family: clean.family };
|
|
60
|
+
if (clean.sizePx)
|
|
61
|
+
detected.sizePx = clean.sizePx;
|
|
62
|
+
return detected;
|
|
63
|
+
}
|
|
64
|
+
/** The full record a connector serves from: detection plus local files. */
|
|
65
|
+
function describeTerminalFont(detected, deps = {}) {
|
|
66
|
+
if (!detected?.family)
|
|
67
|
+
return null;
|
|
68
|
+
const faces = resolveFontFaces(detected.family, deps);
|
|
69
|
+
return { ...detected, faces, subsets: resolveSubsetSources(detected.family, faces, deps) };
|
|
70
|
+
}
|
|
71
|
+
/** The same record with the file paths removed: what may leave this machine. */
|
|
72
|
+
export function publicTerminalFont(record) {
|
|
73
|
+
return record ? sanitizeTerminalFont(record) : null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* One slice of a face, by hash. The file is re-checked against what was
|
|
77
|
+
* announced, so an edited font is refused rather than spliced into a browser's
|
|
78
|
+
* copy of the old one.
|
|
79
|
+
*/
|
|
80
|
+
export function readFontChunk(record, sha256, index) {
|
|
81
|
+
const face = record?.faces?.find((candidate) => candidate.sha256 === sha256);
|
|
82
|
+
if (!face)
|
|
83
|
+
return null;
|
|
84
|
+
const total = Math.ceil(face.bytes / TERMINAL_FONT_CHUNK_BYTES);
|
|
85
|
+
if (!Number.isInteger(index) || index < 0 || index >= total)
|
|
86
|
+
return null;
|
|
87
|
+
let fd;
|
|
88
|
+
try {
|
|
89
|
+
const stat = fs.statSync(face.path);
|
|
90
|
+
if (stat.size !== face.bytes || stat.mtimeMs !== face.mtimeMs)
|
|
91
|
+
return null;
|
|
92
|
+
fd = fs.openSync(face.path, 'r');
|
|
93
|
+
const start = index * TERMINAL_FONT_CHUNK_BYTES;
|
|
94
|
+
const length = Math.min(TERMINAL_FONT_CHUNK_BYTES, face.bytes - start);
|
|
95
|
+
const buffer = Buffer.alloc(length);
|
|
96
|
+
const read = fs.readSync(fd, buffer, 0, length, start);
|
|
97
|
+
if (read !== length)
|
|
98
|
+
return null;
|
|
99
|
+
return { data: buffer, total };
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
if (fd !== undefined)
|
|
106
|
+
try {
|
|
107
|
+
fs.closeSync(fd);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Closing a read-only descriptor cannot lose data.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Reads a font a parent process already detected. */
|
|
115
|
+
export function fontFromEnvironment(env = process.env) {
|
|
116
|
+
const raw = env.HERDR_TERM_FONT_JSON;
|
|
117
|
+
if (!raw)
|
|
118
|
+
return null;
|
|
119
|
+
try {
|
|
120
|
+
return sanitizeDetected(JSON.parse(raw));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Remembers the font for a start that has no terminal around it: a service
|
|
128
|
+
* manager's, or Herdr's detached server's.
|
|
129
|
+
*/
|
|
130
|
+
function rememberTerminalFont(detected) {
|
|
131
|
+
const clean = sanitizeDetected(detected);
|
|
132
|
+
if (!clean)
|
|
133
|
+
return null;
|
|
134
|
+
try {
|
|
135
|
+
const state = readRuntime();
|
|
136
|
+
const previous = state.terminalFont;
|
|
137
|
+
if (!previous || JSON.stringify(previous) !== JSON.stringify(clean)) {
|
|
138
|
+
writeRuntime({ ...state, terminalFont: clean });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// A font is a nicety; failing to remember it must not break a start.
|
|
143
|
+
}
|
|
144
|
+
return clean;
|
|
145
|
+
}
|
|
146
|
+
function rememberedTerminalFont() {
|
|
147
|
+
try {
|
|
148
|
+
return sanitizeDetected(readRuntime().terminalFont);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Detect the terminal's font at the entry point, where the terminal's
|
|
156
|
+
* variables and process ancestry are still this process's own, and hand the
|
|
157
|
+
* answer to everything this command starts.
|
|
158
|
+
*/
|
|
159
|
+
export function captureTerminalFont({ env = process.env, detect = detectTerminalFont, } = {}) {
|
|
160
|
+
if (env.HERDR_TERM_FONT_JSON)
|
|
161
|
+
return fontFromEnvironment(env);
|
|
162
|
+
const detected = detect({ env });
|
|
163
|
+
if (!detected)
|
|
164
|
+
return null;
|
|
165
|
+
env.HERDR_TERM_FONT_JSON = JSON.stringify(detected);
|
|
166
|
+
rememberTerminalFont(detected);
|
|
167
|
+
return detected;
|
|
168
|
+
}
|
|
169
|
+
/** The font a service start hands its children: inherited, else remembered. */
|
|
170
|
+
export function resolveHostFont({ env = process.env } = {}) {
|
|
171
|
+
return fontFromEnvironment(env) || rememberedTerminalFont();
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* What the connector serves: the terminal's settings read again now, when the
|
|
175
|
+
* terminal is known, so a font changed since the last start is not reported
|
|
176
|
+
* stale; otherwise what was handed down. `refresh` also re-identifies a
|
|
177
|
+
* terminal nobody had named, for a browser that asks to sync.
|
|
178
|
+
*/
|
|
179
|
+
export function loadHostTerminalFont({ env = process.env, refresh = false, deps = {}, } = {}) {
|
|
180
|
+
const known = resolveHostFont({ env });
|
|
181
|
+
let current = known?.source ? readTerminalFont(known.source, { ...deps, env }) : null;
|
|
182
|
+
if (!current && refresh && !known)
|
|
183
|
+
current = detectTerminalFont({ env, deps });
|
|
184
|
+
if (current && JSON.stringify(current) !== JSON.stringify(known))
|
|
185
|
+
rememberTerminalFont(current);
|
|
186
|
+
return describeTerminalFont(current || known, deps);
|
|
187
|
+
}
|