herdr-remote 0.2.0

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.
@@ -0,0 +1,280 @@
1
+ 'use strict';
2
+
3
+ const os = require('node:os');
4
+ const crypto = require('node:crypto');
5
+ const { WebSocket } = require('ws');
6
+ const { loadConfig, hostWebSocketUrl, resolveHostRelayUrl } = require('./config');
7
+ const { resolveSocketPath, inspectSocket } = require('./socket-discovery');
8
+ const { PtySession } = require('./pty-session');
9
+ const { resolveHerdrCommand } = require('./herdr-command');
10
+ // The wire format lives in the relay package so both ends of the protocol are
11
+ // generated from one definition.
12
+ const { packStreamFrame, unpackStreamFrame, PROTOCOL_VERSION } = require('herdr-remote-relay/protocol');
13
+ const { resolveHostPalette } = require('./terminal-palette');
14
+ const { EXIT_REPLACED } = require('./exit-codes');
15
+
16
+ function randomId(prefix) {
17
+ return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
18
+ }
19
+
20
+ function sendJson(ws, payload) {
21
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(payload));
22
+ }
23
+
24
+ function closeSocket(ws) {
25
+ if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) return;
26
+ try { ws.close(1000, 'host connector stopping'); } catch {}
27
+ }
28
+
29
+ class HostConnector {
30
+ constructor(options = {}) {
31
+ const config = options.config || loadConfig();
32
+ this.config = config;
33
+ this.relayUrl = options.relayUrl
34
+ || (process.env.RELAY_URL ? hostWebSocketUrl(process.env.RELAY_URL) : resolveHostRelayUrl(config));
35
+ this.hostId = options.hostId || process.env.RELAY_HOST_ID || randomId('host');
36
+ this.hostToken = options.hostToken || process.env.RELAY_HOST_TOKEN || '';
37
+ // Optional; a relay without a password accepts any workstation.
38
+ this.relayPassword = options.relayPassword ?? process.env.RELAY_PASSWORD ?? '';
39
+ this.socketPath = options.socketPath || resolveSocketPath(config.herdr.socketPath);
40
+ this.herdrCommand = options.herdrCommand || resolveHerdrCommand();
41
+ this.herdrArgs = options.herdrArgs || config.herdr.args;
42
+ this.cwd = options.cwd || config.herdr.cwd;
43
+ /**
44
+ * What the workstation's own terminal looks like. A browser has no way to
45
+ * know it — the PTY carries color indices, not colors — so the host reports
46
+ * it and the browser renders the session the way the workstation sees it.
47
+ * Normally captured by the start path that still had a terminal and passed
48
+ * down in the environment; `null` when nobody could ask.
49
+ */
50
+ this.terminalPalette = options.terminalPalette !== undefined
51
+ ? options.terminalPalette
52
+ : resolveHostPalette();
53
+ this.ws = null;
54
+ this.sessions = new Map();
55
+ this.reconnectTimer = null;
56
+ this.heartbeatTimer = null;
57
+ this.reconnectAttempts = 0;
58
+ this.stopping = false;
59
+ }
60
+
61
+ start() {
62
+ if (!this.hostToken) {
63
+ throw new Error('RELAY_HOST_TOKEN is required');
64
+ }
65
+ this.stopping = false;
66
+ this.connect();
67
+ }
68
+
69
+ stop() {
70
+ this.stopping = true;
71
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
72
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
73
+ this.reconnectTimer = null;
74
+ this.heartbeatTimer = null;
75
+ this.destroySessions();
76
+ closeSocket(this.ws);
77
+ this.ws = null;
78
+ }
79
+
80
+ connect() {
81
+ if (this.stopping || (this.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(this.ws.readyState))) return;
82
+ let ws;
83
+ try {
84
+ ws = new WebSocket(this.relayUrl);
85
+ } catch (error) {
86
+ this.scheduleReconnect(error);
87
+ return;
88
+ }
89
+ this.ws = ws;
90
+ ws.isAlive = true;
91
+ ws.on('open', () => {
92
+ this.reconnectAttempts = 0;
93
+ ws.isAlive = true;
94
+ sendJson(ws, {
95
+ type: 'host_hello',
96
+ protocol: PROTOCOL_VERSION,
97
+ hostId: this.hostId,
98
+ token: this.hostToken,
99
+ password: this.relayPassword || null,
100
+ hostname: os.hostname(),
101
+ platform: process.platform,
102
+ arch: process.arch,
103
+ terminalPalette: this.terminalPalette || null,
104
+ });
105
+ this.sendHeartbeat();
106
+ this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
107
+ });
108
+ ws.on('pong', () => { ws.isAlive = true; });
109
+ ws.on('message', (raw, isBinary) => this.handleMessage(raw, isBinary));
110
+ ws.on('close', (code, rawReason) => {
111
+ if (this.ws === ws) this.ws = null;
112
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
113
+ this.heartbeatTimer = null;
114
+ this.destroySessions();
115
+
116
+ // Another connector has claimed this workstation. Reconnecting would just
117
+ // displace it in turn, and each swap drops every attached browser, so
118
+ // stand down instead of fighting for the slot.
119
+ const reason = rawReason ? rawReason.toString() : '';
120
+ if (reason === 'host_replaced') {
121
+ this.stopping = true;
122
+ process.stderr.write('herdr-remote host connector: another instance took over this workstation; exiting\n');
123
+ this.stop();
124
+ process.exit(EXIT_REPLACED);
125
+ }
126
+ this.scheduleReconnect();
127
+ });
128
+ ws.on('error', (error) => {
129
+ process.stderr.write(`herdr-remote host connector: ${error.message}\n`);
130
+ });
131
+ }
132
+
133
+ scheduleReconnect(error = null) {
134
+ if (this.stopping || this.reconnectTimer) return;
135
+ if (error) process.stderr.write(`herdr-remote host connector: ${error.message}\n`);
136
+ const delay = Math.min(30000, 500 * (1.5 ** this.reconnectAttempts) + Math.floor(Math.random() * 250));
137
+ this.reconnectAttempts += 1;
138
+ this.reconnectTimer = setTimeout(() => {
139
+ this.reconnectTimer = null;
140
+ this.connect();
141
+ }, delay);
142
+ }
143
+
144
+ handleMessage(raw, isBinary) {
145
+ if (isBinary) {
146
+ let frame;
147
+ try {
148
+ frame = unpackStreamFrame(raw);
149
+ } catch (error) {
150
+ process.stderr.write(`herdr-remote host connector: invalid relay frame: ${error.message}\n`);
151
+ return;
152
+ }
153
+ if (frame.type === 'input') this.sessions.get(frame.streamId)?.pty.write(frame.payload);
154
+ return;
155
+ }
156
+ let message;
157
+ try { message = JSON.parse(raw.toString('utf8')); } catch { return; }
158
+ // A relay-level error (no clientId) is a rejected handshake — a wrong relay
159
+ // password, or a host token the relay does not recognise. Without this the
160
+ // connection just closed silently and reconnected forever, leaving the user
161
+ // with an empty log and no idea what was wrong.
162
+ if (message.type === 'error' && !message.clientId) {
163
+ process.stderr.write(`herdr-remote host connector: relay rejected the connection: ${message.message || message.code}\n`);
164
+ return;
165
+ }
166
+ if (message.type === 'session_start') this.startSession(message);
167
+ else if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
168
+ else if (message.type === 'resize') this.resizeSession(message);
169
+ }
170
+
171
+ startSession(message) {
172
+ const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
173
+ if (!streamId) return;
174
+ this.stopSession(streamId);
175
+ const socketInfo = inspectSocket(this.socketPath);
176
+ if (!socketInfo.ok) {
177
+ sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: socketInfo.reason });
178
+ return;
179
+ }
180
+ const pty = new PtySession({
181
+ command: this.herdrCommand,
182
+ args: this.herdrArgs,
183
+ cwd: this.cwd,
184
+ socketPath: this.socketPath,
185
+ });
186
+ const session = {
187
+ id: streamId,
188
+ pty,
189
+ cols: Number(message.cols) || PtySession.DEFAULT_COLS,
190
+ rows: Number(message.rows) || PtySession.DEFAULT_ROWS,
191
+ createdAt: new Date().toISOString(),
192
+ clientId: streamId,
193
+ };
194
+ try {
195
+ pty.start({
196
+ cols: session.cols,
197
+ rows: session.rows,
198
+ onData: (data) => {
199
+ const payload = Buffer.from(data, 'utf8');
200
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(packStreamFrame('output', streamId, payload));
201
+ },
202
+ onExit: ({ exitCode }) => {
203
+ if (this.sessions.get(streamId) !== session) return;
204
+ this.sessions.delete(streamId);
205
+ sendJson(this.ws, { type: 'session_exit', clientId: streamId, code: exitCode });
206
+ this.sendHeartbeat();
207
+ },
208
+ });
209
+ } catch (error) {
210
+ sendJson(this.ws, { type: 'error', clientId: streamId, code: 'pty_start_failed', message: error.message });
211
+ pty.kill();
212
+ return;
213
+ }
214
+ this.sessions.set(streamId, session);
215
+ sendJson(this.ws, { type: 'session_ready', clientId: streamId });
216
+ this.sendHeartbeat();
217
+ }
218
+
219
+ stopSession(streamId) {
220
+ const session = this.sessions.get(streamId);
221
+ if (!session) return;
222
+ this.sessions.delete(streamId);
223
+ session.pty.kill();
224
+ this.sendHeartbeat();
225
+ }
226
+
227
+ resizeSession(message) {
228
+ const id = message.clientId || message.streamId;
229
+ const session = this.sessions.get(id);
230
+ if (!session) return;
231
+ session.cols = Number.isInteger(message.cols) ? Math.min(500, Math.max(2, message.cols)) : session.cols;
232
+ session.rows = Number.isInteger(message.rows) ? Math.min(500, Math.max(2, message.rows)) : session.rows;
233
+ session.pty.resize(session.cols, session.rows);
234
+ }
235
+
236
+ destroySessions() {
237
+ for (const session of this.sessions.values()) session.pty.kill();
238
+ this.sessions.clear();
239
+ }
240
+
241
+ sendHeartbeat() {
242
+ const memory = process.memoryUsage();
243
+ const load = os.loadavg();
244
+ sendJson(this.ws, {
245
+ type: 'heartbeat',
246
+ load: {
247
+ load1m: load[0] || 0,
248
+ load5m: load[1] || 0,
249
+ load15m: load[2] || 0,
250
+ rssBytes: memory.rss,
251
+ heapUsedBytes: memory.heapUsed,
252
+ },
253
+ ptys: [...this.sessions.values()].map((session) => ({
254
+ id: session.id,
255
+ pid: session.pty.terminal?.pid || null,
256
+ command: session.pty.command,
257
+ cols: session.cols,
258
+ rows: session.rows,
259
+ cwd: session.pty.cwd,
260
+ createdAt: session.createdAt,
261
+ activeClients: 1,
262
+ })),
263
+ });
264
+ }
265
+ }
266
+
267
+ if (require.main === module) {
268
+ const connector = new HostConnector();
269
+ try {
270
+ connector.start();
271
+ } catch (error) {
272
+ process.stderr.write(`herdr-remote host connector failed: ${error.message}\n`);
273
+ process.exitCode = 1;
274
+ }
275
+ const stop = () => { connector.stop(); process.exit(0); };
276
+ process.once('SIGINT', stop);
277
+ process.once('SIGTERM', stop);
278
+ }
279
+
280
+ module.exports = { HostConnector, PROTOCOL_VERSION };
package/src/i18n/en.js ADDED
@@ -0,0 +1,219 @@
1
+ 'use strict';
2
+
3
+ // English message catalogue. This file is the reference: zh.js must define
4
+ // exactly the same keys, which tests/i18n.test.js enforces.
5
+
6
+ module.exports = {
7
+ 'app.name': 'Herdr Remote',
8
+ 'app.tagline': 'Remote browser access to your Herdr workspaces',
9
+
10
+ 'common.yes': 'Yes',
11
+ 'common.no': 'No',
12
+ 'common.on': 'on',
13
+ 'common.off': 'off',
14
+ 'common.none': 'none',
15
+ 'common.auto': 'auto',
16
+ 'common.unknown': 'unknown',
17
+ 'common.running': 'running',
18
+ 'common.stopped': 'stopped',
19
+ 'common.connected': 'connected',
20
+ 'common.disconnected': 'disconnected',
21
+ 'common.installed': 'installed',
22
+ 'common.notInstalled': 'not installed',
23
+ 'common.enabled': 'enabled',
24
+ 'common.disabled': 'disabled',
25
+ 'common.loading': 'Working…',
26
+ 'common.save': 'Save settings',
27
+ 'common.saved': 'Configuration saved to {path}',
28
+ 'common.back': 'Back',
29
+ 'common.cancel': 'Cancel',
30
+ 'common.confirm': 'Confirm',
31
+ 'common.pid': 'pid {pid}',
32
+ 'common.notApplicable': 'n/a',
33
+
34
+ 'nav.overview': 'Overview',
35
+ 'nav.pair': 'Pair a device',
36
+ 'nav.services': 'Services',
37
+ 'nav.relay': 'Relay',
38
+ 'nav.keepalive': 'Keep-alive',
39
+ 'nav.herdr': 'Herdr',
40
+ 'nav.about': 'Language & about',
41
+
42
+ 'mode.local': 'This machine only',
43
+ 'mode.lan': 'Local network / Tailscale',
44
+ 'mode.remote': 'Self-hosted relay',
45
+ 'mode.local.description': 'The relay listens on 127.0.0.1. Only a browser on this machine can reach it.',
46
+ 'mode.lan.description': 'The relay listens on 0.0.0.0 (every interface), so phones on your LAN or tailnet can reach it.',
47
+ 'mode.remote.description': 'No local relay. The workstation dials a relay you run, which is the only way in from outside your network.',
48
+
49
+ 'overview.title': 'Status',
50
+ 'overview.mode': 'Access mode',
51
+ 'overview.relay': 'Relay',
52
+ 'overview.host': 'Host connector',
53
+ 'overview.socket': 'Herdr socket',
54
+ 'overview.webUrl': 'Web UI',
55
+ 'overview.keepalive': 'Keep-alive',
56
+ 'overview.devices': 'Browsers',
57
+ 'overview.hosts': 'Workstations',
58
+ 'overview.uptime': 'Relay uptime',
59
+ 'overview.relayLocal': 'local, {bind}:{port}',
60
+ 'overview.relayRemote': 'remote, {url}',
61
+ 'overview.socketMissing': 'not found — is Herdr running?',
62
+ 'overview.unreachable': 'unreachable: {message}',
63
+ 'overview.notStarted': 'Services are not running. Open the Services tab to start them.',
64
+
65
+ 'pair.title': 'Pair a device',
66
+ 'pair.generate': 'Generate a one-time pairing code',
67
+ 'pair.regenerate': 'Generate another code',
68
+ 'pair.working': 'Starting services and requesting a code…',
69
+ 'pair.code': 'Pairing code',
70
+ 'pair.url': 'Open on your phone',
71
+ 'pair.expires': 'Expires in {minutes} (at {time})',
72
+ 'pair.expired': 'This code has expired. Generate a new one.',
73
+ 'pair.instructions': 'Open the URL, enter the code, and the browser is paired for good.',
74
+ 'pair.qrHint': 'Scan the code with your phone camera to open the URL.',
75
+ 'pair.qrUnavailable': 'The terminal is too narrow for a QR code; use the URL above.',
76
+ 'pair.failed': 'Could not create a pairing code: {message}',
77
+ 'pair.hostOffline': 'The relay has no workstation connected yet. Start the services first.',
78
+
79
+ 'services.title': 'Services',
80
+ 'services.start': 'Start services',
81
+ 'services.stop': 'Stop services',
82
+ 'services.restart': 'Restart services',
83
+ 'services.started': 'Services started.',
84
+ 'services.stopped': 'Services stopped.',
85
+ 'services.restarted': 'Services restarted.',
86
+ 'services.startFailed': 'Could not start services: {message}',
87
+ 'services.stopFailed': 'Could not stop services: {message}',
88
+ 'services.unsavedBlocked': 'There are unsaved changes. Save them first, or the services restart on the configuration still on disk.',
89
+ 'services.managedNotice': 'These services are managed by {manager}; the keep-alive unit was used to apply the change.',
90
+ 'services.logs': 'Recent log output',
91
+ 'services.logEmpty': 'No log output yet.',
92
+ 'services.logRelay': 'Relay',
93
+ 'services.logHost': 'Host connector',
94
+
95
+ 'relay.title': 'Relay settings',
96
+ 'relay.settings': 'Settings',
97
+ 'relay.password': 'Relay password',
98
+ 'relay.passwordHint': 'Must match RELAY_PASSWORD on the relay. Leave empty for a public relay.',
99
+ 'relay.passwordSet': 'set',
100
+ 'relay.passwordEmpty': 'not set (public relay)',
101
+ 'relay.passwordSaved': 'Relay password saved. Restart the services to apply it.',
102
+ 'relay.showPassword': 'Show the password',
103
+ 'relay.hidePassword': 'Hide the password',
104
+ 'relay.identity': 'Workstation id',
105
+ 'relay.regenerate': 'Generate a new workstation identity',
106
+ 'relay.regenerated': 'New workstation identity generated. Restart the services to enrol again.',
107
+ 'relay.envSnippet': 'Command for your relay server',
108
+ 'relay.envHint': 'Run this on the relay host.',
109
+ 'relay.test': 'Test the relay connection',
110
+ 'relay.testOk': 'Relay reachable: version {version}, {hosts} workstation(s) connected.',
111
+ 'relay.testFailed': 'Relay unreachable: {message}',
112
+ 'relay.selectAddress': 'Choose the address to advertise',
113
+ 'relay.listenAddress': 'Listen address',
114
+ 'relay.listenAddressHint': 'This is the server bind address. The advertised address below is what browsers open.',
115
+ 'relay.addressTailscale': 'Tailscale',
116
+ 'relay.addressLan': 'LAN',
117
+ 'relay.addressVirtual': 'virtual',
118
+ 'relay.addressLoopback': 'loopback',
119
+ 'relay.noAddresses': 'No non-loopback addresses found. Connect to a network or start Tailscale.',
120
+ 'relay.docsHint': 'Running your own relay: see docs/self-hosted-relay.md',
121
+
122
+ 'keepalive.title': 'Keep-alive service',
123
+ 'keepalive.manager': 'Manager',
124
+ 'keepalive.state': 'State',
125
+ 'keepalive.install': 'Install and start',
126
+ 'keepalive.uninstall': 'Stop and remove',
127
+ 'keepalive.restart': 'Restart the service',
128
+ 'keepalive.installed': 'Keep-alive installed with {manager}.',
129
+ 'keepalive.uninstalled': 'Keep-alive removed.',
130
+ 'keepalive.restarted': 'Keep-alive service restarted.',
131
+ 'keepalive.failed': 'Keep-alive operation failed: {message}',
132
+ 'keepalive.unitPath': 'Unit file',
133
+ 'keepalive.logsHint': 'Follow logs with: {command}',
134
+ 'keepalive.linger': 'Start at boot without logging in',
135
+ 'keepalive.lingerEnabled': 'Lingering is enabled: the service starts at boot.',
136
+ 'keepalive.lingerDisabled': 'Lingering is off, so the service only runs while you are logged in.',
137
+ 'keepalive.enableLinger': 'Enable start at boot',
138
+ 'keepalive.lingerDone': 'Lingering enabled for {username}.',
139
+ 'keepalive.fallbackNote': 'No system service manager is available; a supervised background process is used instead. It does not survive a reboot.',
140
+
141
+ 'herdr.title': 'Herdr integration',
142
+ 'herdr.socketPath': 'Socket path',
143
+ 'herdr.args': 'Extra arguments',
144
+ 'herdr.plugin': 'Plugin registration',
145
+ 'herdr.pluginRegistered': 'Registered with Herdr.',
146
+ 'herdr.pluginMissing': 'Not registered with Herdr.',
147
+ 'herdr.register': 'Register this package as a Herdr plugin',
148
+ 'herdr.unregister': 'Unregister the Herdr plugin',
149
+ 'herdr.registerDone': 'Registered: {path}',
150
+ 'herdr.unregisterDone': 'Plugin unregistered.',
151
+ 'herdr.registerFailed': 'Registration failed: {message}',
152
+ 'herdr.cliMissing': 'The herdr command was not found on PATH.',
153
+
154
+ 'about.title': 'Language & about',
155
+ 'about.language': 'Interface language',
156
+ 'about.languageAuto': 'Follow the system ({detected})',
157
+ 'about.languageZh': '中文',
158
+ 'about.languageEn': 'English',
159
+ 'about.version': 'Version',
160
+ 'about.configPath': 'Config file',
161
+ 'about.statePath': 'State directory',
162
+ 'about.relayPackage': 'Relay package',
163
+ 'about.docs': 'Self-hosting guide: docs/self-hosted-relay.md',
164
+
165
+ 'wizard.title': 'First-time setup',
166
+ 'wizard.step': 'Step {current} of {total}',
167
+ 'wizard.languageTitle': 'Choose your language',
168
+ 'wizard.accessTitle': 'How do you want to reach this machine?',
169
+ 'wizard.accessHint': 'You can change this later on the Relay screen.',
170
+ 'wizard.addressTitle': 'Which address should phones use?',
171
+ 'wizard.relayTitle': 'Your relay server',
172
+ 'wizard.relayUrlLabel': 'Relay URL (wss://…)',
173
+ 'wizard.relayHint': 'The relay must already be running. See docs/self-hosted-relay.md to set one up.',
174
+ 'wizard.passwordTitle': 'Relay password',
175
+ 'wizard.passwordHint': 'The RELAY_PASSWORD your relay was started with. Leave it empty if the relay has none.',
176
+ 'wizard.finishTitle': 'Ready',
177
+ 'wizard.finishHint': 'Configuration will be saved to {path}.',
178
+ 'wizard.startNow': 'Start the services now',
179
+ 'wizard.installKeepalive': 'Keep the services running in the background',
180
+ 'wizard.finish': 'Save and continue',
181
+
182
+ 'field.mode': 'Access mode',
183
+ 'field.port': 'Relay port',
184
+ 'field.lanHost': 'Browser address',
185
+ 'field.remoteUrl': 'Relay URL',
186
+ 'field.publicUrl': 'Browser URL override',
187
+ 'field.socketPath': 'Herdr socket path',
188
+ 'field.herdrArgs': 'Herdr arguments',
189
+ 'field.language': 'Language',
190
+ 'field.keepalive': 'Keep-alive manager',
191
+
192
+ 'placeholder.autoDiscovered': 'auto-discovered',
193
+ 'placeholder.none': 'none',
194
+
195
+ 'error.invalidMode': 'Unknown access mode.',
196
+ 'error.invalidPort': 'The port must be a number between 1 and 65535.',
197
+ 'error.invalidLanHost': 'The browser address must be a reachable LAN or Tailscale address, not loopback or 0.0.0.0.',
198
+ 'error.invalidRelayUrl': 'The relay URL must start with wss://, ws://, https:// or http://.',
199
+ 'error.invalidPublicUrl': 'The browser URL must start with https:// or http:// and cannot use 0.0.0.0 or ::.',
200
+ 'error.invalidLanguage': 'Unknown language.',
201
+ 'error.invalidKeepalive': 'Unknown keep-alive manager.',
202
+ 'error.unknownField': 'Unknown setting.',
203
+ 'error.remoteUrlRequired': 'A relay URL is required for the self-hosted relay mode.',
204
+ 'error.saveFailed': 'Could not save the configuration: {message}',
205
+
206
+ 'hint.navigate': '↑↓ move',
207
+ 'hint.select': '↵ select',
208
+ 'hint.edit': '↵ edit',
209
+ 'hint.tabs': '← → switch tab',
210
+ 'hint.back': 'esc back',
211
+ 'hint.quit': 'q quit',
212
+ 'hint.mouseOn': 'm mouse off',
213
+ 'hint.mouseOff': 'm mouse on',
214
+ 'hint.mouseUnsupported': 'mouse unsupported',
215
+ 'hint.restartRequired': 'Restart the services to apply these changes.',
216
+ 'hint.unsavedChanges': 'Unsaved changes — press s to save.',
217
+ 'hint.save': 's save',
218
+ 'hint.editing': '↵ confirm · esc cancel',
219
+ };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const en = require('./en');
4
+ const zh = require('./zh');
5
+
6
+ const CATALOGUES = { en, zh };
7
+ const DEFAULT_LOCALE = 'en';
8
+
9
+ /**
10
+ * Work out the interface language from the environment.
11
+ *
12
+ * Order matters: an explicit override wins, then the saved preference, then the
13
+ * POSIX locale variables in the order the C library itself consults them.
14
+ */
15
+ function detectLocale({ env = process.env, preference = 'auto' } = {}) {
16
+ if (preference && preference !== 'auto' && CATALOGUES[preference]) return preference;
17
+ if (env.HERDR_REMOTE_LANG && CATALOGUES[env.HERDR_REMOTE_LANG]) return env.HERDR_REMOTE_LANG;
18
+
19
+ const raw = env.LC_ALL || env.LC_MESSAGES || env.LANG || env.LANGUAGE || '';
20
+ const primary = String(raw).split(/[:.@]/)[0].toLowerCase();
21
+ if (primary.startsWith('zh')) return 'zh';
22
+ return DEFAULT_LOCALE;
23
+ }
24
+
25
+ function interpolate(template, values) {
26
+ if (!values) return template;
27
+ return template.replace(/\{(\w+)\}/g, (match, key) => (
28
+ Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match
29
+ ));
30
+ }
31
+
32
+ /**
33
+ * Build a translator for a locale.
34
+ *
35
+ * A missing key falls back to English and finally to the key itself, so a typo
36
+ * shows up as a visible key rather than an empty gap in the layout.
37
+ */
38
+ function createTranslator(locale = DEFAULT_LOCALE) {
39
+ const active = CATALOGUES[locale] || CATALOGUES[DEFAULT_LOCALE];
40
+ const fallback = CATALOGUES[DEFAULT_LOCALE];
41
+ const t = (key, values) => {
42
+ const template = active[key] ?? fallback[key] ?? key;
43
+ return interpolate(template, values);
44
+ };
45
+ t.locale = locale;
46
+ t.has = (key) => Object.prototype.hasOwnProperty.call(active, key);
47
+ return t;
48
+ }
49
+
50
+ module.exports = {
51
+ CATALOGUES,
52
+ DEFAULT_LOCALE,
53
+ detectLocale,
54
+ createTranslator,
55
+ interpolate,
56
+ };