herdr-remote 0.2.17 → 0.2.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/herdr-remote.js +3 -221
- package/dist/agent-status.js +130 -0
- package/dist/binary-plist.js +157 -0
- package/dist/cli.js +255 -0
- package/dist/config.js +270 -0
- package/dist/connector/agent-watch.js +110 -0
- package/dist/connector/font-server.js +165 -0
- package/dist/connector/host-connector.js +430 -0
- package/dist/connector/lock.js +66 -0
- package/dist/connector/main.js +18 -0
- package/dist/connector/sessions.js +366 -0
- package/dist/connector/update-report.js +67 -0
- package/dist/connector/wire.js +42 -0
- package/{src → dist}/exit-codes.js +1 -4
- package/dist/font-subset.js +110 -0
- package/dist/herdr-api.js +246 -0
- package/dist/herdr-command.js +223 -0
- package/dist/herdr-plugin.js +116 -0
- package/dist/herdr-server.js +189 -0
- package/dist/i18n/en.js +223 -0
- package/dist/i18n/index.js +48 -0
- package/dist/i18n/zh.js +222 -0
- package/dist/keepalive/detached.js +78 -0
- package/dist/keepalive/environment.js +44 -0
- package/dist/keepalive/index.js +110 -0
- package/dist/keepalive/launchd.js +117 -0
- package/dist/keepalive/systemd.js +108 -0
- package/dist/keepalive/types.js +1 -0
- package/dist/lib/process.js +12 -0
- package/dist/lifecycle.js +59 -0
- package/dist/net-interfaces.js +73 -0
- package/dist/pasted-files.js +115 -0
- package/dist/paths.js +62 -0
- package/dist/pty-session.js +96 -0
- package/dist/relay-client.js +106 -0
- package/dist/relay-urls.js +99 -0
- package/dist/runtime.js +106 -0
- package/dist/service.js +260 -0
- package/dist/settings-model.js +298 -0
- package/dist/socket-discovery.js +48 -0
- package/dist/supervisor.js +319 -0
- package/dist/terminal-font/deps.js +73 -0
- package/dist/terminal-font/files.js +354 -0
- package/dist/terminal-font/formats.js +335 -0
- package/dist/terminal-font/index.js +187 -0
- package/dist/terminal-font/readers.js +316 -0
- package/dist/terminal-font/terminals.js +128 -0
- package/dist/terminal-palette.js +339 -0
- package/dist/tui/App.js +195 -0
- package/dist/tui/api.js +79 -0
- package/dist/tui/components/TextField.js +50 -0
- package/dist/tui/components/common.js +80 -0
- package/dist/tui/index.js +36 -0
- package/dist/tui/mouse/index.js +164 -0
- package/dist/tui/mouse/protocol.js +96 -0
- package/dist/tui/mouse/source.js +74 -0
- package/dist/tui/screens/About.js +166 -0
- package/dist/tui/screens/Herdr.js +74 -0
- package/dist/tui/screens/Keepalive.js +84 -0
- package/dist/tui/screens/Overview.js +36 -0
- package/dist/tui/screens/Pair.js +74 -0
- package/dist/tui/screens/Relay.js +242 -0
- package/dist/tui/screens/Services.js +63 -0
- package/dist/tui/screens/Wizard.js +235 -0
- package/dist/tui/terminal.js +39 -0
- package/dist/tui/theme.js +33 -0
- package/dist/updater.js +382 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +10 -9
- package/dist/tui.mjs +0 -4613
- package/src/agent-status.js +0 -139
- package/src/config.js +0 -454
- package/src/herdr-api.js +0 -244
- package/src/herdr-command.js +0 -231
- package/src/herdr-plugin.js +0 -130
- package/src/herdr-server.js +0 -201
- package/src/host-connector.js +0 -927
- package/src/i18n/en.js +0 -240
- package/src/i18n/index.js +0 -56
- package/src/i18n/zh.js +0 -239
- package/src/keepalive.js +0 -460
- package/src/lifecycle.js +0 -62
- package/src/net-interfaces.js +0 -76
- package/src/pasted-files.js +0 -176
- package/src/pty-session.js +0 -92
- package/src/service.js +0 -516
- package/src/settings-model.js +0 -323
- package/src/socket-discovery.js +0 -60
- package/src/state.js +0 -47
- package/src/supervisor.js +0 -250
- package/src/terminal-palette.js +0 -325
- package/src/updater.js +0 -394
package/bin/herdr-remote.js
CHANGED
|
@@ -1,224 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
// The `herdr-remote` command, and what herdr-plugin.toml runs.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
// With no arguments (and a real terminal) this opens the configuration TUI;
|
|
7
|
-
// the subcommands exist for scripting and for the actions declared in
|
|
8
|
-
// herdr-plugin.toml, which Herdr runs without a terminal attached.
|
|
4
|
+
import { run } from '../dist/cli.js';
|
|
9
5
|
|
|
10
|
-
|
|
11
|
-
const path = require('node:path');
|
|
12
|
-
const { pathToFileURL } = require('node:url');
|
|
13
|
-
|
|
14
|
-
const {
|
|
15
|
-
LANGUAGES,
|
|
16
|
-
configPath,
|
|
17
|
-
loadConfig,
|
|
18
|
-
migrateLegacyConfig,
|
|
19
|
-
resolvePublicUrl,
|
|
20
|
-
stateDir,
|
|
21
|
-
} = require('../src/config');
|
|
22
|
-
const { createTranslator, detectLocale } = require('../src/i18n');
|
|
23
|
-
const { preferredLanAddress } = require('../src/net-interfaces');
|
|
24
|
-
|
|
25
|
-
const VERSION = require('../package.json').version;
|
|
26
|
-
|
|
27
|
-
const USAGE = `herdr-remote ${VERSION} — browser access to Herdr workspaces
|
|
28
|
-
|
|
29
|
-
Usage: herdr-remote [command] [options]
|
|
30
|
-
|
|
31
|
-
Commands:
|
|
32
|
-
(none) Open the configuration TUI
|
|
33
|
-
start Start relay and host connector
|
|
34
|
-
stop Stop services
|
|
35
|
-
restart Restart services
|
|
36
|
-
status [--json] Show service status
|
|
37
|
-
pair [--json] Create pairing code
|
|
38
|
-
url Print browser access URL
|
|
39
|
-
run [--daemon] Run services in foreground (keep-alive)
|
|
40
|
-
keepalive <action> install | uninstall | restart | status
|
|
41
|
-
plugin <action> link | unlink | status (Herdr plugin)
|
|
42
|
-
|
|
43
|
-
Options:
|
|
44
|
-
--lang <zh|en> Interface language
|
|
45
|
-
--json JSON output
|
|
46
|
-
-h, --help Show help
|
|
47
|
-
-v, --version Show version
|
|
48
|
-
|
|
49
|
-
Self-hosting: docs/self-hosted-relay.md
|
|
50
|
-
`;
|
|
51
|
-
|
|
52
|
-
function parseArgs(argv) {
|
|
53
|
-
const positional = [];
|
|
54
|
-
const flags = {};
|
|
55
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
56
|
-
const arg = argv[index];
|
|
57
|
-
if (arg === '--json') { flags.json = true; continue; }
|
|
58
|
-
if (arg === '--daemon') { flags.daemon = true; continue; }
|
|
59
|
-
if (arg === '--help' || arg === '-h') { flags.help = true; continue; }
|
|
60
|
-
if (arg === '--version' || arg === '-v') { flags.version = true; continue; }
|
|
61
|
-
if (arg === '--lang') { flags.lang = argv[index + 1]; index += 1; continue; }
|
|
62
|
-
if (arg.startsWith('--lang=')) { flags.lang = arg.slice('--lang='.length); continue; }
|
|
63
|
-
if (arg.startsWith('-')) { flags.unknown = arg; continue; }
|
|
64
|
-
positional.push(arg);
|
|
65
|
-
}
|
|
66
|
-
return { positional, flags };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function printJson(value) {
|
|
70
|
-
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function describeStatus(status, t) {
|
|
74
|
-
const lines = [];
|
|
75
|
-
lines.push(`${t('overview.mode')}: ${t(`mode.${status.mode}`)}`);
|
|
76
|
-
if (status.relay.local) {
|
|
77
|
-
lines.push(`${t('overview.relay')}: ${status.relay.alive ? t('common.running') : t('common.stopped')} (${status.relay.bind}:${status.relay.port})`);
|
|
78
|
-
} else {
|
|
79
|
-
lines.push(`${t('overview.relay')}: ${status.relay.remoteUrl}`);
|
|
80
|
-
}
|
|
81
|
-
lines.push(`${t('overview.host')}: ${status.host.alive ? t('common.running') : t('common.stopped')}`);
|
|
82
|
-
lines.push(`${t('overview.socket')}: ${status.host.socketPath}${status.host.socketExists ? '' : ` (${t('overview.socketMissing')})`}`);
|
|
83
|
-
lines.push(`${t('overview.webUrl')}: ${status.publicUrl}`);
|
|
84
|
-
if (status.keepalive) {
|
|
85
|
-
const state = status.keepalive.active ? t('common.running') : status.keepalive.installed ? t('common.installed') : t('common.notInstalled');
|
|
86
|
-
lines.push(`${t('overview.keepalive')}: ${status.keepalive.manager} — ${state}`);
|
|
87
|
-
}
|
|
88
|
-
if (status.relay.health && status.relay.health.ok === false) {
|
|
89
|
-
lines.push(`${t('overview.relay')}: ${t('overview.unreachable', { message: status.relay.health.message })}`);
|
|
90
|
-
}
|
|
91
|
-
return lines.join('\n');
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async function runTui(options) {
|
|
95
|
-
// The TUI is an ESM bundle; import() keeps this entry point loadable on any
|
|
96
|
-
// Node 22 without relying on require(esm).
|
|
97
|
-
const bundle = path.join(__dirname, '..', 'dist', 'tui.mjs');
|
|
98
|
-
if (!fs.existsSync(bundle)) {
|
|
99
|
-
process.stderr.write(
|
|
100
|
-
'herdr-remote: TUI bundle missing. Build first:\n'
|
|
101
|
-
+ ' npm run build -w herdr-remote\n',
|
|
102
|
-
);
|
|
103
|
-
process.exitCode = 1;
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
const module = await import(pathToFileURL(bundle).href);
|
|
107
|
-
await module.startTui(options);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async function main(argv = process.argv.slice(2)) {
|
|
111
|
-
const { positional, flags } = parseArgs(argv);
|
|
112
|
-
const command = positional[0] || null;
|
|
113
|
-
|
|
114
|
-
migrateLegacyConfig();
|
|
115
|
-
|
|
116
|
-
// Ask the terminal for its colors while it is still a plain terminal: the
|
|
117
|
-
// TUI takes the screen a few lines below, and the services this command
|
|
118
|
-
// starts usually run detached with nothing to ask. Everything downstream
|
|
119
|
-
// inherits the answer through the environment.
|
|
120
|
-
require('../src/terminal-palette').captureTerminalPalette();
|
|
121
|
-
|
|
122
|
-
const config = loadConfig();
|
|
123
|
-
const preference = flags.lang && LANGUAGES.includes(flags.lang) ? flags.lang : config.ui.language;
|
|
124
|
-
const t = createTranslator(detectLocale({ preference }));
|
|
125
|
-
|
|
126
|
-
if (flags.help) { process.stdout.write(USAGE); return; }
|
|
127
|
-
if (flags.version) { process.stdout.write(`${VERSION}\n`); return; }
|
|
128
|
-
|
|
129
|
-
// Lazily required so that `--help` and the TUI do not pay for loading the
|
|
130
|
-
// service layer (which pulls in the relay package and node-pty).
|
|
131
|
-
const lifecycle = require('../src/lifecycle');
|
|
132
|
-
const service = require('../src/service');
|
|
133
|
-
|
|
134
|
-
switch (command) {
|
|
135
|
-
case null: {
|
|
136
|
-
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
137
|
-
await runTui({ language: flags.lang || null });
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
// Herdr runs plugin panes and actions without a TTY; emit status instead
|
|
141
|
-
// of trying to paint an interface into a pipe.
|
|
142
|
-
printJson(await lifecycle.fullStatus(config));
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
case 'start': {
|
|
146
|
-
const result = lifecycle.startAll(config);
|
|
147
|
-
if (flags.json) printJson(result);
|
|
148
|
-
else process.stdout.write(`${t('services.started')}\n${describeStatus(await lifecycle.fullStatus(config), t)}\n`);
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
case 'stop': {
|
|
152
|
-
const result = lifecycle.stopAll(config);
|
|
153
|
-
if (flags.json) printJson(result);
|
|
154
|
-
else process.stdout.write(`${t('services.stopped')}\n`);
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
case 'restart': {
|
|
158
|
-
const result = lifecycle.restartAll(config);
|
|
159
|
-
if (flags.json) printJson(result);
|
|
160
|
-
else process.stdout.write(`${t('services.restarted')}\n`);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
case 'status': {
|
|
164
|
-
const status = await lifecycle.fullStatus(config);
|
|
165
|
-
if (flags.json) printJson(status);
|
|
166
|
-
else process.stdout.write(`${describeStatus(status, t)}\n`);
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
case 'pair': {
|
|
170
|
-
const pairing = await service.pair();
|
|
171
|
-
if (flags.json) { printJson(pairing); return; }
|
|
172
|
-
const code = service.extractPairingCode(pairing);
|
|
173
|
-
const url = pairing.pairUrl || `${resolvePublicUrl(config, preferredLanAddress())}/?pairCode=${encodeURIComponent(code)}`;
|
|
174
|
-
const minutes = `${Math.round((config.auth.pairingTtlMs || 600000) / 60000)}m`;
|
|
175
|
-
process.stdout.write(`${t('pair.code')}: ${code}\n${t('pair.url')}: ${url}\n${t('pair.expires', { minutes, time: new Date(pairing.expiresAt).toLocaleTimeString() })}\n`);
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
case 'url': {
|
|
179
|
-
process.stdout.write(`${resolvePublicUrl(config, preferredLanAddress())}\n`);
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
case 'run': {
|
|
183
|
-
const { runForeground } = require('../src/supervisor');
|
|
184
|
-
await runForeground({ logToFiles: Boolean(flags.daemon) });
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
case 'keepalive': {
|
|
188
|
-
const keepalive = require('../src/keepalive');
|
|
189
|
-
const action = positional[1] || 'status';
|
|
190
|
-
if (action === 'install') printJson(keepalive.install(config));
|
|
191
|
-
else if (action === 'uninstall') printJson(keepalive.uninstall(config));
|
|
192
|
-
else if (action === 'restart') printJson(keepalive.restart(config));
|
|
193
|
-
else if (action === 'status') printJson(keepalive.status(config));
|
|
194
|
-
else throw new Error(`unknown keepalive action: ${action}`);
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
case 'plugin': {
|
|
198
|
-
const plugin = require('../src/herdr-plugin');
|
|
199
|
-
const action = positional[1] || 'status';
|
|
200
|
-
if (action === 'link') printJson(plugin.register());
|
|
201
|
-
else if (action === 'unlink') printJson(plugin.unregister());
|
|
202
|
-
else if (action === 'status') printJson(plugin.registrationStatus());
|
|
203
|
-
else throw new Error(`unknown plugin action: ${action}`);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
case 'config': {
|
|
207
|
-
// Handy when reporting a problem: where does this install read from?
|
|
208
|
-
printJson({ configPath: configPath(), stateDir: stateDir(), config });
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
default:
|
|
212
|
-
process.stderr.write(`herdr-remote: unknown command "${command}"\n\n${USAGE}`);
|
|
213
|
-
process.exitCode = 2;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (require.main === module) {
|
|
218
|
-
main().catch((error) => {
|
|
219
|
-
process.stderr.write(`herdr-remote: ${error.message}\n`);
|
|
220
|
-
process.exitCode = 1;
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
module.exports = { main, parseArgs, describeStatus, USAGE, VERSION };
|
|
6
|
+
run();
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the agents on this workstation are doing, small enough to put in a
|
|
3
|
+
* status bar.
|
|
4
|
+
*
|
|
5
|
+
* The question a phone actually has is "does anything need me?", and the
|
|
6
|
+
* terminal is a poor way to ask it: finding a blocked agent means navigating
|
|
7
|
+
* Herdr's sidebar through a viewport the width of a hand. Herdr already knows
|
|
8
|
+
* the answer — it classifies every pane as idle, working, blocked, done or
|
|
9
|
+
* unknown — so this turns a session snapshot into the two-line version.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately a pure function over a snapshot. The socket, the debounce and
|
|
12
|
+
* the retry live in the host connector; what counts as "needs me" lives here,
|
|
13
|
+
* where it can be tested without either.
|
|
14
|
+
*/
|
|
15
|
+
/** Herdr's own vocabulary, in the order a person cares about them. */
|
|
16
|
+
const AGENT_STATUSES = ['blocked', 'done', 'working', 'idle', 'unknown'];
|
|
17
|
+
/**
|
|
18
|
+
* How many agents travel with the summary.
|
|
19
|
+
*
|
|
20
|
+
* The counts are always exact; this caps only the named list, which exists so a
|
|
21
|
+
* future screen can say *which* agent is blocked. A workstation with more than
|
|
22
|
+
* this many live agents is not going to be read off a phone anyway.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_LISTED_AGENTS = 16;
|
|
25
|
+
/** Titles come from a remote program; keep them short and printable. */
|
|
26
|
+
const MAX_TITLE_LENGTH = 48;
|
|
27
|
+
function cleanText(value, limit = MAX_TITLE_LENGTH) {
|
|
28
|
+
if (typeof value !== 'string')
|
|
29
|
+
return null;
|
|
30
|
+
const collapsed = value
|
|
31
|
+
.replace(/[\u0000-\u001F\u007F-\u009F]/g, ' ')
|
|
32
|
+
.replace(/\s+/g, ' ')
|
|
33
|
+
.trim();
|
|
34
|
+
if (!collapsed)
|
|
35
|
+
return null;
|
|
36
|
+
return collapsed.length > limit ? collapsed.slice(0, limit) : collapsed;
|
|
37
|
+
}
|
|
38
|
+
function normalizeStatus(value) {
|
|
39
|
+
return AGENT_STATUSES.includes(value)
|
|
40
|
+
? value
|
|
41
|
+
: 'unknown';
|
|
42
|
+
}
|
|
43
|
+
function emptyCounts() {
|
|
44
|
+
return Object.fromEntries(AGENT_STATUSES.map((status) => [status, 0]));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Turn `session.snapshot` into the summary the browser receives.
|
|
48
|
+
*
|
|
49
|
+
* Sorted by how much each status wants attention, so the capped list keeps the
|
|
50
|
+
* agents worth naming rather than the first ones Herdr happened to report.
|
|
51
|
+
* `focusedPaneId` and `focusedAgent` also track the live pane selection,
|
|
52
|
+
* including shells and panes Herdr has not added to its agent summary.
|
|
53
|
+
*/
|
|
54
|
+
function summarizeAgents(snapshot) {
|
|
55
|
+
const entries = Array.isArray(snapshot?.agents) ? snapshot.agents : [];
|
|
56
|
+
const panes = Array.isArray(snapshot?.panes) ? snapshot.panes : [];
|
|
57
|
+
const reportedFocusId = typeof snapshot?.focused_pane_id === 'string' && snapshot.focused_pane_id
|
|
58
|
+
? snapshot.focused_pane_id
|
|
59
|
+
: null;
|
|
60
|
+
// `focused_pane_id` is the primary signal in Herdr snapshots. The focused
|
|
61
|
+
// flags are a fallback for snapshots where that top-level field is absent.
|
|
62
|
+
const focusedPane = (reportedFocusId
|
|
63
|
+
? panes.find((pane) => pane?.pane_id === reportedFocusId)
|
|
64
|
+
: panes.find((pane) => pane?.focused === true)) || null;
|
|
65
|
+
const focusedAgentEntry = (reportedFocusId
|
|
66
|
+
? entries.find((entry) => entry?.pane_id === reportedFocusId)
|
|
67
|
+
: entries.find((entry) => entry?.focused === true)) || null;
|
|
68
|
+
const focusedPaneId = reportedFocusId ||
|
|
69
|
+
(typeof focusedPane?.pane_id === 'string' ? focusedPane.pane_id : null) ||
|
|
70
|
+
(typeof focusedAgentEntry?.pane_id === 'string' ? focusedAgentEntry.pane_id : null);
|
|
71
|
+
const focusedAgent = typeof focusedPane?.agent === 'string'
|
|
72
|
+
? focusedPane.agent
|
|
73
|
+
: typeof focusedAgentEntry?.agent === 'string'
|
|
74
|
+
? focusedAgentEntry.agent
|
|
75
|
+
: null;
|
|
76
|
+
const counts = emptyCounts();
|
|
77
|
+
const ranked = [];
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (!entry || typeof entry.pane_id !== 'string')
|
|
80
|
+
continue;
|
|
81
|
+
const status = normalizeStatus(entry.agent_status);
|
|
82
|
+
counts[status] += 1;
|
|
83
|
+
ranked.push({
|
|
84
|
+
paneId: entry.pane_id,
|
|
85
|
+
workspaceId: typeof entry.workspace_id === 'string' ? entry.workspace_id : null,
|
|
86
|
+
agent: cleanText(entry.display_agent || entry.agent, 24),
|
|
87
|
+
title: cleanText(entry.terminal_title_stripped || entry.terminal_title),
|
|
88
|
+
status,
|
|
89
|
+
focused: entry.focused === true,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
ranked.sort((left, right) => AGENT_STATUSES.indexOf(left.status) - AGENT_STATUSES.indexOf(right.status));
|
|
93
|
+
return {
|
|
94
|
+
focusedPaneId,
|
|
95
|
+
focusedAgent,
|
|
96
|
+
counts,
|
|
97
|
+
// Total rather than a sum the browser has to compute, and the one number a
|
|
98
|
+
// collapsed chip can show on its own.
|
|
99
|
+
total: ranked.length,
|
|
100
|
+
agents: ranked.slice(0, MAX_LISTED_AGENTS),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** Whether two summaries say the same thing, so an unchanged one is not sent. */
|
|
104
|
+
function sameSummary(left, right) {
|
|
105
|
+
if (!left || !right)
|
|
106
|
+
return left === right;
|
|
107
|
+
if (left.focusedPaneId !== right.focusedPaneId || left.focusedAgent !== right.focusedAgent)
|
|
108
|
+
return false;
|
|
109
|
+
if (left.total !== right.total)
|
|
110
|
+
return false;
|
|
111
|
+
for (const status of AGENT_STATUSES) {
|
|
112
|
+
if ((left.counts?.[status] || 0) !== (right.counts?.[status] || 0))
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (left.agents.length !== right.agents.length)
|
|
116
|
+
return false;
|
|
117
|
+
return left.agents.every((agent, index) => {
|
|
118
|
+
const other = right.agents[index];
|
|
119
|
+
return (other &&
|
|
120
|
+
agent.paneId === other.paneId &&
|
|
121
|
+
agent.status === other.status &&
|
|
122
|
+
agent.agent === other.agent &&
|
|
123
|
+
agent.title === other.title);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** The summary of a workstation with nothing to report, or nothing to ask. */
|
|
127
|
+
function emptySummary() {
|
|
128
|
+
return { focusedPaneId: null, focusedAgent: null, counts: emptyCounts(), total: 0, agents: [] };
|
|
129
|
+
}
|
|
130
|
+
export { AGENT_STATUSES, emptySummary, sameSummary, summarizeAgents };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads Apple binary property lists (`bplist00`), the format macOS writes its
|
|
3
|
+
* preference files in, and the NSKeyedArchiver graphs stored inside them.
|
|
4
|
+
*
|
|
5
|
+
* Terminal.app keeps each profile's font as an archived NSFont — a binary
|
|
6
|
+
* plist nested in a binary plist — and there is no command-line tool that
|
|
7
|
+
* turns that into something readable without asking Terminal itself (an
|
|
8
|
+
* AppleScript that prompts for Automation access). A reader this small is
|
|
9
|
+
* less intrusive than any of those.
|
|
10
|
+
*/
|
|
11
|
+
/** A reference to another object in a keyed archive (`CF$UID`). */
|
|
12
|
+
class Uid {
|
|
13
|
+
value;
|
|
14
|
+
constructor(value) {
|
|
15
|
+
this.value = value;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function readSizedInt(buffer, offset, size) {
|
|
19
|
+
let value = 0;
|
|
20
|
+
for (let i = 0; i < size; i += 1)
|
|
21
|
+
value = value * 256 + buffer[offset + i];
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Parses a whole `bplist00` buffer into plain values: objects, arrays,
|
|
26
|
+
* strings, numbers, booleans, Buffers (data), Dates and `Uid`s. Throws on
|
|
27
|
+
* anything malformed; callers treat that as "no answer".
|
|
28
|
+
*/
|
|
29
|
+
function parseBinaryPlist(buffer) {
|
|
30
|
+
if (!Buffer.isBuffer(buffer) ||
|
|
31
|
+
buffer.length < 40 ||
|
|
32
|
+
buffer.toString('latin1', 0, 8) !== 'bplist00') {
|
|
33
|
+
throw new Error('not a binary plist');
|
|
34
|
+
}
|
|
35
|
+
const trailer = buffer.length - 32;
|
|
36
|
+
const offsetSize = buffer[trailer + 6];
|
|
37
|
+
const refSize = buffer[trailer + 7];
|
|
38
|
+
const count = readSizedInt(buffer, trailer + 8, 8);
|
|
39
|
+
const top = readSizedInt(buffer, trailer + 16, 8);
|
|
40
|
+
const tableOffset = readSizedInt(buffer, trailer + 24, 8);
|
|
41
|
+
if (!offsetSize || !refSize || count > 1_000_000 || tableOffset + count * offsetSize > trailer) {
|
|
42
|
+
throw new Error('corrupt binary plist trailer');
|
|
43
|
+
}
|
|
44
|
+
const offsets = [];
|
|
45
|
+
for (let i = 0; i < count; i += 1)
|
|
46
|
+
offsets.push(readSizedInt(buffer, tableOffset + i * offsetSize, offsetSize));
|
|
47
|
+
const parsing = new Set();
|
|
48
|
+
const parse = (index) => {
|
|
49
|
+
if (index >= count || parsing.has(index))
|
|
50
|
+
throw new Error('corrupt binary plist reference');
|
|
51
|
+
parsing.add(index);
|
|
52
|
+
try {
|
|
53
|
+
return parseObject(offsets[index]);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
parsing.delete(index);
|
|
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)
|
|
63
|
+
return { length: low, start: offset + 1 };
|
|
64
|
+
const marker = buffer[offset + 1];
|
|
65
|
+
if ((marker & 0xf0) !== 0x10)
|
|
66
|
+
throw new Error('corrupt binary plist length');
|
|
67
|
+
const size = 1 << (marker & 0x0f);
|
|
68
|
+
return { length: readSizedInt(buffer, offset + 2, size), start: offset + 2 + size };
|
|
69
|
+
};
|
|
70
|
+
const parseObject = (offset) => {
|
|
71
|
+
const marker = buffer[offset];
|
|
72
|
+
const type = marker >> 4;
|
|
73
|
+
const low = marker & 0x0f;
|
|
74
|
+
switch (type) {
|
|
75
|
+
case 0x0:
|
|
76
|
+
if (marker === 0x08)
|
|
77
|
+
return false;
|
|
78
|
+
if (marker === 0x09)
|
|
79
|
+
return true;
|
|
80
|
+
return null;
|
|
81
|
+
case 0x1: {
|
|
82
|
+
const size = 1 << low;
|
|
83
|
+
if (size === 8)
|
|
84
|
+
return Number(buffer.readBigInt64BE(offset + 1));
|
|
85
|
+
return readSizedInt(buffer, offset + 1, size);
|
|
86
|
+
}
|
|
87
|
+
case 0x2:
|
|
88
|
+
return low === 2 ? buffer.readFloatBE(offset + 1) : buffer.readDoubleBE(offset + 1);
|
|
89
|
+
case 0x3:
|
|
90
|
+
return new Date(Date.UTC(2001, 0, 1) + buffer.readDoubleBE(offset + 1) * 1000);
|
|
91
|
+
case 0x4: {
|
|
92
|
+
const { length, start } = lengthAt(offset);
|
|
93
|
+
return buffer.subarray(start, start + length);
|
|
94
|
+
}
|
|
95
|
+
case 0x5: {
|
|
96
|
+
const { length, start } = lengthAt(offset);
|
|
97
|
+
return buffer.toString('latin1', start, start + length);
|
|
98
|
+
}
|
|
99
|
+
case 0x6: {
|
|
100
|
+
const { length, start } = lengthAt(offset);
|
|
101
|
+
return Buffer.from(buffer.subarray(start, start + length * 2))
|
|
102
|
+
.swap16()
|
|
103
|
+
.toString('utf16le');
|
|
104
|
+
}
|
|
105
|
+
case 0x8:
|
|
106
|
+
return new Uid(readSizedInt(buffer, offset + 1, low + 1));
|
|
107
|
+
case 0xa:
|
|
108
|
+
case 0xc: {
|
|
109
|
+
const { length, start } = lengthAt(offset);
|
|
110
|
+
const items = [];
|
|
111
|
+
for (let i = 0; i < length; i += 1)
|
|
112
|
+
items.push(parse(readSizedInt(buffer, start + i * refSize, refSize)));
|
|
113
|
+
return items;
|
|
114
|
+
}
|
|
115
|
+
case 0xd: {
|
|
116
|
+
const { length, start } = lengthAt(offset);
|
|
117
|
+
const result = {};
|
|
118
|
+
for (let i = 0; i < length; i += 1) {
|
|
119
|
+
const key = parse(readSizedInt(buffer, start + i * refSize, refSize));
|
|
120
|
+
result[String(key)] = parse(readSizedInt(buffer, start + (length + i) * refSize, refSize));
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
default:
|
|
125
|
+
throw new Error(`unsupported binary plist object 0x${marker.toString(16)}`);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
return parse(top);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The root object of an NSKeyedArchiver plist, with every `Uid` replaced by
|
|
132
|
+
* the object it points at (to a bounded depth; archives may be cyclic).
|
|
133
|
+
*/
|
|
134
|
+
function unarchiveKeyed(archive, depth = 6) {
|
|
135
|
+
const record = (value) => value && typeof value === 'object' && !Array.isArray(value)
|
|
136
|
+
? value
|
|
137
|
+
: undefined;
|
|
138
|
+
const objects = record(archive)?.$objects;
|
|
139
|
+
const rootRef = record(record(archive)?.$top)?.root;
|
|
140
|
+
if (!Array.isArray(objects) || !(rootRef instanceof Uid))
|
|
141
|
+
throw new Error('not a keyed archive');
|
|
142
|
+
const resolve = (value, level) => {
|
|
143
|
+
if (value instanceof Uid)
|
|
144
|
+
return level > depth ? null : resolve(objects[value.value], level + 1);
|
|
145
|
+
if (Array.isArray(value))
|
|
146
|
+
return value.map((item) => resolve(item, level));
|
|
147
|
+
if (value && typeof value === 'object' && !Buffer.isBuffer(value) && !(value instanceof Date)) {
|
|
148
|
+
const result = {};
|
|
149
|
+
for (const [key, item] of Object.entries(value))
|
|
150
|
+
result[key] = resolve(item, level);
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
return value === '$null' ? null : value;
|
|
154
|
+
};
|
|
155
|
+
return resolve(rootRef, 0);
|
|
156
|
+
}
|
|
157
|
+
export { parseBinaryPlist, unarchiveKeyed };
|