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/bin/herdr-remote.js +4 -0
- package/dist/tui.mjs +1163 -1
- package/herdr-plugin.toml +1 -1
- package/package.json +3 -2
- package/src/binary-plist.js +146 -0
- package/src/font-subset.js +110 -0
- package/src/host-connector.js +142 -0
- package/src/service.js +14 -0
- package/src/terminal-font.js +1204 -0
package/herdr-plugin.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "herdr-remote",
|
|
3
|
-
"version": "0.2.
|
|
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
|
-
"
|
|
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 };
|
package/src/host-connector.js
CHANGED
|
@@ -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,
|