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.
Files changed (95) hide show
  1. package/bin/herdr-remote.js +3 -225
  2. package/dist/agent-status.js +130 -0
  3. package/dist/binary-plist.js +157 -0
  4. package/dist/cli.js +255 -0
  5. package/dist/config.js +270 -0
  6. package/dist/connector/agent-watch.js +110 -0
  7. package/dist/connector/font-server.js +165 -0
  8. package/dist/connector/host-connector.js +430 -0
  9. package/dist/connector/lock.js +66 -0
  10. package/dist/connector/main.js +18 -0
  11. package/dist/connector/sessions.js +366 -0
  12. package/dist/connector/update-report.js +67 -0
  13. package/dist/connector/wire.js +42 -0
  14. package/{src → dist}/exit-codes.js +1 -4
  15. package/dist/font-subset.js +110 -0
  16. package/dist/herdr-api.js +246 -0
  17. package/dist/herdr-command.js +223 -0
  18. package/dist/herdr-plugin.js +116 -0
  19. package/dist/herdr-server.js +189 -0
  20. package/dist/i18n/en.js +223 -0
  21. package/dist/i18n/index.js +48 -0
  22. package/dist/i18n/zh.js +222 -0
  23. package/dist/keepalive/detached.js +78 -0
  24. package/dist/keepalive/environment.js +44 -0
  25. package/dist/keepalive/index.js +110 -0
  26. package/dist/keepalive/launchd.js +117 -0
  27. package/dist/keepalive/systemd.js +108 -0
  28. package/dist/keepalive/types.js +1 -0
  29. package/dist/lib/process.js +12 -0
  30. package/dist/lifecycle.js +59 -0
  31. package/dist/net-interfaces.js +73 -0
  32. package/dist/pasted-files.js +115 -0
  33. package/dist/paths.js +62 -0
  34. package/dist/pty-session.js +96 -0
  35. package/dist/relay-client.js +106 -0
  36. package/dist/relay-urls.js +99 -0
  37. package/dist/runtime.js +106 -0
  38. package/dist/service.js +260 -0
  39. package/dist/settings-model.js +298 -0
  40. package/dist/socket-discovery.js +48 -0
  41. package/dist/supervisor.js +319 -0
  42. package/dist/terminal-font/deps.js +73 -0
  43. package/dist/terminal-font/files.js +354 -0
  44. package/dist/terminal-font/formats.js +335 -0
  45. package/dist/terminal-font/index.js +187 -0
  46. package/dist/terminal-font/readers.js +316 -0
  47. package/dist/terminal-font/terminals.js +128 -0
  48. package/dist/terminal-palette.js +339 -0
  49. package/dist/tui/App.js +195 -0
  50. package/dist/tui/api.js +79 -0
  51. package/dist/tui/components/TextField.js +50 -0
  52. package/dist/tui/components/common.js +80 -0
  53. package/dist/tui/index.js +36 -0
  54. package/dist/tui/mouse/index.js +164 -0
  55. package/dist/tui/mouse/protocol.js +96 -0
  56. package/dist/tui/mouse/source.js +74 -0
  57. package/dist/tui/screens/About.js +166 -0
  58. package/dist/tui/screens/Herdr.js +74 -0
  59. package/dist/tui/screens/Keepalive.js +84 -0
  60. package/dist/tui/screens/Overview.js +36 -0
  61. package/dist/tui/screens/Pair.js +74 -0
  62. package/dist/tui/screens/Relay.js +242 -0
  63. package/dist/tui/screens/Services.js +63 -0
  64. package/dist/tui/screens/Wizard.js +235 -0
  65. package/dist/tui/terminal.js +39 -0
  66. package/dist/tui/theme.js +33 -0
  67. package/dist/updater.js +382 -0
  68. package/herdr-plugin.toml +1 -1
  69. package/package.json +8 -8
  70. package/dist/tui.mjs +0 -5775
  71. package/src/agent-status.js +0 -139
  72. package/src/binary-plist.js +0 -146
  73. package/src/config.js +0 -454
  74. package/src/font-subset.js +0 -110
  75. package/src/herdr-api.js +0 -244
  76. package/src/herdr-command.js +0 -231
  77. package/src/herdr-plugin.js +0 -130
  78. package/src/herdr-server.js +0 -201
  79. package/src/host-connector.js +0 -1069
  80. package/src/i18n/en.js +0 -240
  81. package/src/i18n/index.js +0 -56
  82. package/src/i18n/zh.js +0 -239
  83. package/src/keepalive.js +0 -460
  84. package/src/lifecycle.js +0 -62
  85. package/src/net-interfaces.js +0 -76
  86. package/src/pasted-files.js +0 -176
  87. package/src/pty-session.js +0 -92
  88. package/src/service.js +0 -530
  89. package/src/settings-model.js +0 -323
  90. package/src/socket-discovery.js +0 -60
  91. package/src/state.js +0 -47
  92. package/src/supervisor.js +0 -250
  93. package/src/terminal-font.js +0 -1204
  94. package/src/terminal-palette.js +0 -325
  95. package/src/updater.js +0 -394
@@ -1,1204 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Reads the font the workstation's terminal draws with.
5
- *
6
- * Colors can be asked for with OSC queries (see terminal-palette.js); a font
7
- * cannot. No escape sequence that mainstream emulators answer reports the
8
- * family, so the host works out which terminal it is running in and reads that
9
- * terminal's own settings — the same settings dialog the user edited.
10
- *
11
- * The answer travels to the browser in `host_hello` as a family name, a size,
12
- * and the font files the browser may fetch by hash, so a phone draws the
13
- * session in the face the workstation shows even though it has no such font
14
- * installed. Nothing here guesses: a terminal this module does not know yields
15
- * `null`, and the browser keeps its own monospace stack.
16
- */
17
-
18
- const crypto = require('node:crypto');
19
- const fs = require('node:fs');
20
- const os = require('node:os');
21
- const path = require('node:path');
22
- const { spawnSync } = require('node:child_process');
23
- const {
24
- MAX_TERMINAL_FONT_BYTES,
25
- TERMINAL_FONT_CHUNK_BYTES,
26
- TERMINAL_FONT_STYLES,
27
- sanitizeTerminalFont,
28
- } = require('herdr-remote-relay/protocol');
29
- const { runtimeStatePath, stateDir } = require('./config');
30
- const { ensureDir, readJson, writeJsonAtomic } = require('./state');
31
- const { parseBinaryPlist, unarchiveKeyed } = require('./binary-plist');
32
-
33
- /** Terminals size fonts in points; CSS pixels are 1/96 inch. */
34
- const PX_PER_PT = 96 / 72;
35
- const COMMAND_TIMEOUT_MS = 3000;
36
-
37
- /** Names a terminal accepts in place of a real family. */
38
- const GENERIC_FAMILIES = new Set(['monospace', 'mono', 'sans', 'sans-serif', 'serif', 'system-ui', 'ui-monospace']);
39
-
40
- // ── Small readers ────────────────────────────────────────────────────────
41
-
42
- function defaultRun(command, args) {
43
- try {
44
- const result = spawnSync(command, args, { encoding: 'utf8', timeout: COMMAND_TIMEOUT_MS });
45
- return { status: result.status, stdout: result.stdout || '' };
46
- } catch {
47
- return { status: null, stdout: '' };
48
- }
49
- }
50
-
51
- function defaultReadFile(filePath) {
52
- try {
53
- return fs.readFileSync(filePath, 'utf8');
54
- } catch {
55
- return null;
56
- }
57
- }
58
-
59
- function defaultReadBuffer(filePath) {
60
- try {
61
- return fs.readFileSync(filePath);
62
- } catch {
63
- return null;
64
- }
65
- }
66
-
67
- function withDefaults(deps = {}) {
68
- const env = deps.env || process.env;
69
- return {
70
- env,
71
- platform: deps.platform || process.platform,
72
- home: deps.home || env.HOME || os.homedir(),
73
- run: deps.run || defaultRun,
74
- readFile: deps.readFile || defaultReadFile,
75
- readBuffer: deps.readBuffer || defaultReadBuffer,
76
- terminalArgs: deps.terminalArgs || [],
77
- };
78
- }
79
-
80
- function configHome(deps) {
81
- return deps.env.XDG_CONFIG_HOME || path.join(deps.home, '.config');
82
- }
83
-
84
- function dataHome(deps) {
85
- return deps.env.XDG_DATA_HOME || path.join(deps.home, '.local', 'share');
86
- }
87
-
88
- function expandHome(filePath, deps) {
89
- return filePath.startsWith('~/') ? path.join(deps.home, filePath.slice(2)) : filePath;
90
- }
91
-
92
- function pointsToPx(points) {
93
- const value = Number(points);
94
- return Number.isFinite(value) && value > 0 ? Math.round(value * PX_PER_PT * 10) / 10 : undefined;
95
- }
96
-
97
- function positiveNumber(value) {
98
- const number = Number(value);
99
- return Number.isFinite(number) && number > 0 ? number : undefined;
100
- }
101
-
102
- /** `[section]` / `key=value` files: konsolerc, kdeglobals, terminalrc, foot.ini. */
103
- function parseIni(text) {
104
- const sections = { '': {} };
105
- let current = sections[''];
106
- for (const rawLine of String(text || '').split(/\r?\n/)) {
107
- const line = rawLine.trim();
108
- if (!line || line.startsWith('#') || line.startsWith(';')) continue;
109
- const header = /^\[(.+)\]$/.exec(line);
110
- if (header) {
111
- current = sections[header[1].trim()] ||= {};
112
- continue;
113
- }
114
- const equals = line.indexOf('=');
115
- if (equals === -1) continue;
116
- current[line.slice(0, equals).trim()] = line.slice(equals + 1).trim();
117
- }
118
- return sections;
119
- }
120
-
121
- /** A GSettings value as printed by `gsettings get`: `'text'`, `true`, `@as []`. */
122
- function parseGVariantString(output) {
123
- const text = String(output || '').trim();
124
- const quoted = /^'((?:[^'\\]|\\.)*)'$/.exec(text) || /^"((?:[^"\\]|\\.)*)"$/.exec(text);
125
- if (!quoted) return null;
126
- return quoted[1].replace(/\\(.)/g, '$1');
127
- }
128
-
129
- function gsettingsGet(deps, schema, key) {
130
- const result = deps.run('gsettings', ['get', schema, key]);
131
- if (result.status !== 0) return null;
132
- return result.stdout.trim();
133
- }
134
-
135
- // ── Font description formats ────────────────────────────────────────────
136
-
137
- const PANGO_STYLE_WORDS = new Set([
138
- 'normal', 'roman', 'oblique', 'italic', 'small-caps', 'all-small-caps', 'petite-caps',
139
- 'all-petite-caps', 'unicase', 'title-caps', 'ultra-condensed', 'extra-condensed', 'condensed',
140
- 'semi-condensed', 'semi-expanded', 'expanded', 'extra-expanded', 'ultra-expanded', 'thin',
141
- 'ultra-light', 'extra-light', 'light', 'semi-light', 'demi-light', 'book', 'regular', 'medium',
142
- 'semi-bold', 'demi-bold', 'bold', 'ultra-bold', 'extra-bold', 'heavy', 'black', 'ultra-black',
143
- 'extra-black', 'ultra-heavy', 'extra-heavy', 'not-rotated', 'south', 'upside-down', 'north',
144
- 'rotated-left', 'east', 'rotated-right', 'west',
145
- ]);
146
-
147
- /**
148
- * `JetBrainsMono Nerd Font Bold 9` → family and size.
149
- *
150
- * Pango writes `FAMILY-LIST [STYLE-OPTIONS] [SIZE[px]] [@VARIATIONS]`: the
151
- * size and the style words are peeled off the end, and what is left is the
152
- * family list, whose first entry is the face the terminal asked for.
153
- */
154
- function parsePangoFontDescription(description) {
155
- if (typeof description !== 'string') return null;
156
- const words = description.trim().split(/\s+/).filter(Boolean);
157
- while (words.length && words[words.length - 1].startsWith('@')) words.pop();
158
-
159
- let sizePx;
160
- const size = /^(\d+(?:\.\d+)?)(px)?$/i.exec(words[words.length - 1] || '');
161
- if (size && words.length > 1) {
162
- words.pop();
163
- sizePx = size[2] ? positiveNumber(size[1]) : pointsToPx(size[1]);
164
- }
165
- while (words.length > 1) {
166
- const last = words[words.length - 1].toLowerCase();
167
- if (!PANGO_STYLE_WORDS.has(last) && !/^weight=\d+$/.test(last)) break;
168
- words.pop();
169
- }
170
- const family = words.join(' ').split(',')[0].trim();
171
- return family ? { family, sizePx } : null;
172
- }
173
-
174
- /** Qt's `QFont::toString()`: `JetBrains Mono,10,-1,5,400,0,...`. */
175
- function parseQtFontString(value) {
176
- if (typeof value !== 'string') return null;
177
- const parts = value.split(',');
178
- const family = (parts[0] || '').trim();
179
- if (!family) return null;
180
- const points = Number(parts[1]);
181
- const pixels = Number(parts[2]);
182
- const sizePx = points > 0 ? pointsToPx(points) : pixels > 0 ? pixels : undefined;
183
- return { family, sizePx };
184
- }
185
-
186
- /** A fontconfig pattern as foot writes it: `JetBrains Mono:size=10:weight=bold`. */
187
- function parseFontconfigPattern(value) {
188
- if (typeof value !== 'string') return null;
189
- const first = value.split(/(?<!\\),/)[0];
190
- const [rawFamily, ...properties] = first.split(/(?<!\\):/);
191
- const family = rawFamily.replace(/\\(.)/g, '$1').trim();
192
- if (!family) return null;
193
- let sizePx;
194
- for (const property of properties) {
195
- const [key, raw] = property.split('=');
196
- if (key === 'size') sizePx = pointsToPx(raw);
197
- else if (key === 'pixelsize') sizePx = positiveNumber(raw);
198
- }
199
- return { family, sizePx };
200
- }
201
-
202
- /** The first entry of a CSS font-family list: `'Fira Code', monospace` → `Fira Code`. */
203
- function firstCssFamily(value) {
204
- if (typeof value !== 'string') return null;
205
- const first = value.split(',')[0].trim().replace(/^(['"])(.*)\1$/, '$2').trim();
206
- return first || null;
207
- }
208
-
209
- /** JSON with comments and trailing commas, as VS Code writes its settings. */
210
- function parseJsonc(text) {
211
- if (typeof text !== 'string') return null;
212
- let out = '';
213
- let inString = false;
214
- for (let i = 0; i < text.length; i += 1) {
215
- const char = text[i];
216
- if (inString) {
217
- out += char;
218
- if (char === '\\') { out += text[i + 1] || ''; i += 1; } else if (char === '"') inString = false;
219
- continue;
220
- }
221
- if (char === '"') { inString = true; out += char; continue; }
222
- if (char === '/' && text[i + 1] === '/') {
223
- while (i < text.length && text[i] !== '\n') i += 1;
224
- out += '\n';
225
- continue;
226
- }
227
- if (char === '/' && text[i + 1] === '*') {
228
- i += 2;
229
- while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
230
- i += 1;
231
- continue;
232
- }
233
- out += char;
234
- }
235
- try {
236
- return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
237
- } catch {
238
- return null;
239
- }
240
- }
241
-
242
- /**
243
- * Just enough TOML for Alacritty's font keys: tables, dotted keys, strings,
244
- * numbers, inline tables and (possibly multi-line) string arrays. Returns a
245
- * flat `dotted.key → value` map.
246
- */
247
- function parseTomlSubset(text) {
248
- const values = {};
249
- const lines = String(text || '').split(/\r?\n/);
250
- let table = '';
251
- const unquote = (raw) => {
252
- const value = raw.trim();
253
- const basic = /^"((?:[^"\\]|\\.)*)"/.exec(value);
254
- if (basic) return basic[1].replace(/\\(.)/g, '$1');
255
- const literal = /^'([^']*)'/.exec(value);
256
- if (literal) return literal[1];
257
- const number = Number(value.replace(/#.*$/, '').trim());
258
- return Number.isFinite(number) ? number : undefined;
259
- };
260
- const keyPath = (raw) => raw.split('.').map((part) => part.trim().replace(/^(['"])(.*)\1$/, '$2'));
261
- const assign = (prefix, rawKey, rawValue) => {
262
- const key = [...(prefix ? [prefix] : []), ...keyPath(rawKey)].join('.');
263
- const value = rawValue.trim();
264
- if (value.startsWith('{')) {
265
- const body = value.slice(1, value.lastIndexOf('}'));
266
- for (const pair of body.split(/,(?=(?:[^"']|"[^"]*"|'[^']*')*$)/)) {
267
- const equals = pair.indexOf('=');
268
- if (equals !== -1) assign(key, pair.slice(0, equals), pair.slice(equals + 1));
269
- }
270
- return;
271
- }
272
- if (value.startsWith('[')) {
273
- values[key] = [...value.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
274
- return;
275
- }
276
- const parsed = unquote(value);
277
- if (parsed !== undefined) values[key] = parsed;
278
- };
279
-
280
- for (let index = 0; index < lines.length; index += 1) {
281
- const line = lines[index].trim();
282
- if (!line || line.startsWith('#')) continue;
283
- const header = /^\[([^[\]]+)\]/.exec(line);
284
- if (header) { table = keyPath(header[1]).join('.'); continue; }
285
- if (line.startsWith('[[')) { table = '\0'; continue; }
286
- if (table === '\0') continue;
287
- const equals = line.indexOf('=');
288
- if (equals === -1) continue;
289
- let rawValue = line.slice(equals + 1);
290
- // An array may span lines; gather it before assigning.
291
- if (rawValue.trim().startsWith('[') && !rawValue.includes(']')) {
292
- while (index + 1 < lines.length && !rawValue.includes(']')) {
293
- index += 1;
294
- rawValue += ` ${lines[index].trim()}`;
295
- }
296
- }
297
- assign(table, line.slice(0, equals), rawValue);
298
- }
299
- return values;
300
- }
301
-
302
- // ── Which terminal is this? ─────────────────────────────────────────────
303
-
304
- /** Process names of the terminals this module can read, innermost wins. */
305
- const TERMINAL_PROCESSES = [
306
- [/^gnome-terminal/, 'gnome-terminal'],
307
- [/^ptyxis/, 'ptyxis'],
308
- [/^tilix$/, 'tilix'],
309
- [/^konsole$/, 'konsole'],
310
- [/^xfce4-terminal$/, 'xfce4-terminal'],
311
- [/^kitty$/, 'kitty'],
312
- [/^alacritty$/i, 'alacritty'],
313
- [/^ghostty$/i, 'ghostty'],
314
- [/^wezterm-gui$/, 'wezterm'],
315
- [/^foot(client)?$/, 'foot'],
316
- [/^iTerm2$/, 'iterm2'],
317
- [/^Terminal$/, 'apple-terminal'],
318
- [/^xterm$/, 'xterm'],
319
- [/^(urxvt|urxvtd|rxvt)$/, 'urxvt'],
320
- ];
321
-
322
- /**
323
- * The emulator a process runs under, from its own ancestry first.
324
- *
325
- * Environment variables leak through nested terminals — a kitty launched from
326
- * GNOME Terminal still carries `GNOME_TERMINAL_SCREEN` — whereas the nearest
327
- * emulator process above this one is the one actually drawing it. The
328
- * environment is the fallback for the common case where there is no such
329
- * ancestor: Herdr's server runs detached, so everything it starts, this
330
- * connector included, has lost its terminal parent but kept its variables.
331
- */
332
- function identifyTerminal({ env = process.env, ancestry = [] } = {}) {
333
- for (const name of ancestry) {
334
- const base = path.basename(String(name || ''));
335
- for (const [pattern, id] of TERMINAL_PROCESSES) {
336
- if (pattern.test(base)) return id;
337
- }
338
- }
339
-
340
- const program = env.TERM_PROGRAM || '';
341
- if (program === 'vscode') return 'vscode';
342
- if (program === 'iTerm.app' || env.ITERM_SESSION_ID) return 'iterm2';
343
- if (program === 'Apple_Terminal') return 'apple-terminal';
344
- if (program === 'WezTerm' || env.WEZTERM_EXECUTABLE) return 'wezterm';
345
- if (program === 'ghostty' || env.GHOSTTY_RESOURCES_DIR) return 'ghostty';
346
- if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') return 'kitty';
347
- if (env.ALACRITTY_SOCKET || env.ALACRITTY_WINDOW_ID || env.ALACRITTY_LOG) return 'alacritty';
348
- if (/^foot/.test(env.TERM || '')) return 'foot';
349
- if (env.KONSOLE_VERSION || env.KONSOLE_DBUS_SESSION) return 'konsole';
350
- if (env.PTYXIS_VERSION) return 'ptyxis';
351
- if (env.TILIX_ID) return 'tilix';
352
- if (env.GNOME_TERMINAL_SCREEN || env.GNOME_TERMINAL_SERVICE) return 'gnome-terminal';
353
- if (/^rxvt-unicode/.test(env.TERM || '')) return 'urxvt';
354
- // Last: a terminal started from xterm inherits this too.
355
- if (env.XTERM_VERSION) return 'xterm';
356
- return null;
357
- }
358
-
359
- /** This process's ancestors, nearest first: `{ pid, name }`. */
360
- function processLineage({ platform = process.platform, pid = process.ppid, run = defaultRun } = {}) {
361
- const lineage = [];
362
- if (platform === 'linux') {
363
- let current = pid;
364
- for (let depth = 0; depth < 32 && current > 1; depth += 1) {
365
- let stat;
366
- try {
367
- stat = fs.readFileSync(`/proc/${current}/stat`, 'utf8');
368
- } catch {
369
- break;
370
- }
371
- // `pid (comm) state ppid ...`, where comm may itself contain spaces.
372
- const open = stat.indexOf('(');
373
- const close = stat.lastIndexOf(')');
374
- if (open === -1 || close === -1) break;
375
- lineage.push({ pid: current, name: stat.slice(open + 1, close) });
376
- current = Number(stat.slice(close + 2).split(' ')[1]);
377
- }
378
- return lineage;
379
- }
380
- if (platform === 'darwin') {
381
- const result = run('ps', ['-axo', 'pid=,ppid=,comm=']);
382
- if (result.status !== 0) return lineage;
383
- const table = new Map();
384
- for (const line of result.stdout.split('\n')) {
385
- const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
386
- if (match) table.set(Number(match[1]), { ppid: Number(match[2]), comm: match[3].trim() });
387
- }
388
- let current = pid;
389
- for (let depth = 0; depth < 32 && table.has(current) && current > 1; depth += 1) {
390
- const entry = table.get(current);
391
- lineage.push({ pid: current, name: entry.comm });
392
- current = entry.ppid;
393
- }
394
- }
395
- return lineage;
396
- }
397
-
398
- /** Names of this process's ancestors, nearest first. */
399
- function processAncestry(options = {}) {
400
- return processLineage(options).map((entry) => entry.name);
401
- }
402
-
403
- /** The command line of a running process, as its argument list. */
404
- function processArguments(pid, { platform = process.platform } = {}) {
405
- if (platform !== 'linux') return [];
406
- try {
407
- return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').filter(Boolean);
408
- } catch {
409
- return [];
410
- }
411
- }
412
-
413
- /** The ancestor process that is `source`'s emulator, if any. */
414
- function terminalProcess(source, lineage) {
415
- return lineage.find((entry) => TERMINAL_PROCESSES.some(([pattern, id]) => (
416
- id === source && pattern.test(path.basename(String(entry.name || '')))
417
- ))) || null;
418
- }
419
-
420
- // ── Per-terminal settings ───────────────────────────────────────────────
421
-
422
- /** A GNOME-style profile: its own font, or the desktop's monospace font. */
423
- function readGSettingsProfileFont(deps, { schema, path: profilePath, fontKey = 'font' }) {
424
- const useSystem = gsettingsGet(deps, `${schema}:${profilePath}`, 'use-system-font');
425
- if (useSystem === null) return null;
426
- if (useSystem === 'false') {
427
- const font = parsePangoFontDescription(parseGVariantString(gsettingsGet(deps, `${schema}:${profilePath}`, fontKey)));
428
- if (font) return font;
429
- }
430
- return systemMonospaceFont(deps);
431
- }
432
-
433
- function systemMonospaceFont(deps) {
434
- return parsePangoFontDescription(parseGVariantString(
435
- gsettingsGet(deps, 'org.gnome.desktop.interface', 'monospace-font-name'),
436
- ));
437
- }
438
-
439
- const READERS = {
440
- 'gnome-terminal': (deps) => {
441
- const id = parseGVariantString(gsettingsGet(deps, 'org.gnome.Terminal.ProfilesList', 'default'));
442
- if (!id) return null;
443
- return readGSettingsProfileFont(deps, {
444
- schema: 'org.gnome.Terminal.Legacy.Profile',
445
- path: `/org/gnome/terminal/legacy/profiles:/:${id}/`,
446
- });
447
- },
448
-
449
- tilix: (deps) => {
450
- const id = parseGVariantString(gsettingsGet(deps, 'com.gexperts.Tilix.ProfilesList', 'default'));
451
- if (!id) return null;
452
- return readGSettingsProfileFont(deps, {
453
- schema: 'com.gexperts.Tilix.Profile',
454
- path: `/com/gexperts/Tilix/profiles/${id}/`,
455
- });
456
- },
457
-
458
- ptyxis: (deps) => {
459
- const useSystem = gsettingsGet(deps, 'org.gnome.Ptyxis', 'use-system-font');
460
- if (useSystem === 'false') {
461
- const font = parsePangoFontDescription(parseGVariantString(gsettingsGet(deps, 'org.gnome.Ptyxis', 'font-name')));
462
- if (font) return font;
463
- }
464
- return useSystem === null ? null : systemMonospaceFont(deps);
465
- },
466
-
467
- konsole: (deps) => {
468
- const rc = parseIni(deps.readFile(path.join(configHome(deps), 'konsolerc')));
469
- const profileName = rc['Desktop Entry']?.DefaultProfile;
470
- if (profileName) {
471
- const profile = parseIni(deps.readFile(path.join(dataHome(deps), 'konsole', profileName)));
472
- const font = parseQtFontString(profile.Appearance?.Font);
473
- if (font) return font;
474
- }
475
- // Konsole's built-in profile draws with the desktop's fixed-width font.
476
- const globals = parseIni(deps.readFile(path.join(configHome(deps), 'kdeglobals')));
477
- return parseQtFontString(globals.General?.fixed) || { family: 'Hack', sizePx: pointsToPx(10) };
478
- },
479
-
480
- 'xfce4-terminal': (deps) => {
481
- // 1.1+ keeps its settings in xfconf; older releases in terminalrc.
482
- const query = (property) => {
483
- const result = deps.run('xfconf-query', ['-c', 'xfce4-terminal', '-p', property]);
484
- return result.status === 0 ? result.stdout.trim() : null;
485
- };
486
- const rc = parseIni(deps.readFile(path.join(configHome(deps), 'xfce4', 'terminal', 'terminalrc'))).Configuration || {};
487
- const useSystem = (query('/font-use-system') ?? rc.FontUseSystem ?? 'false').toLowerCase() === 'true';
488
- if (!useSystem) {
489
- const font = parsePangoFontDescription(query('/font-name') ?? rc.FontName ?? 'Monospace 12');
490
- if (font) return font;
491
- }
492
- const result = deps.run('xfconf-query', ['-c', 'xsettings', '-p', '/Gtk/MonospaceFontName']);
493
- return parsePangoFontDescription(result.status === 0 ? result.stdout.trim() : 'Monospace 10');
494
- },
495
-
496
- kitty: (deps) => {
497
- const directory = deps.env.KITTY_CONFIG_DIRECTORY || path.join(configHome(deps), 'kitty');
498
- const settings = {};
499
- const visit = (file, depth) => {
500
- const text = deps.readFile(file);
501
- if (text === null || depth > 4) return;
502
- for (const rawLine of text.split(/\r?\n/)) {
503
- const line = rawLine.trim();
504
- if (!line || line.startsWith('#')) continue;
505
- const [key, ...rest] = line.split(/\s+/);
506
- const value = rest.join(' ');
507
- if (key === 'include') visit(path.resolve(directory, expandHome(value, deps)), depth + 1);
508
- else if (key === 'font_family' || key === 'font_size') settings[key] = value;
509
- }
510
- };
511
- visit(path.join(directory, 'kitty.conf'), 0);
512
- // kitty 0.33+ also accepts `font_family family="JetBrains Mono" style=...`.
513
- const raw = settings.font_family || 'monospace';
514
- const quoted = /family\s*=\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(raw);
515
- const family = quoted ? (quoted[1] || quoted[2] || quoted[3]) : raw;
516
- return { family, sizePx: pointsToPx(settings.font_size || 11) };
517
- },
518
-
519
- alacritty: (deps) => {
520
- const candidates = [
521
- path.join(configHome(deps), 'alacritty', 'alacritty.toml'),
522
- path.join(configHome(deps), 'alacritty.toml'),
523
- path.join(deps.home, '.alacritty.toml'),
524
- ];
525
- const merged = {};
526
- const visit = (file, depth) => {
527
- const text = deps.readFile(file);
528
- if (text === null || depth > 4) return false;
529
- const values = parseTomlSubset(text);
530
- // Imports come first; the importing file overrides them.
531
- for (const imported of [...(values.import || []), ...(values['general.import'] || [])]) {
532
- visit(path.resolve(path.dirname(file), expandHome(String(imported), deps)), depth + 1);
533
- }
534
- Object.assign(merged, values);
535
- return true;
536
- };
537
- candidates.some((file) => visit(file, 0));
538
- const fallbackFamily = deps.platform === 'darwin' ? 'Menlo' : 'monospace';
539
- return {
540
- family: String(merged['font.normal.family'] || fallbackFamily),
541
- sizePx: pointsToPx(merged['font.size'] || 11.25),
542
- };
543
- },
544
-
545
- ghostty: (deps) => {
546
- const settings = { families: [], size: undefined };
547
- const visit = (file, depth) => {
548
- const text = deps.readFile(file);
549
- if (text === null || depth > 4) return;
550
- for (const rawLine of text.split(/\r?\n/)) {
551
- const line = rawLine.trim();
552
- if (!line || line.startsWith('#')) continue;
553
- const equals = line.indexOf('=');
554
- if (equals === -1) continue;
555
- const key = line.slice(0, equals).trim();
556
- const value = line.slice(equals + 1).trim().replace(/^"(.*)"$/, '$1');
557
- if (key === 'font-family') {
558
- // An empty value resets the list, per Ghostty's repeatable keys.
559
- if (value) settings.families.push(value); else settings.families = [];
560
- } else if (key === 'font-size') {
561
- settings.size = value;
562
- } else if (key === 'config-file') {
563
- const target = expandHome(value.replace(/^\?/, ''), deps);
564
- visit(path.resolve(path.dirname(file), target), depth + 1);
565
- }
566
- }
567
- };
568
- visit(path.join(configHome(deps), 'ghostty', 'config'), 0);
569
- if (deps.platform === 'darwin') {
570
- visit(path.join(deps.home, 'Library', 'Application Support', 'com.mitchellh.ghostty', 'config'), 0);
571
- }
572
- // Ghostty ships JetBrains Mono inside the binary and draws with it by default.
573
- return {
574
- family: settings.families[0] || 'JetBrains Mono',
575
- sizePx: pointsToPx(settings.size || (deps.platform === 'darwin' ? 13 : 12)),
576
- };
577
- },
578
-
579
- wezterm: (deps) => {
580
- const result = deps.run('wezterm', ['ls-fonts']);
581
- const listing = result.status === 0 ? result.stdout : '';
582
- const match = /family\s*=\s*"([^"]+)"/.exec(listing) || /font_with_fallback\(\{[\s\S]*?"([^"]+)"/.exec(listing);
583
- let sizePt = 12;
584
- for (const file of [path.join(deps.home, '.wezterm.lua'), path.join(configHome(deps), 'wezterm', 'wezterm.lua')]) {
585
- const size = /font_size\s*=\s*(\d+(?:\.\d+)?)/.exec(deps.readFile(file) || '');
586
- if (size) { sizePt = Number(size[1]); break; }
587
- }
588
- // WezTerm, like Ghostty, embeds JetBrains Mono as its default.
589
- return { family: match ? match[1] : 'JetBrains Mono', sizePx: pointsToPx(sizePt) };
590
- },
591
-
592
- foot: (deps) => {
593
- const ini = parseIni(deps.readFile(path.join(configHome(deps), 'foot', 'foot.ini')));
594
- const value = ini.main?.font || ini['']?.font || 'monospace:size=8';
595
- return parseFontconfigPattern(value);
596
- },
597
-
598
- vscode: (deps) => {
599
- const base = deps.platform === 'darwin'
600
- ? path.join(deps.home, 'Library', 'Application Support')
601
- : configHome(deps);
602
- for (const product of ['Code', 'Cursor', 'Code - Insiders', 'VSCodium', 'Windsurf']) {
603
- const settings = parseJsonc(deps.readFile(path.join(base, product, 'User', 'settings.json')));
604
- if (!settings) continue;
605
- const family = firstCssFamily(settings['terminal.integrated.fontFamily'])
606
- || firstCssFamily(settings['editor.fontFamily']);
607
- const sizePx = positiveNumber(settings['terminal.integrated.fontSize'])
608
- || positiveNumber(settings['editor.fontSize'])
609
- || 14;
610
- if (family) return { family, sizePx };
611
- }
612
- return { family: deps.platform === 'darwin' ? 'Menlo' : 'Droid Sans Mono', sizePx: 14 };
613
- },
614
-
615
- iterm2: (deps) => {
616
- const prefs = readPreferences('com.googlecode.iterm2', deps);
617
- const profiles = Array.isArray(prefs?.['New Bookmarks']) ? prefs['New Bookmarks'] : [];
618
- const profile = profiles.find((candidate) => candidate?.Guid === prefs['Default Bookmark Guid']) || profiles[0];
619
- // `JetBrainsMonoNF-Regular 13`: a PostScript name, then the size in points.
620
- const match = /^(.+?)\s+(\d+(?:\.\d+)?)$/.exec(String(profile?.['Normal Font'] || '').trim());
621
- if (!match) return null;
622
- const family = familyForPostScriptName(match[1], deps);
623
- return family ? { family, sizePx: pointsToPx(match[2]) } : null;
624
- },
625
-
626
- 'apple-terminal': (deps) => {
627
- const prefs = readPreferences('com.apple.Terminal', deps);
628
- if (!prefs) return null;
629
- const name = prefs['Default Window Settings'] || prefs['Startup Window Settings'] || 'Basic';
630
- const profile = prefs['Window Settings']?.[name];
631
- // A profile that was never given a font draws with Terminal's own default,
632
- // SF Mono 11 since macOS 10.15.
633
- let postscript = 'SFMono-Regular';
634
- let points = 11;
635
- if (Buffer.isBuffer(profile?.Font)) {
636
- // An archived NSFont: its PostScript name and point size.
637
- const font = unarchiveKeyed(parseBinaryPlist(profile.Font));
638
- if (typeof font?.NSName === 'string' && font.NSName) postscript = font.NSName;
639
- if (Number(font?.NSSize) > 0) points = Number(font.NSSize);
640
- }
641
- const family = familyForPostScriptName(postscript, deps);
642
- return family ? { family, sizePx: pointsToPx(points) } : null;
643
- },
644
-
645
- xterm: (deps) => {
646
- // The emulator's own command line (`-fa`, `-fs`, `-xrm`) over the X
647
- // resources, the way xterm itself ranks them. Without a faceName xterm
648
- // draws with a bitmap core font, which no browser can load: no answer.
649
- const classes = ['XTerm', 'xterm', 'UXTerm', 'uxterm'];
650
- const args = deps.terminalArgs || [];
651
- const resources = [...xResources(deps), ...xrmResources(args)];
652
- const face = optionValue(args, ['-fa', '-faceName']) || xResource(resources, classes, 'faceName');
653
- if (!face) return null;
654
- const font = parseXFontName(face, { bareIsPattern: true });
655
- if (!font) return null;
656
- const size = Number(optionValue(args, ['-fs', '-faceSize']) || xResource(resources, classes, 'faceSize'));
657
- return size > 0 ? { ...font, sizePx: pointsToPx(size) } : font;
658
- },
659
-
660
- urxvt: (deps) => {
661
- const args = deps.terminalArgs || [];
662
- const resources = [...xResources(deps), ...xrmResources(args)];
663
- const font = optionValue(args, ['-fn']) || xResource(resources, ['URxvt', 'urxvt', 'Rxvt', 'rxvt'], 'font');
664
- return font ? parseXFontName(font) : null;
665
- },
666
- };
667
-
668
- /**
669
- * A macOS preferences file, parsed. These are binary plists; one that is not
670
- * (edited by hand) is left alone rather than guessed at.
671
- */
672
- function readPreferences(domain, deps) {
673
- const data = deps.readBuffer(path.join(deps.home, 'Library', 'Preferences', `${domain}.plist`));
674
- if (!data) return null;
675
- try {
676
- return parseBinaryPlist(data);
677
- } catch {
678
- return null;
679
- }
680
- }
681
-
682
- /** The last value given to any of `names` on a command line. */
683
- function optionValue(args, names) {
684
- let value = null;
685
- for (let i = 0; i < args.length - 1; i += 1) {
686
- if (names.includes(args[i])) value = args[i + 1];
687
- }
688
- return value;
689
- }
690
-
691
- /** `-xrm 'XTerm*faceName: Hack'` entries, in the shape `xResources` returns. */
692
- function xrmResources(args) {
693
- const entries = [];
694
- for (let i = 0; i < args.length - 1; i += 1) {
695
- if (args[i] !== '-xrm') continue;
696
- const colon = args[i + 1].indexOf(':');
697
- if (colon !== -1) entries.push([args[i + 1].slice(0, colon).trim(), args[i + 1].slice(colon + 1).trim()]);
698
- }
699
- return entries;
700
- }
701
-
702
- /** The X resource database, as `xrdb -query` prints it: `name:\tvalue`. */
703
- function xResources(deps) {
704
- const result = deps.run('xrdb', ['-query']);
705
- if (result.status !== 0) return [];
706
- return result.stdout.split('\n').map((line) => {
707
- const colon = line.indexOf(':');
708
- return colon === -1 ? null : [line.slice(0, colon).trim(), line.slice(colon + 1).trim()];
709
- }).filter(Boolean);
710
- }
711
-
712
- /**
713
- * The value X would give `<class>.<...>.<resource>`, simplified to what
714
- * terminal users write: `XTerm*faceName`, `xterm.vt100.faceName`, `*faceName`.
715
- * A named class wins over the bare wildcard.
716
- */
717
- function xResource(resources, classes, resource) {
718
- let named = null;
719
- let wildcard = null;
720
- // Later entries win: `-xrm` options are appended after the database.
721
- for (const [name, value] of resources) {
722
- const parts = name.split(/[.*]/).filter(Boolean);
723
- if (parts[parts.length - 1] !== resource || !value) continue;
724
- if (parts.length === 1) wildcard = value;
725
- else if (classes.includes(parts[0])) named = value;
726
- }
727
- return named ?? wildcard;
728
- }
729
-
730
- /**
731
- * An X terminal's font name: `xft:JetBrains Mono:size=11` (FreeType, any list
732
- * takes its first entry) or an XLFD `-misc-dejavu sans mono-medium-r-normal--14-…`.
733
- * A bare core-font alias (`fixed`, `9x15`) names a bitmap font: no answer —
734
- * except in xterm's `faceName`, which is always a FreeType pattern.
735
- */
736
- function parseXFontName(value, { bareIsPattern = false } = {}) {
737
- const first = String(value || '').split(/,(?=\s*(?:xft:|-))/)[0].trim();
738
- if (/^xft:/i.test(first)) return parseFontconfigPattern(first.slice(4));
739
- if (first.startsWith('-')) {
740
- const fields = first.split('-');
741
- const family = (fields[2] || '').trim();
742
- if (!family || family === '*' || /^(fixed|misc)$/i.test(family)) return null;
743
- const pixels = Number(fields[7]);
744
- const decipoints = Number(fields[8]);
745
- return { family, sizePx: pixels > 0 ? pixels : decipoints > 0 ? pointsToPx(decipoints / 10) : undefined };
746
- }
747
- if (!first) return null;
748
- return bareIsPattern || first.includes(':') ? parseFontconfigPattern(first) : null;
749
- }
750
-
751
- // ── Font files ──────────────────────────────────────────────────────────
752
-
753
- const FACE_PATTERNS = {
754
- regular: ':weight=regular:slant=roman',
755
- bold: ':weight=bold:slant=roman',
756
- italic: ':weight=regular:slant=italic',
757
- boldItalic: ':weight=bold:slant=italic',
758
- };
759
-
760
- function escapeFontconfig(value) {
761
- return value.replace(/([\\\-:,])/g, '\\$1');
762
- }
763
-
764
- /** `00 01 00 00` / `true` are TrueType, `OTTO` is CFF OpenType. */
765
- function sfntFormat(header) {
766
- if (!header || header.length < 4) return null;
767
- const tag = header.subarray(0, 4).toString('latin1');
768
- if (tag === '\x00\x01\x00\x00' || tag === 'true') return 'truetype';
769
- if (tag === 'OTTO') return 'opentype';
770
- return null;
771
- }
772
-
773
- function readHeader(filePath, length) {
774
- let fd;
775
- try {
776
- fd = fs.openSync(filePath, 'r');
777
- const buffer = Buffer.alloc(length);
778
- const read = fs.readSync(fd, buffer, 0, length, 0);
779
- return buffer.subarray(0, read);
780
- } catch {
781
- return null;
782
- } finally {
783
- if (fd !== undefined) try { fs.closeSync(fd); } catch {}
784
- }
785
- }
786
-
787
- /**
788
- * The family, subfamily and PostScript names in a TTF/OTF `name` table. Used
789
- * where fontconfig is absent — a stock macOS — to find a family's files.
790
- */
791
- function readSfntNames(filePath) {
792
- let fd;
793
- try {
794
- fd = fs.openSync(filePath, 'r');
795
- const head = Buffer.alloc(12);
796
- fs.readSync(fd, head, 0, 12, 0);
797
- if (!sfntFormat(head)) return null;
798
- const tables = head.readUInt16BE(4);
799
- const directory = Buffer.alloc(tables * 16);
800
- fs.readSync(fd, directory, 0, directory.length, 12);
801
- let offset = -1;
802
- let length = 0;
803
- for (let i = 0; i < tables; i += 1) {
804
- if (directory.toString('latin1', i * 16, i * 16 + 4) === 'name') {
805
- offset = directory.readUInt32BE(i * 16 + 8);
806
- length = directory.readUInt32BE(i * 16 + 12);
807
- }
808
- }
809
- if (offset < 0 || length > 1024 * 1024) return null;
810
- const table = Buffer.alloc(length);
811
- fs.readSync(fd, table, 0, length, offset);
812
- const count = table.readUInt16BE(2);
813
- const strings = table.readUInt16BE(4);
814
- const names = {};
815
- for (let i = 0; i < count; i += 1) {
816
- const record = 6 + i * 12;
817
- const platformId = table.readUInt16BE(record);
818
- const languageId = table.readUInt16BE(record + 4);
819
- const nameId = table.readUInt16BE(record + 6);
820
- const size = table.readUInt16BE(record + 8);
821
- const start = strings + table.readUInt16BE(record + 10);
822
- if (![1, 2, 6, 16, 17].includes(nameId) || start + size > table.length) continue;
823
- let text;
824
- if ((platformId === 3 || platformId === 0) && size % 2 === 0) {
825
- if (platformId === 3 && languageId !== 0x409 && names[nameId]) continue;
826
- const utf16 = Buffer.from(table.subarray(start, start + size));
827
- text = utf16.swap16().toString('utf16le');
828
- } else if (platformId === 1 && !names[nameId]) {
829
- text = table.toString('latin1', start, start + size);
830
- }
831
- if (text) names[nameId] = text;
832
- }
833
- return {
834
- family: names[16] || names[1] || null,
835
- subfamily: names[17] || names[2] || '',
836
- postscript: names[6] || null,
837
- };
838
- } catch {
839
- return null;
840
- } finally {
841
- if (fd !== undefined) try { fs.closeSync(fd); } catch {}
842
- }
843
- }
844
-
845
- function macFontFiles(deps) {
846
- const roots = [
847
- path.join(deps.home, 'Library', 'Fonts'),
848
- '/Library/Fonts',
849
- '/System/Library/Fonts',
850
- ];
851
- const files = [];
852
- const walk = (directory, depth) => {
853
- let entries;
854
- try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return; }
855
- for (const entry of entries) {
856
- const full = path.join(directory, entry.name);
857
- if (entry.isDirectory() && depth < 2) walk(full, depth + 1);
858
- else if (/\.(ttf|otf)$/i.test(entry.name)) files.push(full);
859
- }
860
- };
861
- for (const root of roots) walk(root, 0);
862
- return files;
863
- }
864
-
865
- /** Apple's own terminal faces, which live outside the font folders. */
866
- const APPLE_POSTSCRIPT_FAMILIES = [
867
- [/^\.?SF(NS)?Mono/, 'SF Mono'],
868
- [/^Menlo-/, 'Menlo'],
869
- [/^Monaco$/, 'Monaco'],
870
- [/^Courier(New)?/, 'Courier New'],
871
- [/^AndaleMono$/, 'Andale Mono'],
872
- ];
873
-
874
- function familyForPostScriptName(postscript, deps) {
875
- const apple = APPLE_POSTSCRIPT_FAMILIES.find(([pattern]) => pattern.test(postscript));
876
- if (apple && deps.platform === 'darwin') return apple[1];
877
- const listed = deps.run('fc-list', [`:postscriptname=${escapeFontconfig(postscript)}`, 'family']);
878
- if (listed.status === 0 && listed.stdout.trim()) return listed.stdout.trim().split('\n')[0].split(',')[0].trim();
879
- if (deps.platform !== 'darwin') return null;
880
- for (const file of macFontFiles(deps)) {
881
- const names = readSfntNames(file);
882
- if (names?.postscript === postscript) return names.family;
883
- }
884
- return null;
885
- }
886
-
887
- function styleOfSubfamily(subfamily) {
888
- const text = String(subfamily || '').toLowerCase();
889
- const bold = /\bbold\b/.test(text) && !/(semi|demi|extra|ultra)\s*bold/.test(text);
890
- const italic = /italic|oblique/.test(text);
891
- if (bold && italic) return 'boldItalic';
892
- if (bold) return 'bold';
893
- if (italic) return 'italic';
894
- return /^(regular|normal|book|roman)$/.test(text.trim()) ? 'regular' : null;
895
- }
896
-
897
- /** Candidate files per style, from fontconfig or, failing that, a scan. */
898
- function locateFaceFiles(family, deps) {
899
- const located = {};
900
- let fontconfig = false;
901
- for (const style of TERMINAL_FONT_STYLES) {
902
- const result = deps.run('fc-match', ['-f', '%{file}\n%{family}\n%{index}\n', `${escapeFontconfig(family)}${FACE_PATTERNS[style]}`]);
903
- if (result.status !== 0) continue;
904
- fontconfig = true;
905
- const [file, families = '', index = '0'] = result.stdout.split('\n');
906
- // fontconfig always answers; an answer in another family means the
907
- // requested one is not installed. A collection member is not a file a
908
- // browser can load on its own.
909
- const names = families.split(',').map((name) => name.trim().toLowerCase());
910
- if (!file || !names.includes(family.toLowerCase()) || Number(index) !== 0) continue;
911
- located[style] = file;
912
- }
913
- if (fontconfig || deps.platform !== 'darwin') return located;
914
-
915
- for (const file of macFontFiles(deps)) {
916
- const names = readSfntNames(file);
917
- if (!names || names.family?.toLowerCase() !== family.toLowerCase()) continue;
918
- const style = styleOfSubfamily(names.subfamily);
919
- if (style && !located[style]) located[style] = file;
920
- }
921
- return located;
922
- }
923
-
924
- function hashFile(filePath) {
925
- return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
926
- }
927
-
928
- /**
929
- * The files behind a family, ready to be offered to a browser.
930
- *
931
- * A style fontconfig can only satisfy with the regular file is left out: the
932
- * browser synthesises bold and italic from the regular face just as well, and
933
- * sending the same file twice would only cost the phone the bytes.
934
- */
935
- function resolveFontFaces(family, deps = {}) {
936
- const resolved = withDefaults(deps);
937
- if (!family || GENERIC_FAMILIES.has(family.toLowerCase())) return [];
938
- const located = locateFaceFiles(family, resolved);
939
- const faces = [];
940
- const usedFiles = new Set();
941
- for (const style of TERMINAL_FONT_STYLES) {
942
- const file = located[style];
943
- if (!file || usedFiles.has(file)) continue;
944
- // Apple's system fonts (SF Mono, Menlo…) are licensed for Apple devices
945
- // only; offering them to any browser would redistribute them. An Apple
946
- // device has them already, and anything else falls back by name.
947
- if (/^\/System\//.test(file) || /\.app\/Contents\//.test(file)) continue;
948
- if (style !== 'regular' && !faces.length) continue;
949
- let stat;
950
- try { stat = fs.statSync(file); } catch { continue; }
951
- if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_TERMINAL_FONT_BYTES) continue;
952
- const format = sfntFormat(readHeader(file, 4));
953
- if (!format) continue;
954
- usedFiles.add(file);
955
- faces.push({ style, format, bytes: stat.size, sha256: hashFile(file), path: file, mtimeMs: stat.mtimeMs });
956
- }
957
- return faces;
958
- }
959
-
960
- /** A file a subset can be cut from; far larger than a face sent whole. */
961
- const MAX_SUBSET_SOURCE_BYTES = 64 * 1024 * 1024;
962
-
963
- /** Where fontconfig finds `pattern`: the file, the face's names, its index. */
964
- function locateFont(pattern, deps) {
965
- const result = deps.run('fc-match', ['-f', '%{file}\n%{family}\n%{index}\n', pattern]);
966
- if (result.status !== 0) return null;
967
- const [file, families = '', index = '0'] = result.stdout.split('\n');
968
- if (!file) return null;
969
- return { file, families: families.split(',').map((name) => name.trim()).filter(Boolean), index: Number(index) || 0 };
970
- }
971
-
972
- /**
973
- * Fonts the browser receives a few characters at a time instead of whole:
974
- *
975
- * - `cjk`: the face fontconfig falls back to for Chinese under this family —
976
- * what the workstation's terminal actually draws Hanzi with. 16–20 MB, and
977
- * often inside a collection (.ttc) a browser cannot load anyway.
978
- * - `all`: the family itself, when it is too large or in a collection to be
979
- * sent as a whole file (a CJK programming font like Sarasa Mono).
980
- *
981
- * Only regular weights: the browser emboldens them for bold text, which is
982
- * how a missing bold is drawn everywhere else too.
983
- */
984
- function resolveSubsetSources(family, faces, deps = {}) {
985
- const resolved = withDefaults(deps);
986
- if (!family || GENERIC_FAMILIES.has(family.toLowerCase())) return [];
987
- const wanted = family.toLowerCase();
988
- const sources = [];
989
- const add = (located, scope, name) => {
990
- if (!located || /^\/System\//.test(located.file) || /\.app\/Contents\//.test(located.file)) return;
991
- let stat;
992
- try { stat = fs.statSync(located.file); } catch { return; }
993
- if (!stat.isFile() || stat.size > MAX_SUBSET_SOURCE_BYTES) return;
994
- const header = readHeader(located.file, 4);
995
- if (!sfntFormat(header) && header?.toString('latin1') !== 'ttcf') return;
996
- // Identifies the face without reading 20 MB: a browser caches by it.
997
- const sha256 = crypto.createHash('sha256')
998
- .update(`${located.file}\0${located.index}\0${stat.size}\0${stat.mtimeMs}`)
999
- .digest('hex');
1000
- sources.push({
1001
- family: name, style: 'regular', scope, sha256,
1002
- path: located.file, index: located.index, bytes: stat.size, mtimeMs: stat.mtimeMs,
1003
- });
1004
- };
1005
-
1006
- const self = locateFont(`${escapeFontconfig(family)}${FACE_PATTERNS.regular}`, resolved);
1007
- const selfInstalled = self && self.families.some((name) => name.toLowerCase() === wanted);
1008
- if (selfInstalled && !faces.some((face) => face.style === 'regular')) add(self, 'all', family);
1009
-
1010
- const cjk = locateFont(`${escapeFontconfig(family)}:charset=4e00${FACE_PATTERNS.regular}`, resolved);
1011
- const coveredBySelf = cjk && cjk.families.some((name) => name.toLowerCase() === wanted);
1012
- if (cjk && !coveredBySelf && cjk.families[0]) add(cjk, 'cjk', cjk.families[0]);
1013
- return sources;
1014
- }
1015
-
1016
- /** A generic family is replaced by the real face fontconfig picks for it. */
1017
- function concreteFamily(family, deps) {
1018
- if (!GENERIC_FAMILIES.has(String(family).toLowerCase())) return family;
1019
- const result = deps.run('fc-match', ['-f', '%{family[0]}', family]);
1020
- const matched = result.status === 0 ? result.stdout.trim() : '';
1021
- return matched || null;
1022
- }
1023
-
1024
- // ── Putting it together ─────────────────────────────────────────────────
1025
-
1026
- /** Reads one terminal's configured font: `{ source, family, sizePx }` or null. */
1027
- function readTerminalFont(source, deps = {}) {
1028
- const resolved = withDefaults(deps);
1029
- const reader = READERS[source];
1030
- if (!reader) return null;
1031
- let font;
1032
- try {
1033
- font = reader(resolved);
1034
- } catch {
1035
- return null;
1036
- }
1037
- if (!font?.family) return null;
1038
- const family = concreteFamily(font.family, resolved);
1039
- if (!family) return null;
1040
- return sanitizeDetected({ source, family, sizePx: font.sizePx });
1041
- }
1042
-
1043
- /** Identifies the terminal and reads its font. */
1044
- function detectTerminalFont({ env = process.env, ancestry, lineage, deps = {} } = {}) {
1045
- const processes = lineage || (ancestry ? ancestry.map((name) => ({ pid: 0, name })) : processLineage({ run: deps.run }));
1046
- const source = identifyTerminal({ env, ancestry: processes.map((entry) => entry.name) });
1047
- if (!source) return null;
1048
- // A terminal configured on its command line (xterm's `-fa`) says so there.
1049
- const emulator = terminalProcess(source, processes);
1050
- const terminalArgs = deps.terminalArgs || (emulator?.pid ? processArguments(emulator.pid) : []);
1051
- return readTerminalFont(source, { ...deps, env, terminalArgs });
1052
- }
1053
-
1054
- /** What detection reports, before any files are attached. */
1055
- function sanitizeDetected(value) {
1056
- const clean = sanitizeTerminalFont(value);
1057
- if (!clean) return null;
1058
- const detected = { source: clean.source || null, family: clean.family };
1059
- if (clean.sizePx) detected.sizePx = clean.sizePx;
1060
- return detected;
1061
- }
1062
-
1063
- /** The full record a connector serves from: detection plus local files. */
1064
- function describeTerminalFont(detected, deps = {}) {
1065
- if (!detected?.family) return null;
1066
- const faces = resolveFontFaces(detected.family, deps);
1067
- return { ...detected, faces, subsets: resolveSubsetSources(detected.family, faces, deps) };
1068
- }
1069
-
1070
- /** The same record with the file paths removed: what may leave this machine. */
1071
- function publicTerminalFont(record) {
1072
- return record ? sanitizeTerminalFont(record) : null;
1073
- }
1074
-
1075
- /**
1076
- * One slice of a face, by hash. The file is re-checked against what was
1077
- * announced, so an edited font is refused rather than spliced into a browser's
1078
- * copy of the old one.
1079
- */
1080
- function readFontChunk(record, sha256, index) {
1081
- const face = record?.faces?.find((candidate) => candidate.sha256 === sha256);
1082
- if (!face) return null;
1083
- const total = Math.ceil(face.bytes / TERMINAL_FONT_CHUNK_BYTES);
1084
- if (!Number.isInteger(index) || index < 0 || index >= total) return null;
1085
- let fd;
1086
- try {
1087
- const stat = fs.statSync(face.path);
1088
- if (stat.size !== face.bytes || stat.mtimeMs !== face.mtimeMs) return null;
1089
- fd = fs.openSync(face.path, 'r');
1090
- const start = index * TERMINAL_FONT_CHUNK_BYTES;
1091
- const length = Math.min(TERMINAL_FONT_CHUNK_BYTES, face.bytes - start);
1092
- const buffer = Buffer.alloc(length);
1093
- const read = fs.readSync(fd, buffer, 0, length, start);
1094
- if (read !== length) return null;
1095
- return { data: buffer, total };
1096
- } catch {
1097
- return null;
1098
- } finally {
1099
- if (fd !== undefined) try { fs.closeSync(fd); } catch {}
1100
- }
1101
- }
1102
-
1103
- /** Reads a font a parent process already detected. */
1104
- function fontFromEnvironment(env = process.env) {
1105
- const raw = env.HERDR_TERM_FONT_JSON;
1106
- if (!raw) return null;
1107
- try {
1108
- return sanitizeDetected(JSON.parse(raw));
1109
- } catch {
1110
- return null;
1111
- }
1112
- }
1113
-
1114
- /**
1115
- * Remembers the font for a start that has no terminal around it: a service
1116
- * manager's, or Herdr's detached server's.
1117
- */
1118
- function rememberTerminalFont(detected) {
1119
- const clean = sanitizeDetected(detected);
1120
- if (!clean) return null;
1121
- try {
1122
- ensureDir(stateDir());
1123
- const state = readJson(runtimeStatePath(), {});
1124
- const previous = state.terminalFont;
1125
- if (!previous || JSON.stringify(previous) !== JSON.stringify(clean)) {
1126
- writeJsonAtomic(runtimeStatePath(), { ...state, terminalFont: clean });
1127
- }
1128
- } catch {
1129
- // A font is a nicety; failing to remember it must not break a start.
1130
- }
1131
- return clean;
1132
- }
1133
-
1134
- function rememberedTerminalFont() {
1135
- try {
1136
- return sanitizeDetected(readJson(runtimeStatePath(), {}).terminalFont);
1137
- } catch {
1138
- return null;
1139
- }
1140
- }
1141
-
1142
- /**
1143
- * Detect the terminal's font at the entry point, where the terminal's
1144
- * variables and process ancestry are still this process's own, and hand the
1145
- * answer to everything this command starts.
1146
- */
1147
- function captureTerminalFont({ env = process.env, detect = detectTerminalFont } = {}) {
1148
- if (env.HERDR_TERM_FONT_JSON) return fontFromEnvironment(env);
1149
- const detected = detect({ env });
1150
- if (!detected) return null;
1151
- env.HERDR_TERM_FONT_JSON = JSON.stringify(detected);
1152
- rememberTerminalFont(detected);
1153
- return detected;
1154
- }
1155
-
1156
- /** The font a service start hands its children: inherited, else remembered. */
1157
- function resolveHostFont({ env = process.env } = {}) {
1158
- return fontFromEnvironment(env) || rememberedTerminalFont();
1159
- }
1160
-
1161
- /**
1162
- * What the connector serves: the terminal's settings read again now, when the
1163
- * terminal is known, so a font changed since the last start is not reported
1164
- * stale; otherwise what was handed down. `refresh` also re-identifies a
1165
- * terminal nobody had named, for a browser that asks to sync.
1166
- */
1167
- function loadHostTerminalFont({ env = process.env, refresh = false, deps = {} } = {}) {
1168
- const known = resolveHostFont({ env });
1169
- let current = known?.source ? readTerminalFont(known.source, { ...deps, env }) : null;
1170
- if (!current && refresh && !known) current = detectTerminalFont({ env, deps });
1171
- if (current && JSON.stringify(current) !== JSON.stringify(known)) rememberTerminalFont(current);
1172
- return describeTerminalFont(current || known, deps);
1173
- }
1174
-
1175
- module.exports = {
1176
- PX_PER_PT,
1177
- parsePangoFontDescription,
1178
- parseQtFontString,
1179
- parseFontconfigPattern,
1180
- parseGVariantString,
1181
- parseTomlSubset,
1182
- parseJsonc,
1183
- parseXFontName,
1184
- xResource,
1185
- firstCssFamily,
1186
- identifyTerminal,
1187
- processAncestry,
1188
- processLineage,
1189
- readTerminalFont,
1190
- detectTerminalFont,
1191
- resolveFontFaces,
1192
- resolveSubsetSources,
1193
- readSfntNames,
1194
- sfntFormat,
1195
- describeTerminalFont,
1196
- publicTerminalFont,
1197
- readFontChunk,
1198
- fontFromEnvironment,
1199
- rememberTerminalFont,
1200
- rememberedTerminalFont,
1201
- captureTerminalFont,
1202
- resolveHostFont,
1203
- loadHostTerminalFont,
1204
- };