herdr-remote 0.2.16 → 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 +1394 -86
- 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 +197 -0
- package/src/i18n/en.js +7 -1
- package/src/i18n/zh.js +7 -1
- package/src/service.js +14 -0
- package/src/terminal-font.js +1204 -0
- package/src/updater.js +203 -61
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,13 +24,29 @@ 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');
|
|
32
35
|
const { savePastedFile, cleanPastedDir } = require('./pasted-files');
|
|
33
36
|
const { ensureHerdrServer, probeHerdrServer } = require('./herdr-server');
|
|
37
|
+
const {
|
|
38
|
+
checkForUpdate,
|
|
39
|
+
compareVersions,
|
|
40
|
+
currentVersion,
|
|
41
|
+
installedVersionOnDisk,
|
|
42
|
+
updateChecksEnabled,
|
|
43
|
+
} = require('./updater');
|
|
44
|
+
|
|
45
|
+
/** Windows opening within this long of a check reuse its answer. */
|
|
46
|
+
const UPDATE_RECHECK_MS = 60_000;
|
|
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;
|
|
34
50
|
|
|
35
51
|
/** How often the agent summary is re-read while a browser is watching. */
|
|
36
52
|
const AGENT_STATUS_POLL_MS = 5_000;
|
|
@@ -115,6 +131,19 @@ class HostConnector {
|
|
|
115
131
|
this.terminalPalette = options.terminalPalette !== undefined
|
|
116
132
|
? options.terminalPalette
|
|
117
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();
|
|
118
147
|
this.ws = null;
|
|
119
148
|
this.sessions = new Map();
|
|
120
149
|
this.streamIndexToId = new Map();
|
|
@@ -129,6 +158,18 @@ class HostConnector {
|
|
|
129
158
|
this.probingStarts = new Map();
|
|
130
159
|
/** One start at a time, however many windows asked for it. */
|
|
131
160
|
this.herdrLaunch = null;
|
|
161
|
+
/**
|
|
162
|
+
* Whether a newer herdr-remote is out, asked when a window opens. The
|
|
163
|
+
* version this process runs is fixed at start; the one on disk moves when
|
|
164
|
+
* somebody updates without restarting.
|
|
165
|
+
*/
|
|
166
|
+
this.checkUpdate = options.checkUpdate !== undefined
|
|
167
|
+
? options.checkUpdate
|
|
168
|
+
: (updateChecksEnabled() ? checkForUpdate : null);
|
|
169
|
+
this.readInstalledVersion = options.readInstalledVersion || installedVersionOnDisk;
|
|
170
|
+
this.runningVersion = options.runningVersion || currentVersion();
|
|
171
|
+
this.updateCheckedAt = 0;
|
|
172
|
+
this.updateCheck = null;
|
|
132
173
|
/** The side channel that answers "does anything need me?"; see startAgentStatus. */
|
|
133
174
|
this.requestHerdr = options.requestHerdr || requestHerdr;
|
|
134
175
|
this.subscribeHerdr = options.subscribeHerdr || subscribeHerdr;
|
|
@@ -255,6 +296,7 @@ class HostConnector {
|
|
|
255
296
|
platform: process.platform,
|
|
256
297
|
arch: process.arch,
|
|
257
298
|
terminalPalette: this.terminalPalette || null,
|
|
299
|
+
terminalFont: publicTerminalFont(this.terminalFont),
|
|
258
300
|
capabilities: ['host_handoff', 'idle_heartbeat', 'binary_frame_v2'],
|
|
259
301
|
});
|
|
260
302
|
// The relay sends host_ready with the current browser count. No business
|
|
@@ -364,6 +406,127 @@ class HostConnector {
|
|
|
364
406
|
if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
|
|
365
407
|
else if (message.type === 'resize') this.resizeSession(message);
|
|
366
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) });
|
|
367
530
|
}
|
|
368
531
|
|
|
369
532
|
/**
|
|
@@ -414,6 +577,9 @@ class HostConnector {
|
|
|
414
577
|
const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
|
|
415
578
|
if (!streamId) return undefined;
|
|
416
579
|
this.stopSession(streamId);
|
|
580
|
+
// A window opening is when a person is looking; that is when to ask.
|
|
581
|
+
this.reportUpdateStatus();
|
|
582
|
+
this.pickUpTerminalFont();
|
|
417
583
|
const missingHerdr = this.ensureHerdrCommand();
|
|
418
584
|
if (missingHerdr) {
|
|
419
585
|
process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
|
|
@@ -445,6 +611,37 @@ class HostConnector {
|
|
|
445
611
|
});
|
|
446
612
|
}
|
|
447
613
|
|
|
614
|
+
/**
|
|
615
|
+
* Tell every window whether this workstation's herdr-remote is behind the
|
|
616
|
+
* newest release. Asked at most once a minute however many windows open;
|
|
617
|
+
* the relay keeps the last answer for windows that open in between. A failed
|
|
618
|
+
* check says nothing: offline is not news.
|
|
619
|
+
*/
|
|
620
|
+
reportUpdateStatus({ now = Date.now() } = {}) {
|
|
621
|
+
if (!this.checkUpdate || this.updateCheck) return this.updateCheck;
|
|
622
|
+
if (now - this.updateCheckedAt < UPDATE_RECHECK_MS) return null;
|
|
623
|
+
this.updateCheckedAt = now;
|
|
624
|
+
this.updateCheck = Promise.resolve()
|
|
625
|
+
.then(() => this.checkUpdate())
|
|
626
|
+
.then((result) => {
|
|
627
|
+
if (!result?.ok || !result.latest) return;
|
|
628
|
+
const running = this.runningVersion;
|
|
629
|
+
const installed = this.readInstalledVersion() || running;
|
|
630
|
+
sendJson(this.ws, {
|
|
631
|
+
type: 'update_status',
|
|
632
|
+
current: running,
|
|
633
|
+
installed,
|
|
634
|
+
latest: result.latest,
|
|
635
|
+
updateAvailable: compareVersions(result.latest, running) > 0,
|
|
636
|
+
// Updated on disk, still running the old code: a restart finishes it.
|
|
637
|
+
restartPending: compareVersions(installed, running) > 0,
|
|
638
|
+
});
|
|
639
|
+
})
|
|
640
|
+
.catch(() => {})
|
|
641
|
+
.finally(() => { this.updateCheck = null; });
|
|
642
|
+
return this.updateCheck;
|
|
643
|
+
}
|
|
644
|
+
|
|
448
645
|
/**
|
|
449
646
|
* Hold a window's session until Herdr runs, and ask the browser whether it
|
|
450
647
|
* should be started. Nothing starts until somebody says yes.
|
package/src/i18n/en.js
CHANGED
|
@@ -219,16 +219,22 @@ module.exports = {
|
|
|
219
219
|
'hint.save': 's save',
|
|
220
220
|
'hint.editing': '↵ confirm · esc cancel',
|
|
221
221
|
'update.title': 'Updates',
|
|
222
|
+
'update.banner': 'herdr-remote {latest} is out (running {current}). Press 7 and Enter on the update row to install.',
|
|
222
223
|
'update.check': 'Check for updates',
|
|
223
224
|
'update.checking': 'Checking npm…',
|
|
224
225
|
'update.upToDate': 'Up to date ({version})',
|
|
225
226
|
'update.available': 'Update {version} available (press Enter to install)',
|
|
226
227
|
'update.updating': 'Installing {version}…',
|
|
228
|
+
'update.updatingRetry': 'Installing {version}… (npm has not caught up yet, attempt {attempt})',
|
|
227
229
|
'update.done': 'Updated to {version}',
|
|
228
230
|
'update.restartHint': 'Update installed. Restart herdr-remote to apply.',
|
|
229
231
|
'update.errorNetwork': 'Could not reach any npm registry',
|
|
230
232
|
'update.errorNetworkDetail': 'No registry answered: {message}',
|
|
231
|
-
'update.errorFailed': 'Update failed. Run: npm install -g herdr-remote@
|
|
233
|
+
'update.errorFailed': 'Update failed. Run: npm install -g herdr-remote@{version} --prefer-online',
|
|
234
|
+
'update.errorFailedDetail': 'npm: {message}',
|
|
235
|
+
'update.errorNotYetPublished': 'npm has not caught up with {version} yet (common in the first minutes after a release). Press Enter to try again shortly.',
|
|
236
|
+
'update.errorNotApplied': 'npm reported success, but {installed} is still installed',
|
|
237
|
+
'update.mirrorBehind': '{registries} still has an older version; installing from {source}',
|
|
232
238
|
'update.cannot.source': 'Source checkout — update with git',
|
|
233
239
|
'update.cannot.linked': 'Linked package — managed by npm link',
|
|
234
240
|
};
|
package/src/i18n/zh.js
CHANGED
|
@@ -218,16 +218,22 @@ module.exports = {
|
|
|
218
218
|
'hint.save': 's 保存',
|
|
219
219
|
'hint.editing': '↵ 确认 · esc 取消',
|
|
220
220
|
'update.title': '更新',
|
|
221
|
+
'update.banner': 'herdr-remote {latest} 已发布(当前 {current}),按 7 进入「语言与关于」,在更新一行按 Enter 安装。',
|
|
221
222
|
'update.check': '检查更新',
|
|
222
223
|
'update.checking': '查询 npm 中…',
|
|
223
224
|
'update.upToDate': '已是最新({version})',
|
|
224
225
|
'update.available': '新版本 {version} 可用(按 Enter 安装)',
|
|
225
226
|
'update.updating': '安装 {version} 中…',
|
|
227
|
+
'update.updatingRetry': '安装 {version} 中…(npm 尚未同步,第 {attempt} 次尝试)',
|
|
226
228
|
'update.done': '已更新至 {version}',
|
|
227
229
|
'update.restartHint': '更新已安装,重启 herdr-remote 生效。',
|
|
228
230
|
'update.errorNetwork': '所有 npm registry 均无法连接',
|
|
229
231
|
'update.errorNetworkDetail': '无 registry 应答:{message}',
|
|
230
|
-
'update.errorFailed': '更新失败,可手动执行:npm install -g herdr-remote@
|
|
232
|
+
'update.errorFailed': '更新失败,可手动执行:npm install -g herdr-remote@{version} --prefer-online',
|
|
233
|
+
'update.errorFailedDetail': 'npm:{message}',
|
|
234
|
+
'update.errorNotYetPublished': 'npm 还没同步到 {version}(刚发布的几分钟内常见),稍后按 Enter 重试。',
|
|
235
|
+
'update.errorNotApplied': 'npm 报告成功,但安装的仍是 {installed}',
|
|
236
|
+
'update.mirrorBehind': '{registries} 仍是旧版本,将从 {source} 安装',
|
|
231
237
|
'update.cannot.source': '源码目录运行,请使用 git 更新',
|
|
232
238
|
'update.cannot.linked': 'npm link 工作副本,由 npm link 管理',
|
|
233
239
|
};
|
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,
|