herdr-remote 0.2.17 → 0.2.18

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/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.17"
3
+ version = "0.2.18"
4
4
  min_herdr_version = "0.9.1"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,7 +43,8 @@
43
43
  "start": "node bin/herdr-remote.js"
44
44
  },
45
45
  "dependencies": {
46
- "herdr-remote-relay": "^0.2.1",
46
+ "harfbuzzjs": "^1.6.2",
47
+ "herdr-remote-relay": "^0.3.9",
47
48
  "ink": "^7.1.1",
48
49
  "node-pty": "^1.1.0",
49
50
  "qrcode": "^1.5.4",
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Reads Apple binary property lists (`bplist00`), the format macOS writes its
5
+ * preference files in, and the NSKeyedArchiver graphs stored inside them.
6
+ *
7
+ * Terminal.app keeps each profile's font as an archived NSFont — a binary
8
+ * plist nested in a binary plist — and there is no command-line tool that
9
+ * turns that into something readable without asking Terminal itself (an
10
+ * AppleScript that prompts for Automation access). A reader this small is
11
+ * less intrusive than any of those.
12
+ */
13
+
14
+ /** A reference to another object in a keyed archive (`CF$UID`). */
15
+ class Uid {
16
+ constructor(value) {
17
+ this.value = value;
18
+ }
19
+ }
20
+
21
+ function readSizedInt(buffer, offset, size) {
22
+ let value = 0;
23
+ for (let i = 0; i < size; i += 1) value = value * 256 + buffer[offset + i];
24
+ return value;
25
+ }
26
+
27
+ /**
28
+ * Parses a whole `bplist00` buffer into plain values: objects, arrays,
29
+ * strings, numbers, booleans, Buffers (data), Dates and `Uid`s. Throws on
30
+ * anything malformed; callers treat that as "no answer".
31
+ */
32
+ function parseBinaryPlist(buffer) {
33
+ if (!Buffer.isBuffer(buffer) || buffer.length < 40 || buffer.toString('latin1', 0, 8) !== 'bplist00') {
34
+ throw new Error('not a binary plist');
35
+ }
36
+ const trailer = buffer.length - 32;
37
+ const offsetSize = buffer[trailer + 6];
38
+ const refSize = buffer[trailer + 7];
39
+ const count = readSizedInt(buffer, trailer + 8, 8);
40
+ const top = readSizedInt(buffer, trailer + 16, 8);
41
+ const tableOffset = readSizedInt(buffer, trailer + 24, 8);
42
+ if (!offsetSize || !refSize || count > 1_000_000 || tableOffset + count * offsetSize > trailer) {
43
+ throw new Error('corrupt binary plist trailer');
44
+ }
45
+ const offsets = [];
46
+ for (let i = 0; i < count; i += 1) offsets.push(readSizedInt(buffer, tableOffset + i * offsetSize, offsetSize));
47
+
48
+ const parsing = new Set();
49
+ const parse = (index) => {
50
+ if (index >= count || parsing.has(index)) throw new Error('corrupt binary plist reference');
51
+ parsing.add(index);
52
+ try {
53
+ return parseObject(offsets[index]);
54
+ } finally {
55
+ parsing.delete(index);
56
+ }
57
+ };
58
+
59
+ // A length stored in the marker's low nibble, or as the int object after it.
60
+ const lengthAt = (offset) => {
61
+ const low = buffer[offset] & 0x0f;
62
+ if (low !== 0x0f) return { length: low, start: offset + 1 };
63
+ const marker = buffer[offset + 1];
64
+ if ((marker & 0xf0) !== 0x10) throw new Error('corrupt binary plist length');
65
+ const size = 1 << (marker & 0x0f);
66
+ return { length: readSizedInt(buffer, offset + 2, size), start: offset + 2 + size };
67
+ };
68
+
69
+ const parseObject = (offset) => {
70
+ const marker = buffer[offset];
71
+ const type = marker >> 4;
72
+ const low = marker & 0x0f;
73
+ switch (type) {
74
+ case 0x0:
75
+ if (marker === 0x08) return false;
76
+ if (marker === 0x09) return true;
77
+ return null;
78
+ case 0x1: {
79
+ const size = 1 << low;
80
+ if (size === 8) return Number(buffer.readBigInt64BE(offset + 1));
81
+ return readSizedInt(buffer, offset + 1, size);
82
+ }
83
+ case 0x2:
84
+ return low === 2 ? buffer.readFloatBE(offset + 1) : buffer.readDoubleBE(offset + 1);
85
+ case 0x3:
86
+ return new Date(Date.UTC(2001, 0, 1) + buffer.readDoubleBE(offset + 1) * 1000);
87
+ case 0x4: {
88
+ const { length, start } = lengthAt(offset);
89
+ return buffer.subarray(start, start + length);
90
+ }
91
+ case 0x5: {
92
+ const { length, start } = lengthAt(offset);
93
+ return buffer.toString('latin1', start, start + length);
94
+ }
95
+ case 0x6: {
96
+ const { length, start } = lengthAt(offset);
97
+ return Buffer.from(buffer.subarray(start, start + length * 2)).swap16().toString('utf16le');
98
+ }
99
+ case 0x8:
100
+ return new Uid(readSizedInt(buffer, offset + 1, low + 1));
101
+ case 0xa:
102
+ case 0xc: {
103
+ const { length, start } = lengthAt(offset);
104
+ const items = [];
105
+ for (let i = 0; i < length; i += 1) items.push(parse(readSizedInt(buffer, start + i * refSize, refSize)));
106
+ return items;
107
+ }
108
+ case 0xd: {
109
+ const { length, start } = lengthAt(offset);
110
+ const result = {};
111
+ for (let i = 0; i < length; i += 1) {
112
+ const key = parse(readSizedInt(buffer, start + i * refSize, refSize));
113
+ result[String(key)] = parse(readSizedInt(buffer, start + (length + i) * refSize, refSize));
114
+ }
115
+ return result;
116
+ }
117
+ default:
118
+ throw new Error(`unsupported binary plist object 0x${marker.toString(16)}`);
119
+ }
120
+ };
121
+
122
+ return parse(top);
123
+ }
124
+
125
+ /**
126
+ * The root object of an NSKeyedArchiver plist, with every `Uid` replaced by
127
+ * the object it points at (to a bounded depth; archives may be cyclic).
128
+ */
129
+ function unarchiveKeyed(archive, depth = 6) {
130
+ const objects = archive?.$objects;
131
+ const rootRef = archive?.$top?.root;
132
+ if (!Array.isArray(objects) || !(rootRef instanceof Uid)) throw new Error('not a keyed archive');
133
+ const resolve = (value, level) => {
134
+ if (value instanceof Uid) return level > depth ? null : resolve(objects[value.value], level + 1);
135
+ if (Array.isArray(value)) return value.map((item) => resolve(item, level));
136
+ if (value && typeof value === 'object' && !Buffer.isBuffer(value) && !(value instanceof Date)) {
137
+ const result = {};
138
+ for (const [key, item] of Object.entries(value)) result[key] = resolve(item, level);
139
+ return result;
140
+ }
141
+ return value === '$null' ? null : value;
142
+ };
143
+ return resolve(rootRef, 0);
144
+ }
145
+
146
+ module.exports = { Uid, parseBinaryPlist, unarchiveKeyed };
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cuts the characters a browser needs out of a large font.
5
+ *
6
+ * A CJK face is 16–20 MB — too much to send a phone whole — but a terminal
7
+ * only ever shows a few thousand distinct characters. HarfBuzz's subsetter
8
+ * (the same code Google Fonts slices its CJK families with) produces a small,
9
+ * valid font holding exactly the requested characters: 3,755 common Hanzi in
10
+ * about 1.7 MB and 50 ms, a handful of rare ones in a few KB and milliseconds.
11
+ *
12
+ * The WebAssembly build has no imports and runs synchronously. A loaded face
13
+ * costs its file size twice over (the file and the wasm heap), so the whole
14
+ * instance is dropped after a quiet minute; wasm memory never shrinks, only
15
+ * a new instance gives it back.
16
+ */
17
+
18
+ const fs = require('node:fs');
19
+
20
+ /** How long a loaded font may sit unused before its memory is released. */
21
+ const IDLE_RELEASE_MS = 60_000;
22
+ /** Never cut more than this many characters in one go. */
23
+ const MAX_CODEPOINTS = 16_384;
24
+ const HB_MEMORY_MODE_READONLY = 1;
25
+
26
+ let wasmModule = null;
27
+ function loadModule() {
28
+ if (!wasmModule) {
29
+ const file = require.resolve('harfbuzzjs/dist/harfbuzz-subset.wasm');
30
+ wasmModule = new WebAssembly.Module(fs.readFileSync(file));
31
+ }
32
+ return wasmModule;
33
+ }
34
+
35
+ class FontSubsetter {
36
+ constructor({ idleMs = IDLE_RELEASE_MS } = {}) {
37
+ this.idleMs = idleMs;
38
+ this.instance = null;
39
+ /** Loaded faces by source key: `{ pointer, face }` in the current instance. */
40
+ this.faces = new Map();
41
+ this.idleTimer = null;
42
+ }
43
+
44
+ exports() {
45
+ if (!this.instance) {
46
+ this.instance = new WebAssembly.Instance(loadModule(), {}).exports;
47
+ // An Emscripten reactor: static constructors run here, before any call.
48
+ this.instance._initialize();
49
+ }
50
+ return this.instance;
51
+ }
52
+
53
+ face(source) {
54
+ const key = `${source.path}\0${source.index || 0}`;
55
+ const loaded = this.faces.get(key);
56
+ if (loaded) return loaded.face;
57
+ const hb = this.exports();
58
+ const data = fs.readFileSync(source.path);
59
+ const pointer = hb.malloc(data.length);
60
+ if (!pointer) throw new Error('out of memory loading font');
61
+ new Uint8Array(hb.memory.buffer).set(data, pointer);
62
+ const blob = hb.hb_blob_create(pointer, data.length, HB_MEMORY_MODE_READONLY, 0, 0);
63
+ const face = hb.hb_face_create(blob, source.index || 0);
64
+ hb.hb_blob_destroy(blob);
65
+ this.faces.set(key, { pointer, face });
66
+ return face;
67
+ }
68
+
69
+ /** A standalone font with just `codepoints` from `source` (`{ path, index }`). */
70
+ subset(source, codepoints) {
71
+ const hb = this.exports();
72
+ const face = this.face(source);
73
+ const input = hb.hb_subset_input_create_or_fail();
74
+ if (!input) throw new Error('font subsetting is unavailable');
75
+ let result = 0;
76
+ let subsetFace = 0;
77
+ try {
78
+ const set = hb.hb_subset_input_unicode_set(input);
79
+ for (const codepoint of codepoints.slice(0, MAX_CODEPOINTS)) hb.hb_set_add(set, codepoint);
80
+ subsetFace = hb.hb_subset_or_fail(face, input);
81
+ if (!subsetFace) throw new Error('the font could not be subset');
82
+ result = hb.hb_face_reference_blob(subsetFace);
83
+ const length = hb.hb_blob_get_length(result);
84
+ const pointer = hb.hb_blob_get_data(result, 0);
85
+ // Copied out: the heap may grow (and move) on the next call.
86
+ return Buffer.from(new Uint8Array(hb.memory.buffer, pointer, length));
87
+ } finally {
88
+ if (result) hb.hb_blob_destroy(result);
89
+ if (subsetFace) hb.hb_face_destroy(subsetFace);
90
+ hb.hb_subset_input_destroy(input);
91
+ this.scheduleRelease();
92
+ }
93
+ }
94
+
95
+ scheduleRelease() {
96
+ if (this.idleTimer) clearTimeout(this.idleTimer);
97
+ this.idleTimer = setTimeout(() => this.release(), this.idleMs);
98
+ this.idleTimer.unref?.();
99
+ }
100
+
101
+ /** Drop every loaded face and the instance holding them. */
102
+ release() {
103
+ if (this.idleTimer) clearTimeout(this.idleTimer);
104
+ this.idleTimer = null;
105
+ this.faces.clear();
106
+ this.instance = null;
107
+ }
108
+ }
109
+
110
+ module.exports = { FontSubsetter, MAX_CODEPOINTS };
@@ -24,8 +24,11 @@ const {
24
24
  packStreamFrameV2,
25
25
  FRAME_TYPE_OUTPUT,
26
26
  PROTOCOL_VERSION,
27
+ TERMINAL_FONT_CHUNK_BYTES,
27
28
  } = require('herdr-remote-relay/protocol');
28
29
  const { resolveHostPalette } = require('./terminal-palette');
30
+ const { loadHostTerminalFont, publicTerminalFont, readFontChunk } = require('./terminal-font');
31
+ const { FontSubsetter } = require('./font-subset');
29
32
  const { requestHerdr, subscribeHerdr } = require('./herdr-api');
30
33
  const { sameSummary, summarizeAgents } = require('./agent-status');
31
34
  const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
@@ -42,6 +45,9 @@ const {
42
45
  /** Windows opening within this long of a check reuse its answer. */
43
46
  const UPDATE_RECHECK_MS = 60_000;
44
47
 
48
+ /** Cut fonts kept for the slices a browser has yet to fetch, by total size. */
49
+ const FONT_SUBSET_CACHE_BYTES = 48 * 1024 * 1024;
50
+
45
51
  /** How often the agent summary is re-read while a browser is watching. */
46
52
  const AGENT_STATUS_POLL_MS = 5_000;
47
53
  const AGENT_STATUS_EVENT_DEBOUNCE_MS = 150;
@@ -125,6 +131,19 @@ class HostConnector {
125
131
  this.terminalPalette = options.terminalPalette !== undefined
126
132
  ? options.terminalPalette
127
133
  : resolveHostPalette();
134
+ /**
135
+ * The terminal's font, with the local files behind it. Only the family,
136
+ * size and file hashes leave this process; a browser that lacks the font
137
+ * fetches the files by hash, one chunk at a time. Injectable for tests.
138
+ */
139
+ this.loadTerminalFont = options.loadTerminalFont || loadHostTerminalFont;
140
+ this.terminalFont = options.terminalFont !== undefined
141
+ ? options.terminalFont
142
+ : this.loadTerminalFont();
143
+ /** Cuts characters out of large fonts; see font-subset.js. */
144
+ this.fontSubsetter = options.fontSubsetter || new FontSubsetter();
145
+ /** Subsets too large for one message, by hash, until fetched. */
146
+ this.fontSubsets = new Map();
128
147
  this.ws = null;
129
148
  this.sessions = new Map();
130
149
  this.streamIndexToId = new Map();
@@ -277,6 +296,7 @@ class HostConnector {
277
296
  platform: process.platform,
278
297
  arch: process.arch,
279
298
  terminalPalette: this.terminalPalette || null,
299
+ terminalFont: publicTerminalFont(this.terminalFont),
280
300
  capabilities: ['host_handoff', 'idle_heartbeat', 'binary_frame_v2'],
281
301
  });
282
302
  // The relay sends host_ready with the current browser count. No business
@@ -386,6 +406,127 @@ class HostConnector {
386
406
  if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
387
407
  else if (message.type === 'resize') this.resizeSession(message);
388
408
  else if (message.type === 'paste_file') this.handlePasteFile(message);
409
+ else if (message.type === 'host_font_chunk_request') this.handleFontChunkRequest(message);
410
+ else if (message.type === 'host_font_subset_request') this.handleFontSubsetRequest(message);
411
+ else if (message.type === 'host_font_refresh') this.handleFontRefresh();
412
+ }
413
+
414
+ /**
415
+ * A service started at boot runs before anything has seen a terminal, so it
416
+ * starts with no font. The first `herdr-remote start` from a terminal (or
417
+ * from Herdr's plugin hook) writes one down; the next window to open picks
418
+ * it up here instead of waiting for a restart.
419
+ */
420
+ pickUpTerminalFont() {
421
+ if (this.terminalFont) return;
422
+ let next = null;
423
+ try {
424
+ next = this.loadTerminalFont();
425
+ } catch {
426
+ return;
427
+ }
428
+ if (!next) return;
429
+ this.terminalFont = next;
430
+ sendJson(this.ws, { type: 'terminal_font', terminalFont: publicTerminalFont(next) });
431
+ }
432
+
433
+ /** One slice of a terminal font file, for the window that asked for it. */
434
+ handleFontChunkRequest(message) {
435
+ const streamId = message.clientId || message.streamId;
436
+ const chunk = this.subsetChunk(message.sha256, message.index)
437
+ || readFontChunk(this.terminalFont, message.sha256, message.index);
438
+ if (!chunk) {
439
+ sendJson(this.ws, {
440
+ type: 'error',
441
+ clientId: streamId,
442
+ code: 'host_font_unavailable',
443
+ message: 'The terminal font changed or is no longer on this workstation',
444
+ });
445
+ return;
446
+ }
447
+ sendJson(this.ws, {
448
+ type: 'host_font_chunk',
449
+ clientId: streamId,
450
+ sha256: message.sha256,
451
+ index: message.index,
452
+ total: chunk.total,
453
+ dataBase64: chunk.data.toString('base64'),
454
+ });
455
+ }
456
+
457
+ /** A slice of a cut font still held for the browser that asked for it. */
458
+ subsetChunk(sha256, index) {
459
+ const data = this.fontSubsets.get(sha256);
460
+ if (!data) return null;
461
+ const total = Math.ceil(data.length / TERMINAL_FONT_CHUNK_BYTES);
462
+ if (!Number.isInteger(index) || index < 0 || index >= total) return null;
463
+ const start = index * TERMINAL_FONT_CHUNK_BYTES;
464
+ return { data: data.subarray(start, start + TERMINAL_FONT_CHUNK_BYTES), total };
465
+ }
466
+
467
+ /**
468
+ * The characters a window is about to draw, cut out of a large font (CJK).
469
+ * A small result travels in the answer itself; a large one (the common
470
+ * characters fetched up front) is held here and pulled in slices.
471
+ */
472
+ handleFontSubsetRequest(message) {
473
+ const streamId = message.clientId || message.streamId;
474
+ const source = this.terminalFont?.subsets?.find((candidate) => candidate.sha256 === message.sha256);
475
+ const fail = (code, text) => sendJson(this.ws, { type: 'error', clientId: streamId, code, message: text });
476
+ if (!source) {
477
+ fail('host_font_unavailable', 'The terminal font changed or is no longer on this workstation');
478
+ return;
479
+ }
480
+ let stat;
481
+ try { stat = fs.statSync(source.path); } catch {}
482
+ if (!stat || stat.size !== source.bytes || stat.mtimeMs !== source.mtimeMs) {
483
+ fail('host_font_unavailable', 'The terminal font changed or is no longer on this workstation');
484
+ return;
485
+ }
486
+ const codepoints = [...new Set(Array.from(String(message.text || ''), (char) => char.codePointAt(0)))];
487
+ let data;
488
+ try {
489
+ data = this.fontSubsetter.subset(source, codepoints);
490
+ } catch (error) {
491
+ fail('host_font_subset_failed', error.message || 'The font could not be subset');
492
+ return;
493
+ }
494
+ const subsetSha = crypto.createHash('sha256').update(data).digest('hex');
495
+ const answer = {
496
+ type: 'host_font_subset_ready',
497
+ clientId: streamId,
498
+ requestId: message.requestId,
499
+ sha256: source.sha256,
500
+ subsetSha,
501
+ bytes: data.length,
502
+ };
503
+ if (data.length <= TERMINAL_FONT_CHUNK_BYTES) {
504
+ answer.dataBase64 = data.toString('base64');
505
+ } else {
506
+ this.fontSubsets.delete(subsetSha);
507
+ this.fontSubsets.set(subsetSha, data);
508
+ let held = [...this.fontSubsets.values()].reduce((sum, item) => sum + item.length, 0);
509
+ for (const [key, item] of this.fontSubsets) {
510
+ if (held <= FONT_SUBSET_CACHE_BYTES || key === subsetSha) break;
511
+ this.fontSubsets.delete(key);
512
+ held -= item.length;
513
+ }
514
+ }
515
+ sendJson(this.ws, answer);
516
+ }
517
+
518
+ /**
519
+ * Read the terminal's font settings again, at a browser's request. Every
520
+ * window of this workstation hears the answer, since they all draw with it.
521
+ */
522
+ handleFontRefresh() {
523
+ try {
524
+ const next = this.loadTerminalFont({ refresh: true });
525
+ if (next) this.terminalFont = next;
526
+ } catch (error) {
527
+ process.stderr.write(`herdr-remote host connector: could not read the terminal font: ${error.message}\n`);
528
+ }
529
+ sendJson(this.ws, { type: 'terminal_font', terminalFont: publicTerminalFont(this.terminalFont) });
389
530
  }
390
531
 
391
532
  /**
@@ -438,6 +579,7 @@ class HostConnector {
438
579
  this.stopSession(streamId);
439
580
  // A window opening is when a person is looking; that is when to ask.
440
581
  this.reportUpdateStatus();
582
+ this.pickUpTerminalFont();
441
583
  const missingHerdr = this.ensureHerdrCommand();
442
584
  if (missingHerdr) {
443
585
  process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
package/src/service.js CHANGED
@@ -24,6 +24,7 @@ const {
24
24
  rememberTerminalPalette,
25
25
  rememberedTerminalPalette,
26
26
  } = require('./terminal-palette');
27
+ const { resolveHostFont } = require('./terminal-font');
27
28
  const { resolveSocketPath } = require('./socket-discovery');
28
29
  const { preferredLanAddress } = require('./net-interfaces');
29
30
  const { MIN_HERDR_VERSION, findHerdrCommand, herdrVersion, resolveHerdrCommand } = require('./herdr-command');
@@ -120,6 +121,16 @@ function hostTerminalPalette({ refresh = false } = {}) {
120
121
  return cachedTerminalPalette;
121
122
  }
122
123
 
124
+ /**
125
+ * The terminal font a service start hands its connector: what this command
126
+ * detected at its entry point, else what an earlier start with a terminal
127
+ * wrote down. Only the family, size and terminal travel here; the connector
128
+ * finds the files itself, on the machine that has them.
129
+ */
130
+ function hostTerminalFont() {
131
+ return resolveHostFont();
132
+ }
133
+
123
134
  function logPath(name) {
124
135
  return path.join(stateDir(), `${name}.log`);
125
136
  }
@@ -175,6 +186,7 @@ function serviceSpecs(config = loadConfig(), state = ensureRuntime()) {
175
186
  }
176
187
 
177
188
  const terminalPalette = hostTerminalPalette();
189
+ const terminalFont = hostTerminalFont();
178
190
 
179
191
  specs.push({
180
192
  name: 'host',
@@ -184,6 +196,7 @@ function serviceSpecs(config = loadConfig(), state = ensureRuntime()) {
184
196
  // Captured here, where a terminal may still be attached, because the
185
197
  // connector itself usually runs detached with no terminal to ask.
186
198
  ...(terminalPalette ? { HERDR_TERM_PALETTE_JSON: JSON.stringify(terminalPalette) } : {}),
199
+ ...(terminalFont ? { HERDR_TERM_FONT_JSON: JSON.stringify(terminalFont) } : {}),
187
200
  RELAY_URL: resolveHostRelayUrl(config),
188
201
  RELAY_HOST_ID: state.hostId,
189
202
  RELAY_HOST_TOKEN: state.hostToken,
@@ -500,6 +513,7 @@ module.exports = {
500
513
  serviceSpecs,
501
514
  baseEnvironment,
502
515
  hostTerminalPalette,
516
+ hostTerminalFont,
503
517
  startServices,
504
518
  stopServices,
505
519
  restartServices,