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/src/agent-status.js
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* What the agents on this workstation are doing, small enough to put in a
|
|
5
|
-
* status bar.
|
|
6
|
-
*
|
|
7
|
-
* The question a phone actually has is "does anything need me?", and the
|
|
8
|
-
* terminal is a poor way to ask it: finding a blocked agent means navigating
|
|
9
|
-
* Herdr's sidebar through a viewport the width of a hand. Herdr already knows
|
|
10
|
-
* the answer — it classifies every pane as idle, working, blocked, done or
|
|
11
|
-
* unknown — so this turns a session snapshot into the two-line version.
|
|
12
|
-
*
|
|
13
|
-
* Deliberately a pure function over a snapshot. The socket, the debounce and
|
|
14
|
-
* the retry live in the host connector; what counts as "needs me" lives here,
|
|
15
|
-
* where it can be tested without either.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/** Herdr's own vocabulary, in the order a person cares about them. */
|
|
19
|
-
const AGENT_STATUSES = ['blocked', 'done', 'working', 'idle', 'unknown'];
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* How many agents travel with the summary.
|
|
23
|
-
*
|
|
24
|
-
* The counts are always exact; this caps only the named list, which exists so a
|
|
25
|
-
* future screen can say *which* agent is blocked. A workstation with more than
|
|
26
|
-
* this many live agents is not going to be read off a phone anyway.
|
|
27
|
-
*/
|
|
28
|
-
const MAX_LISTED_AGENTS = 16;
|
|
29
|
-
|
|
30
|
-
/** Titles come from a remote program; keep them short and printable. */
|
|
31
|
-
const MAX_TITLE_LENGTH = 48;
|
|
32
|
-
|
|
33
|
-
function cleanText(value, limit = MAX_TITLE_LENGTH) {
|
|
34
|
-
if (typeof value !== 'string') return null;
|
|
35
|
-
// eslint-disable-next-line no-control-regex
|
|
36
|
-
const collapsed = value.replace(/[\u0000-\u001F\u007F-\u009F]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
37
|
-
if (!collapsed) return null;
|
|
38
|
-
return collapsed.length > limit ? collapsed.slice(0, limit) : collapsed;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function normalizeStatus(value) {
|
|
42
|
-
return AGENT_STATUSES.includes(value) ? value : 'unknown';
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function emptyCounts() {
|
|
46
|
-
return Object.fromEntries(AGENT_STATUSES.map((status) => [status, 0]));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Turn `session.snapshot` into the summary the browser receives.
|
|
51
|
-
*
|
|
52
|
-
* Sorted by how much each status wants attention, so the capped list keeps the
|
|
53
|
-
* agents worth naming rather than the first ones Herdr happened to report.
|
|
54
|
-
* `focusedPaneId` and `focusedAgent` also track the live pane selection,
|
|
55
|
-
* including shells and panes Herdr has not added to its agent summary.
|
|
56
|
-
*/
|
|
57
|
-
function summarizeAgents(snapshot) {
|
|
58
|
-
const entries = Array.isArray(snapshot?.agents) ? snapshot.agents : [];
|
|
59
|
-
const panes = Array.isArray(snapshot?.panes) ? snapshot.panes : [];
|
|
60
|
-
const reportedFocusId = typeof snapshot?.focused_pane_id === 'string' && snapshot.focused_pane_id
|
|
61
|
-
? snapshot.focused_pane_id
|
|
62
|
-
: null;
|
|
63
|
-
// `focused_pane_id` is the primary signal in Herdr snapshots. The focused
|
|
64
|
-
// flags are a fallback for snapshots where that top-level field is absent.
|
|
65
|
-
const focusedPane = (reportedFocusId
|
|
66
|
-
? panes.find((pane) => pane?.pane_id === reportedFocusId)
|
|
67
|
-
: panes.find((pane) => pane?.focused === true)) || null;
|
|
68
|
-
const focusedAgentEntry = (reportedFocusId
|
|
69
|
-
? entries.find((entry) => entry?.pane_id === reportedFocusId)
|
|
70
|
-
: entries.find((entry) => entry?.focused === true)) || null;
|
|
71
|
-
const focusedPaneId = reportedFocusId
|
|
72
|
-
|| (typeof focusedPane?.pane_id === 'string' ? focusedPane.pane_id : null)
|
|
73
|
-
|| (typeof focusedAgentEntry?.pane_id === 'string' ? focusedAgentEntry.pane_id : null);
|
|
74
|
-
const focusedAgent = typeof focusedPane?.agent === 'string'
|
|
75
|
-
? focusedPane.agent
|
|
76
|
-
: typeof focusedAgentEntry?.agent === 'string'
|
|
77
|
-
? focusedAgentEntry.agent
|
|
78
|
-
: null;
|
|
79
|
-
const counts = emptyCounts();
|
|
80
|
-
const ranked = [];
|
|
81
|
-
|
|
82
|
-
for (const entry of entries) {
|
|
83
|
-
if (!entry || typeof entry.pane_id !== 'string') continue;
|
|
84
|
-
const status = normalizeStatus(entry.agent_status);
|
|
85
|
-
counts[status] += 1;
|
|
86
|
-
ranked.push({
|
|
87
|
-
paneId: entry.pane_id,
|
|
88
|
-
workspaceId: typeof entry.workspace_id === 'string' ? entry.workspace_id : null,
|
|
89
|
-
agent: cleanText(entry.display_agent || entry.agent, 24),
|
|
90
|
-
title: cleanText(entry.terminal_title_stripped || entry.terminal_title),
|
|
91
|
-
status,
|
|
92
|
-
focused: entry.focused === true,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
ranked.sort((left, right) => AGENT_STATUSES.indexOf(left.status) - AGENT_STATUSES.indexOf(right.status));
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
focusedPaneId,
|
|
100
|
-
focusedAgent,
|
|
101
|
-
counts,
|
|
102
|
-
// Total rather than a sum the browser has to compute, and the one number a
|
|
103
|
-
// collapsed chip can show on its own.
|
|
104
|
-
total: ranked.length,
|
|
105
|
-
agents: ranked.slice(0, MAX_LISTED_AGENTS),
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Whether two summaries say the same thing, so an unchanged one is not sent. */
|
|
110
|
-
function sameSummary(left, right) {
|
|
111
|
-
if (!left || !right) return left === right;
|
|
112
|
-
if (left.focusedPaneId !== right.focusedPaneId || left.focusedAgent !== right.focusedAgent) return false;
|
|
113
|
-
if (left.total !== right.total) return false;
|
|
114
|
-
for (const status of AGENT_STATUSES) {
|
|
115
|
-
if ((left.counts?.[status] || 0) !== (right.counts?.[status] || 0)) return false;
|
|
116
|
-
}
|
|
117
|
-
if (left.agents.length !== right.agents.length) return false;
|
|
118
|
-
return left.agents.every((agent, index) => {
|
|
119
|
-
const other = right.agents[index];
|
|
120
|
-
return other
|
|
121
|
-
&& agent.paneId === other.paneId
|
|
122
|
-
&& agent.status === other.status
|
|
123
|
-
&& agent.agent === other.agent
|
|
124
|
-
&& agent.title === other.title;
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** The summary of a workstation with nothing to report, or nothing to ask. */
|
|
129
|
-
function emptySummary() {
|
|
130
|
-
return { focusedPaneId: null, focusedAgent: null, counts: emptyCounts(), total: 0, agents: [] };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
module.exports = {
|
|
134
|
-
AGENT_STATUSES,
|
|
135
|
-
MAX_LISTED_AGENTS,
|
|
136
|
-
emptySummary,
|
|
137
|
-
sameSummary,
|
|
138
|
-
summarizeAgents,
|
|
139
|
-
};
|
package/src/config.js
DELETED
|
@@ -1,454 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('node:fs');
|
|
4
|
-
const os = require('node:os');
|
|
5
|
-
const path = require('node:path');
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Locate the package directory by walking up to our own package.json.
|
|
9
|
-
*
|
|
10
|
-
* `path.resolve(__dirname, '..')` was wrong as soon as this module started
|
|
11
|
-
* being bundled into dist/tui.js, because the bundle sits at a different depth
|
|
12
|
-
* than src/. Everything that matters — the plugin manifest, the host connector
|
|
13
|
-
* entry point, the systemd unit's ExecStart — is resolved from this value, so
|
|
14
|
-
* it has to be independent of where the code happens to be loaded from.
|
|
15
|
-
*/
|
|
16
|
-
function findPackageRoot(start) {
|
|
17
|
-
let directory = start;
|
|
18
|
-
for (let depth = 0; depth < 8; depth += 1) {
|
|
19
|
-
const candidate = path.join(directory, 'package.json');
|
|
20
|
-
try {
|
|
21
|
-
if (JSON.parse(fs.readFileSync(candidate, 'utf8')).name === 'herdr-remote') return directory;
|
|
22
|
-
} catch {
|
|
23
|
-
// Keep climbing: a missing or unrelated package.json is expected.
|
|
24
|
-
}
|
|
25
|
-
const parent = path.dirname(directory);
|
|
26
|
-
if (parent === directory) break;
|
|
27
|
-
directory = parent;
|
|
28
|
-
}
|
|
29
|
-
return path.resolve(start, '..');
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const PACKAGE_ROOT = findPackageRoot(__dirname);
|
|
33
|
-
|
|
34
|
-
// Access modes
|
|
35
|
-
// local — a relay runs on this machine, reachable only from this machine.
|
|
36
|
-
// lan — a relay runs on this machine, bound to every interface so other
|
|
37
|
-
// devices on the LAN (or on a Tailscale/WireGuard overlay) reach it.
|
|
38
|
-
// remote — no local relay; the host connector dials an operator-run relay,
|
|
39
|
-
// which is the only way in from outside the local network.
|
|
40
|
-
const ACCESS_MODES = ['local', 'lan', 'remote'];
|
|
41
|
-
|
|
42
|
-
// The relay this project runs for people who do not want to host one. Offered
|
|
43
|
-
// as a one-keystroke choice during setup; it is never selected automatically,
|
|
44
|
-
// because routing a terminal through someone else's server has to be a
|
|
45
|
-
// deliberate decision. It is a plain public relay: no join password, and each
|
|
46
|
-
// workstation stays reachable only through its own host token.
|
|
47
|
-
const OFFICIAL_RELAY_URL = 'wss://herdr-remote.564616.xyz';
|
|
48
|
-
const LANGUAGES = ['auto', 'zh', 'en'];
|
|
49
|
-
const KEEPALIVE_MANAGERS = ['auto', 'systemd', 'launchd', 'supervisor', 'none'];
|
|
50
|
-
|
|
51
|
-
const DEFAULTS = {
|
|
52
|
-
ui: {
|
|
53
|
-
language: 'auto',
|
|
54
|
-
},
|
|
55
|
-
relay: {
|
|
56
|
-
mode: 'local',
|
|
57
|
-
port: 8787,
|
|
58
|
-
// Address advertised in pairing URLs when mode is "lan". Empty means "pick
|
|
59
|
-
// the first non-internal IPv4 automatically".
|
|
60
|
-
lanHost: '',
|
|
61
|
-
// Manual override for the URL browsers open. Empty means "derive it".
|
|
62
|
-
publicUrl: '',
|
|
63
|
-
// Operator-run relay, e.g. wss://herdr.example.com (mode "remote" only).
|
|
64
|
-
remoteUrl: '',
|
|
65
|
-
// Kept in step with the relay package's own default: the CLI starts a
|
|
66
|
-
// relay of its own, and a smaller ceiling here would reject an upload the
|
|
67
|
-
// hosted relay accepts.
|
|
68
|
-
maxPayloadBytes: 5 * 1024 * 1024,
|
|
69
|
-
maxClientsPerHost: 16,
|
|
70
|
-
maxHosts: 1024,
|
|
71
|
-
maxPendingHandshakes: 1024,
|
|
72
|
-
maxBufferedBytesPerClient: 4 * 1024 * 1024,
|
|
73
|
-
hostReconnectGraceMs: 30 * 1000,
|
|
74
|
-
allowedOrigins: [],
|
|
75
|
-
},
|
|
76
|
-
herdr: {
|
|
77
|
-
socketPath: null,
|
|
78
|
-
args: [],
|
|
79
|
-
cwd: os.homedir(),
|
|
80
|
-
// Start Herdr's server when herdr-remote starts (at boot, under keep-alive).
|
|
81
|
-
// Off: Herdr starts when the user runs it, or confirms it from the WebUI.
|
|
82
|
-
autoStart: false,
|
|
83
|
-
},
|
|
84
|
-
auth: {
|
|
85
|
-
pairingTtlMs: 10 * 60 * 1000,
|
|
86
|
-
deviceTtlMs: 30 * 24 * 60 * 60 * 1000,
|
|
87
|
-
maxDevices: 32,
|
|
88
|
-
},
|
|
89
|
-
cleanup: {
|
|
90
|
-
intervalMs: 60 * 1000,
|
|
91
|
-
heartbeatIntervalMs: 30 * 1000,
|
|
92
|
-
staleAfterMs: 90 * 1000,
|
|
93
|
-
},
|
|
94
|
-
keepalive: {
|
|
95
|
-
manager: 'auto',
|
|
96
|
-
},
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
function clone(value) {
|
|
100
|
-
return JSON.parse(JSON.stringify(value));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* The canonical configuration directory.
|
|
105
|
-
*
|
|
106
|
-
* Deliberately NOT $HERDR_PLUGIN_CONFIG_DIR: Herdr sets that variable only when
|
|
107
|
-
* it launches the plugin itself, so honouring it would give "herdr-remote" run
|
|
108
|
-
* from a shell and the same tool run from a Herdr pane two different config
|
|
109
|
-
* files. One path, one config; `migrateLegacyConfig()` imports the old one.
|
|
110
|
-
*/
|
|
111
|
-
function configDir() {
|
|
112
|
-
return process.env.HERDR_REMOTE_CONFIG_DIR || path.join(os.homedir(), '.config', 'herdr-remote');
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function stateDir() {
|
|
116
|
-
return process.env.HERDR_REMOTE_STATE_DIR || path.join(os.homedir(), '.local', 'state', 'herdr-remote');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function configPath() {
|
|
120
|
-
return path.join(configDir(), 'config.json');
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function runtimeStatePath() {
|
|
124
|
-
return path.join(stateDir(), 'runtime.json');
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function legacyConfigPath() {
|
|
128
|
-
return process.env.HERDR_PLUGIN_CONFIG_DIR
|
|
129
|
-
? path.join(process.env.HERDR_PLUGIN_CONFIG_DIR, 'config.json')
|
|
130
|
-
: null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function readJson(filePath) {
|
|
134
|
-
try {
|
|
135
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
136
|
-
} catch (error) {
|
|
137
|
-
if (error.code !== 'ENOENT') {
|
|
138
|
-
process.stderr.write(`herdr-remote: ignoring invalid JSON at ${filePath}: ${error.message}\n`);
|
|
139
|
-
}
|
|
140
|
-
return {};
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Copy a config written by an older, plugin-scoped install into the canonical
|
|
146
|
-
* location. Runs at most once: it never overwrites an existing config.
|
|
147
|
-
*/
|
|
148
|
-
function migrateLegacyConfig() {
|
|
149
|
-
const target = configPath();
|
|
150
|
-
if (fs.existsSync(target)) return { migrated: false, reason: 'config already exists' };
|
|
151
|
-
const legacy = legacyConfigPath();
|
|
152
|
-
if (!legacy || !fs.existsSync(legacy)) return { migrated: false, reason: 'no legacy config' };
|
|
153
|
-
try {
|
|
154
|
-
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
155
|
-
fs.copyFileSync(legacy, target);
|
|
156
|
-
return { migrated: true, from: legacy, to: target };
|
|
157
|
-
} catch (error) {
|
|
158
|
-
return { migrated: false, reason: error.message };
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function mergeConfig(fileConfig) {
|
|
163
|
-
const config = clone(DEFAULTS);
|
|
164
|
-
for (const section of Object.keys(config)) {
|
|
165
|
-
if (fileConfig && fileConfig[section] && typeof fileConfig[section] === 'object') {
|
|
166
|
-
Object.assign(config[section], fileConfig[section]);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return config;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Accept configs written before access modes existed. The 0.1 schema used
|
|
174
|
-
* `relay.local` (boolean), `relay.host` (bind address) and `relay.url`.
|
|
175
|
-
*/
|
|
176
|
-
function normalizeLegacyFields(config, fileConfig) {
|
|
177
|
-
const legacy = (fileConfig && fileConfig.relay) || {};
|
|
178
|
-
// The absence of an explicit `mode` — not an invalid one — is what marks a
|
|
179
|
-
// file as pre-0.2, since merging defaults always leaves a valid mode behind.
|
|
180
|
-
const hasExplicitMode = ACCESS_MODES.includes(legacy.mode);
|
|
181
|
-
if (!hasExplicitMode) {
|
|
182
|
-
if (legacy.local === false && typeof legacy.url === 'string' && legacy.url) {
|
|
183
|
-
config.relay.mode = 'remote';
|
|
184
|
-
if (!config.relay.remoteUrl) config.relay.remoteUrl = legacy.url;
|
|
185
|
-
} else if (typeof legacy.host === 'string' && legacy.host && !isLoopbackHost(legacy.host)) {
|
|
186
|
-
config.relay.mode = 'lan';
|
|
187
|
-
} else {
|
|
188
|
-
config.relay.mode = 'local';
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
if (config.relay.mode === 'lan' && !config.relay.lanHost && typeof legacy.host === 'string' && !isLoopbackHost(legacy.host) && legacy.host !== '0.0.0.0') {
|
|
192
|
-
config.relay.lanHost = legacy.host;
|
|
193
|
-
}
|
|
194
|
-
// Superseded keys copied in by the merge would otherwise reappear in the
|
|
195
|
-
// in-memory config and confuse anything reading it.
|
|
196
|
-
delete config.relay.local;
|
|
197
|
-
delete config.relay.host;
|
|
198
|
-
delete config.relay.url;
|
|
199
|
-
// `publicUrl` used to be mandatory and defaulted to the loopback URL; treat
|
|
200
|
-
// that default as "no override" so derivation can take over.
|
|
201
|
-
if (typeof config.relay.publicUrl === 'string' && /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?\/?$/.test(config.relay.publicUrl)) {
|
|
202
|
-
config.relay.publicUrl = '';
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function parseInteger(value, fallback, min, max) {
|
|
207
|
-
const numeric = Number(value);
|
|
208
|
-
if (!Number.isInteger(numeric) || numeric < min || numeric > max) return fallback;
|
|
209
|
-
return numeric;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function isLoopbackHost(value) {
|
|
213
|
-
return value === '127.0.0.1' || value === 'localhost' || value === '::1' || value === '[::1]';
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function isUnspecifiedAddress(value) {
|
|
217
|
-
const address = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
218
|
-
return address === '0.0.0.0' || address === '::' || address === '[::]';
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** An unspecified host is valid for a server bind, but not for a browser URL. */
|
|
222
|
-
function isUnspecifiedHost(value) {
|
|
223
|
-
try {
|
|
224
|
-
const hostname = new URL(value).hostname.toLowerCase();
|
|
225
|
-
return hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]';
|
|
226
|
-
} catch {
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function normalizeUrl(value) {
|
|
232
|
-
return typeof value === 'string' ? value.trim().replace(/\/+$/, '') : '';
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function validate(config) {
|
|
236
|
-
if (!ACCESS_MODES.includes(config.relay.mode)) config.relay.mode = DEFAULTS.relay.mode;
|
|
237
|
-
if (!LANGUAGES.includes(config.ui.language)) config.ui.language = DEFAULTS.ui.language;
|
|
238
|
-
if (!KEEPALIVE_MANAGERS.includes(config.keepalive.manager)) config.keepalive.manager = DEFAULTS.keepalive.manager;
|
|
239
|
-
|
|
240
|
-
config.relay.port = parseInteger(config.relay.port, DEFAULTS.relay.port, 1, 65535);
|
|
241
|
-
config.relay.maxPayloadBytes = parseInteger(config.relay.maxPayloadBytes, DEFAULTS.relay.maxPayloadBytes, 4096, 16 * 1024 * 1024);
|
|
242
|
-
config.relay.maxClientsPerHost = parseInteger(config.relay.maxClientsPerHost, DEFAULTS.relay.maxClientsPerHost, 1, 256);
|
|
243
|
-
config.relay.maxHosts = parseInteger(config.relay.maxHosts, DEFAULTS.relay.maxHosts, 1, 100000);
|
|
244
|
-
config.relay.maxPendingHandshakes = parseInteger(config.relay.maxPendingHandshakes, DEFAULTS.relay.maxPendingHandshakes, 16, 100000);
|
|
245
|
-
config.relay.maxBufferedBytesPerClient = parseInteger(
|
|
246
|
-
config.relay.maxBufferedBytesPerClient,
|
|
247
|
-
DEFAULTS.relay.maxBufferedBytesPerClient,
|
|
248
|
-
64 * 1024,
|
|
249
|
-
256 * 1024 * 1024,
|
|
250
|
-
);
|
|
251
|
-
config.relay.hostReconnectGraceMs = parseInteger(
|
|
252
|
-
config.relay.hostReconnectGraceMs,
|
|
253
|
-
DEFAULTS.relay.hostReconnectGraceMs,
|
|
254
|
-
1000,
|
|
255
|
-
24 * 60 * 60 * 1000,
|
|
256
|
-
);
|
|
257
|
-
config.auth.pairingTtlMs = parseInteger(config.auth.pairingTtlMs, DEFAULTS.auth.pairingTtlMs, 30 * 1000, 24 * 60 * 60 * 1000);
|
|
258
|
-
config.auth.deviceTtlMs = parseInteger(config.auth.deviceTtlMs, DEFAULTS.auth.deviceTtlMs, 60 * 1000, 365 * 24 * 60 * 60 * 1000);
|
|
259
|
-
config.auth.maxDevices = parseInteger(config.auth.maxDevices, DEFAULTS.auth.maxDevices, 1, 10000);
|
|
260
|
-
config.cleanup.intervalMs = parseInteger(config.cleanup.intervalMs, DEFAULTS.cleanup.intervalMs, 1000, 24 * 60 * 60 * 1000);
|
|
261
|
-
config.cleanup.heartbeatIntervalMs = parseInteger(config.cleanup.heartbeatIntervalMs, DEFAULTS.cleanup.heartbeatIntervalMs, 1000, 10 * 60 * 1000);
|
|
262
|
-
config.cleanup.staleAfterMs = parseInteger(config.cleanup.staleAfterMs, DEFAULTS.cleanup.staleAfterMs, config.cleanup.heartbeatIntervalMs * 2, 24 * 60 * 60 * 1000);
|
|
263
|
-
|
|
264
|
-
config.relay.publicUrl = normalizeUrl(config.relay.publicUrl);
|
|
265
|
-
config.relay.remoteUrl = normalizeUrl(config.relay.remoteUrl);
|
|
266
|
-
config.relay.lanHost = typeof config.relay.lanHost === 'string' ? config.relay.lanHost.trim() : '';
|
|
267
|
-
// A LAN relay listens on every interface, but a loopback or wildcard value
|
|
268
|
-
// cannot be opened by another device. Clear stale values from older TUI
|
|
269
|
-
// versions so the advertised URL falls back to a real interface address.
|
|
270
|
-
if (config.relay.mode === 'lan'
|
|
271
|
-
&& (isLoopbackHost(config.relay.lanHost) || isUnspecifiedAddress(config.relay.lanHost))) {
|
|
272
|
-
config.relay.lanHost = '';
|
|
273
|
-
}
|
|
274
|
-
// 0.0.0.0 is a server-side bind wildcard, never a destination a browser can
|
|
275
|
-
// open. Treat it like an empty override so LAN mode derives the real address.
|
|
276
|
-
if (isUnspecifiedHost(config.relay.publicUrl)) config.relay.publicUrl = '';
|
|
277
|
-
if (!Array.isArray(config.relay.allowedOrigins)) config.relay.allowedOrigins = [];
|
|
278
|
-
|
|
279
|
-
if (!Array.isArray(config.herdr.args) || !config.herdr.args.every((arg) => typeof arg === 'string')) {
|
|
280
|
-
config.herdr.args = [];
|
|
281
|
-
}
|
|
282
|
-
// Herdr 0.9.0 removed --no-session; retaining it in saved configs causes
|
|
283
|
-
// session creation to fail. Filter it out if present.
|
|
284
|
-
if (config.herdr.args.includes('--no-session')) {
|
|
285
|
-
config.herdr.args = config.herdr.args.filter((arg) => arg !== '--no-session');
|
|
286
|
-
process.stderr.write('herdr-remote: ignoring removed --no-session argument (removed in Herdr 0.9.0)\n');
|
|
287
|
-
}
|
|
288
|
-
if (typeof config.herdr.socketPath !== 'string' || config.herdr.socketPath.length === 0) {
|
|
289
|
-
config.herdr.socketPath = null;
|
|
290
|
-
}
|
|
291
|
-
if (typeof config.herdr.cwd !== 'string' || config.herdr.cwd.length === 0) {
|
|
292
|
-
config.herdr.cwd = os.homedir();
|
|
293
|
-
}
|
|
294
|
-
config.herdr.autoStart = config.herdr.autoStart === true;
|
|
295
|
-
|
|
296
|
-
// A "remote" config without a relay URL cannot reach anything; fall back to
|
|
297
|
-
// local rather than silently starting a host connector that dials nowhere.
|
|
298
|
-
if (config.relay.mode === 'remote' && !config.relay.remoteUrl) {
|
|
299
|
-
config.relay.mode = 'local';
|
|
300
|
-
}
|
|
301
|
-
return config;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function applyEnvironment(config) {
|
|
305
|
-
const env = process.env;
|
|
306
|
-
if (env.HERDR_REMOTE_LANG && LANGUAGES.includes(env.HERDR_REMOTE_LANG)) config.ui.language = env.HERDR_REMOTE_LANG;
|
|
307
|
-
if (env.HERDR_REMOTE_MODE && ACCESS_MODES.includes(env.HERDR_REMOTE_MODE)) config.relay.mode = env.HERDR_REMOTE_MODE;
|
|
308
|
-
if (env.RELAY_PORT) config.relay.port = env.RELAY_PORT;
|
|
309
|
-
if (env.RELAY_PUBLIC_URL) config.relay.publicUrl = env.RELAY_PUBLIC_URL;
|
|
310
|
-
if (env.RELAY_REMOTE_URL) config.relay.remoteUrl = env.RELAY_REMOTE_URL;
|
|
311
|
-
if (env.RELAY_MAX_HOSTS) config.relay.maxHosts = env.RELAY_MAX_HOSTS;
|
|
312
|
-
if (env.RELAY_MAX_PENDING_HANDSHAKES) config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
|
|
313
|
-
if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT) config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
|
|
314
|
-
if (env.RELAY_HOST_RECONNECT_GRACE_MS) config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
|
|
315
|
-
if (env.HERDR_SOCKET_PATH) config.herdr.socketPath = env.HERDR_SOCKET_PATH;
|
|
316
|
-
if (env.HERDR_CWD) config.herdr.cwd = env.HERDR_CWD;
|
|
317
|
-
if (env.HERDR_ARGS_JSON) {
|
|
318
|
-
try {
|
|
319
|
-
const args = JSON.parse(env.HERDR_ARGS_JSON);
|
|
320
|
-
if (Array.isArray(args)) config.herdr.args = args;
|
|
321
|
-
} catch (error) {
|
|
322
|
-
process.stderr.write(`herdr-remote: invalid HERDR_ARGS_JSON: ${error.message}\n`);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function loadConfig() {
|
|
328
|
-
const fileConfig = readJson(configPath());
|
|
329
|
-
const config = mergeConfig(fileConfig);
|
|
330
|
-
normalizeLegacyFields(config, fileConfig);
|
|
331
|
-
applyEnvironment(config);
|
|
332
|
-
validate(config);
|
|
333
|
-
return config;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function configExists() {
|
|
337
|
-
return fs.existsSync(configPath());
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// ---------------------------------------------------------------------------
|
|
341
|
-
// Derived values
|
|
342
|
-
// ---------------------------------------------------------------------------
|
|
343
|
-
|
|
344
|
-
/** Whether this machine runs its own relay process. */
|
|
345
|
-
function runsLocalRelay(config) {
|
|
346
|
-
return config.relay.mode !== 'remote';
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/** Address the local relay binds to for the configured mode. */
|
|
350
|
-
function bindAddress(config) {
|
|
351
|
-
return config.relay.mode === 'lan' ? '0.0.0.0' : '127.0.0.1';
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Host name to put into pairing URLs. For "lan" this is the interface address a
|
|
356
|
-
* phone can actually reach; `lanHost` is the user's pick from the TUI, and the
|
|
357
|
-
* fallback keeps things working if that interface disappeared.
|
|
358
|
-
*/
|
|
359
|
-
function advertisedHost(config, fallbackLanHost = null) {
|
|
360
|
-
if (config.relay.mode === 'lan') {
|
|
361
|
-
const configured = config.relay.lanHost;
|
|
362
|
-
if (configured && !isLoopbackHost(configured) && !isUnspecifiedAddress(configured)) return configured;
|
|
363
|
-
if (fallbackLanHost && !isLoopbackHost(fallbackLanHost) && !isUnspecifiedAddress(fallbackLanHost)) {
|
|
364
|
-
return fallbackLanHost;
|
|
365
|
-
}
|
|
366
|
-
return '127.0.0.1';
|
|
367
|
-
}
|
|
368
|
-
return '127.0.0.1';
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/** The URL a browser opens. */
|
|
372
|
-
function resolvePublicUrl(config, fallbackLanHost = null) {
|
|
373
|
-
if (config.relay.publicUrl) return config.relay.publicUrl;
|
|
374
|
-
if (config.relay.mode === 'remote') return httpOrigin(config.relay.remoteUrl);
|
|
375
|
-
return `http://${advertisedHost(config, fallbackLanHost)}:${config.relay.port}`;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** Base HTTP origin used for relay admin calls (pairing, health). */
|
|
379
|
-
function resolveAdminOrigin(config) {
|
|
380
|
-
if (config.relay.mode === 'remote') return httpOrigin(config.relay.remoteUrl);
|
|
381
|
-
return `http://127.0.0.1:${config.relay.port}`;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/** WebSocket URL the host connector dials. */
|
|
385
|
-
function resolveHostRelayUrl(config) {
|
|
386
|
-
const base = config.relay.mode === 'remote'
|
|
387
|
-
? config.relay.remoteUrl
|
|
388
|
-
: `ws://127.0.0.1:${config.relay.port}`;
|
|
389
|
-
return hostWebSocketUrl(base);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function httpOrigin(value) {
|
|
393
|
-
try {
|
|
394
|
-
const url = new URL(value);
|
|
395
|
-
if (url.protocol === 'ws:') url.protocol = 'http:';
|
|
396
|
-
if (url.protocol === 'wss:') url.protocol = 'https:';
|
|
397
|
-
const pathname = url.pathname.replace(/\/+$/, '');
|
|
398
|
-
return `${url.origin}${pathname}`;
|
|
399
|
-
} catch {
|
|
400
|
-
return normalizeUrl(value);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function hostWebSocketUrl(base) {
|
|
405
|
-
const url = new URL(base);
|
|
406
|
-
if (url.protocol === 'http:') url.protocol = 'ws:';
|
|
407
|
-
if (url.protocol === 'https:') url.protocol = 'wss:';
|
|
408
|
-
const pathname = url.pathname.replace(/\/+$/, '');
|
|
409
|
-
if (!pathname || pathname === '/') {
|
|
410
|
-
url.pathname = '/ws/host';
|
|
411
|
-
} else if (!pathname.endsWith('/ws/host')) {
|
|
412
|
-
url.pathname = `${pathname}/ws/host`;
|
|
413
|
-
}
|
|
414
|
-
return url.toString();
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function clientWebSocketUrl(locationLike) {
|
|
418
|
-
const url = new URL(locationLike);
|
|
419
|
-
if (url.protocol === 'http:') url.protocol = 'ws:';
|
|
420
|
-
if (url.protocol === 'https:') url.protocol = 'wss:';
|
|
421
|
-
const pathname = url.pathname.replace(/\/+$/, '');
|
|
422
|
-
url.pathname = !pathname || pathname === '/' ? '/ws/client' : `${pathname}/ws/client`;
|
|
423
|
-
return url.toString();
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
module.exports = {
|
|
427
|
-
PACKAGE_ROOT,
|
|
428
|
-
DEFAULTS,
|
|
429
|
-
ACCESS_MODES,
|
|
430
|
-
OFFICIAL_RELAY_URL,
|
|
431
|
-
LANGUAGES,
|
|
432
|
-
KEEPALIVE_MANAGERS,
|
|
433
|
-
configDir,
|
|
434
|
-
configPath,
|
|
435
|
-
configExists,
|
|
436
|
-
stateDir,
|
|
437
|
-
runtimeStatePath,
|
|
438
|
-
legacyConfigPath,
|
|
439
|
-
migrateLegacyConfig,
|
|
440
|
-
loadConfig,
|
|
441
|
-
validate,
|
|
442
|
-
runsLocalRelay,
|
|
443
|
-
bindAddress,
|
|
444
|
-
advertisedHost,
|
|
445
|
-
resolvePublicUrl,
|
|
446
|
-
resolveAdminOrigin,
|
|
447
|
-
resolveHostRelayUrl,
|
|
448
|
-
hostWebSocketUrl,
|
|
449
|
-
clientWebSocketUrl,
|
|
450
|
-
httpOrigin,
|
|
451
|
-
isLoopbackHost,
|
|
452
|
-
isUnspecifiedAddress,
|
|
453
|
-
isUnspecifiedHost,
|
|
454
|
-
};
|