herdr-remote 0.2.3 → 0.2.5

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/herdr-plugin.toml CHANGED
@@ -2,7 +2,7 @@ id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
3
  version = "0.2.3"
4
4
  min_herdr_version = "0.8.2"
5
- description = "Mobile-first remote access to the native Herdr TUI through a local or self-hosted relay"
5
+ description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos"]
7
7
 
8
8
  # No [[build]] steps: this package is installed from npm with its TUI bundle and
@@ -20,31 +20,31 @@ command = ["node", "bin/herdr-remote.js"]
20
20
 
21
21
  [[actions]]
22
22
  id = "configure"
23
- title = "Configure Herdr Remote"
23
+ title = "Configure"
24
24
  contexts = ["workspace", "pane"]
25
25
  command = ["herdr", "plugin", "pane", "open", "--plugin", "herdr.remote.web", "--entrypoint", "config", "--placement", "zoomed", "--focus"]
26
26
 
27
27
  [[actions]]
28
28
  id = "start"
29
- title = "Start Herdr Remote"
29
+ title = "Start"
30
30
  contexts = ["workspace", "pane"]
31
31
  command = ["node", "bin/herdr-remote.js", "start"]
32
32
 
33
33
  [[actions]]
34
34
  id = "stop"
35
- title = "Stop Herdr Remote"
35
+ title = "Stop"
36
36
  contexts = ["workspace", "pane"]
37
37
  command = ["node", "bin/herdr-remote.js", "stop"]
38
38
 
39
39
  [[actions]]
40
40
  id = "status"
41
- title = "Show Herdr Remote status"
41
+ title = "Status"
42
42
  contexts = ["workspace", "pane"]
43
43
  command = ["node", "bin/herdr-remote.js", "status"]
44
44
 
45
45
  [[actions]]
46
46
  id = "pair"
47
- title = "Create a phone pairing code"
47
+ title = "Pair"
48
48
  contexts = ["workspace", "pane"]
49
49
  command = ["node", "bin/herdr-remote.js", "pair"]
50
50
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.3",
4
- "description": "Remote browser access to your Herdr terminal workspaces: Herdr plugin, host connector, and bilingual configuration TUI",
3
+ "version": "0.2.5",
4
+ "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -32,7 +32,8 @@
32
32
  "dist",
33
33
  "src",
34
34
  "herdr-plugin.toml",
35
- "config.example.json"
35
+ "config.example.json",
36
+ "README.zh-CN.md"
36
37
  ],
37
38
  "scripts": {
38
39
  "build": "node scripts/build-tui.mjs",
package/src/config.js CHANGED
@@ -64,6 +64,10 @@ const DEFAULTS = {
64
64
  remoteUrl: '',
65
65
  maxPayloadBytes: 1024 * 1024,
66
66
  maxClientsPerHost: 16,
67
+ maxHosts: 1024,
68
+ maxPendingHandshakes: 1024,
69
+ maxBufferedBytesPerClient: 4 * 1024 * 1024,
70
+ hostReconnectGraceMs: 30 * 1000,
67
71
  allowedOrigins: [],
68
72
  },
69
73
  herdr: {
@@ -230,6 +234,20 @@ function validate(config) {
230
234
  config.relay.port = parseInteger(config.relay.port, DEFAULTS.relay.port, 1, 65535);
231
235
  config.relay.maxPayloadBytes = parseInteger(config.relay.maxPayloadBytes, DEFAULTS.relay.maxPayloadBytes, 4096, 16 * 1024 * 1024);
232
236
  config.relay.maxClientsPerHost = parseInteger(config.relay.maxClientsPerHost, DEFAULTS.relay.maxClientsPerHost, 1, 256);
237
+ config.relay.maxHosts = parseInteger(config.relay.maxHosts, DEFAULTS.relay.maxHosts, 1, 100000);
238
+ config.relay.maxPendingHandshakes = parseInteger(config.relay.maxPendingHandshakes, DEFAULTS.relay.maxPendingHandshakes, 16, 100000);
239
+ config.relay.maxBufferedBytesPerClient = parseInteger(
240
+ config.relay.maxBufferedBytesPerClient,
241
+ DEFAULTS.relay.maxBufferedBytesPerClient,
242
+ 64 * 1024,
243
+ 256 * 1024 * 1024,
244
+ );
245
+ config.relay.hostReconnectGraceMs = parseInteger(
246
+ config.relay.hostReconnectGraceMs,
247
+ DEFAULTS.relay.hostReconnectGraceMs,
248
+ 1000,
249
+ 24 * 60 * 60 * 1000,
250
+ );
233
251
  config.auth.pairingTtlMs = parseInteger(config.auth.pairingTtlMs, DEFAULTS.auth.pairingTtlMs, 30 * 1000, 24 * 60 * 60 * 1000);
234
252
  config.auth.deviceTtlMs = parseInteger(config.auth.deviceTtlMs, DEFAULTS.auth.deviceTtlMs, 60 * 1000, 365 * 24 * 60 * 60 * 1000);
235
253
  config.auth.maxDevices = parseInteger(config.auth.maxDevices, DEFAULTS.auth.maxDevices, 1, 10000);
@@ -277,6 +295,10 @@ function applyEnvironment(config) {
277
295
  if (env.RELAY_PORT) config.relay.port = env.RELAY_PORT;
278
296
  if (env.RELAY_PUBLIC_URL) config.relay.publicUrl = env.RELAY_PUBLIC_URL;
279
297
  if (env.RELAY_REMOTE_URL) config.relay.remoteUrl = env.RELAY_REMOTE_URL;
298
+ if (env.RELAY_MAX_HOSTS) config.relay.maxHosts = env.RELAY_MAX_HOSTS;
299
+ if (env.RELAY_MAX_PENDING_HANDSHAKES) config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
300
+ if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT) config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
301
+ if (env.RELAY_HOST_RECONNECT_GRACE_MS) config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
280
302
  if (env.HERDR_SOCKET_PATH) config.herdr.socketPath = env.HERDR_SOCKET_PATH;
281
303
  if (env.HERDR_CWD) config.herdr.cwd = env.HERDR_CWD;
282
304
  if (env.HERDR_ARGS_JSON) {
package/src/exit-codes.js CHANGED
@@ -10,5 +10,7 @@
10
10
  * one exit the supervisor must respect rather than recover from.
11
11
  */
12
12
  const EXIT_REPLACED = 12;
13
+ /** Credentials need operator intervention; a supervisor must not loop. */
14
+ const EXIT_AUTH_FAILED = 13;
13
15
 
14
- module.exports = { EXIT_REPLACED };
16
+ module.exports = { EXIT_REPLACED, EXIT_AUTH_FAILED };
@@ -1,9 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
3
4
  const os = require('node:os');
5
+ const path = require('node:path');
4
6
  const crypto = require('node:crypto');
5
7
  const { WebSocket } = require('ws');
6
- const { loadConfig, hostWebSocketUrl, resolveHostRelayUrl } = require('./config');
8
+ const { loadConfig, hostWebSocketUrl, resolveHostRelayUrl, stateDir } = require('./config');
9
+ const { ensureDir } = require('./state');
7
10
  const { resolveSocketPath, inspectSocket } = require('./socket-discovery');
8
11
  const { PtySession } = require('./pty-session');
9
12
  const { resolveHerdrCommand } = require('./herdr-command');
@@ -11,7 +14,7 @@ const { resolveHerdrCommand } = require('./herdr-command');
11
14
  // generated from one definition.
12
15
  const { packStreamFrame, unpackStreamFrame, PROTOCOL_VERSION } = require('herdr-remote-relay/protocol');
13
16
  const { resolveHostPalette } = require('./terminal-palette');
14
- const { EXIT_REPLACED } = require('./exit-codes');
17
+ const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
15
18
 
16
19
  function randomId(prefix) {
17
20
  return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
@@ -21,9 +24,19 @@ function sendJson(ws, payload) {
21
24
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(payload));
22
25
  }
23
26
 
24
- function closeSocket(ws) {
27
+ function closeSocket(ws, reason = 'host connector stopping') {
25
28
  if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) return;
26
- try { ws.close(1000, 'host connector stopping'); } catch {}
29
+ try { ws.close(1000, reason); } catch {}
30
+ }
31
+
32
+ function pidAlive(pid) {
33
+ if (!Number.isInteger(pid) || pid <= 0) return false;
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ } catch (error) {
38
+ return error.code === 'EPERM';
39
+ }
27
40
  }
28
41
 
29
42
  class HostConnector {
@@ -55,13 +68,62 @@ class HostConnector {
55
68
  this.reconnectTimer = null;
56
69
  this.heartbeatTimer = null;
57
70
  this.reconnectAttempts = 0;
71
+ this.clientCount = 0;
72
+ this.legacyHeartbeat = false;
73
+ this.ready = false;
74
+ this.authFailure = false;
58
75
  this.stopping = false;
76
+ this.lockPath = options.lockPath
77
+ || process.env.HERDR_REMOTE_HOST_LOCK
78
+ || path.join(stateDir(), 'host-connector.lock');
79
+ this.lockFd = null;
80
+ }
81
+
82
+ acquireLock() {
83
+ if (this.lockFd !== null) return;
84
+ ensureDir(path.dirname(this.lockPath));
85
+ for (let attempt = 0; attempt < 2; attempt += 1) {
86
+ try {
87
+ const fd = fs.openSync(this.lockPath, 'wx', 0o600);
88
+ fs.writeFileSync(fd, `${JSON.stringify({ pid: process.pid, hostId: this.hostId, startedAt: new Date().toISOString() })}\n`);
89
+ this.lockFd = fd;
90
+ return;
91
+ } catch (error) {
92
+ if (error.code !== 'EEXIST') throw error;
93
+ let owner = null;
94
+ try { owner = JSON.parse(fs.readFileSync(this.lockPath, 'utf8')); } catch {}
95
+ if (owner && pidAlive(owner.pid)) {
96
+ const duplicate = new Error(`another host connector is already running (pid ${owner.pid})`);
97
+ duplicate.code = 'HOST_ALREADY_RUNNING';
98
+ throw duplicate;
99
+ }
100
+ try { fs.rmSync(this.lockPath, { force: true }); } catch {}
101
+ }
102
+ }
103
+ const stale = new Error('could not acquire host connector lock');
104
+ stale.code = 'HOST_LOCK_FAILED';
105
+ throw stale;
106
+ }
107
+
108
+ releaseLock() {
109
+ const owned = this.lockFd !== null;
110
+ if (this.lockFd !== null) {
111
+ try { fs.closeSync(this.lockFd); } catch {}
112
+ this.lockFd = null;
113
+ }
114
+ if (!owned) return;
115
+ try {
116
+ const owner = JSON.parse(fs.readFileSync(this.lockPath, 'utf8'));
117
+ if (owner.pid !== process.pid) return;
118
+ } catch {}
119
+ try { fs.rmSync(this.lockPath, { force: true }); } catch {}
59
120
  }
60
121
 
61
122
  start() {
62
123
  if (!this.hostToken) {
63
124
  throw new Error('RELAY_HOST_TOKEN is required');
64
125
  }
126
+ this.acquireLock();
65
127
  this.stopping = false;
66
128
  this.connect();
67
129
  }
@@ -72,9 +134,14 @@ class HostConnector {
72
134
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
73
135
  this.reconnectTimer = null;
74
136
  this.heartbeatTimer = null;
137
+ sendJson(this.ws, { type: 'host_shutdown' });
75
138
  this.destroySessions();
76
- closeSocket(this.ws);
139
+ closeSocket(this.ws, 'host_shutdown');
77
140
  this.ws = null;
141
+ this.ready = false;
142
+ this.clientCount = 0;
143
+ this.legacyHeartbeat = false;
144
+ this.releaseLock();
78
145
  }
79
146
 
80
147
  connect() {
@@ -89,8 +156,9 @@ class HostConnector {
89
156
  this.ws = ws;
90
157
  ws.isAlive = true;
91
158
  ws.on('open', () => {
92
- this.reconnectAttempts = 0;
93
159
  ws.isAlive = true;
160
+ this.ready = false;
161
+ this.authFailure = false;
94
162
  sendJson(ws, {
95
163
  type: 'host_hello',
96
164
  protocol: PROTOCOL_VERSION,
@@ -101,16 +169,24 @@ class HostConnector {
101
169
  platform: process.platform,
102
170
  arch: process.arch,
103
171
  terminalPalette: this.terminalPalette || null,
172
+ capabilities: ['host_handoff', 'idle_heartbeat'],
104
173
  });
105
- this.sendHeartbeat();
106
- this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
174
+ // The relay sends host_ready with the current browser count. No business
175
+ // heartbeat is started until that message says somebody is watching.
107
176
  });
108
177
  ws.on('pong', () => { ws.isAlive = true; });
109
178
  ws.on('message', (raw, isBinary) => this.handleMessage(raw, isBinary));
110
179
  ws.on('close', (code, rawReason) => {
111
- if (this.ws === ws) this.ws = null;
180
+ // A replacement socket may be live while an older socket is still
181
+ // delivering its close event. Never let that stale event destroy the new
182
+ // session or clear its heartbeat timer.
183
+ if (this.ws !== ws) return;
184
+ this.ws = null;
112
185
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
113
186
  this.heartbeatTimer = null;
187
+ this.ready = false;
188
+ this.clientCount = 0;
189
+ this.legacyHeartbeat = false;
114
190
  this.destroySessions();
115
191
 
116
192
  // Another connector has claimed this workstation. Reconnecting would just
@@ -123,6 +199,12 @@ class HostConnector {
123
199
  this.stop();
124
200
  process.exit(EXIT_REPLACED);
125
201
  }
202
+ if (this.authFailure) {
203
+ this.stopping = true;
204
+ process.stderr.write('herdr-remote host connector: authentication failed; update relay credentials and restart the service\n');
205
+ this.releaseLock();
206
+ process.exit(EXIT_AUTH_FAILED);
207
+ }
126
208
  this.scheduleReconnect();
127
209
  });
128
210
  ws.on('error', (error) => {
@@ -160,9 +242,29 @@ class HostConnector {
160
242
  // connection just closed silently and reconnected forever, leaving the user
161
243
  // with an empty log and no idea what was wrong.
162
244
  if (message.type === 'error' && !message.clientId) {
245
+ this.authFailure = ['relay_password_required', 'host_auth_failed', 'invalid_host_credentials'].includes(message.code);
163
246
  process.stderr.write(`herdr-remote host connector: relay rejected the connection: ${message.message || message.code}\n`);
164
247
  return;
165
248
  }
249
+ if (message.type === 'host_ready') {
250
+ this.ready = true;
251
+ this.reconnectAttempts = 0;
252
+ if (Object.hasOwn(message, 'clientCount')) {
253
+ this.legacyHeartbeat = false;
254
+ this.setClientCount(message.clientCount);
255
+ } else {
256
+ // An older relay does not know client_count. Keep its historical
257
+ // telemetry behavior so rolling upgrades do not silently lose status.
258
+ this.legacyHeartbeat = true;
259
+ this.sendHeartbeat(true);
260
+ this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
261
+ }
262
+ return;
263
+ }
264
+ if (message.type === 'client_count') {
265
+ this.setClientCount(message.clientCount);
266
+ return;
267
+ }
166
268
  if (message.type === 'session_start') this.startSession(message);
167
269
  else if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
168
270
  else if (message.type === 'resize') this.resizeSession(message);
@@ -224,6 +326,22 @@ class HostConnector {
224
326
  this.sendHeartbeat();
225
327
  }
226
328
 
329
+ setClientCount(value) {
330
+ const next = Number.isInteger(value) ? Math.max(0, value) : 0;
331
+ if (next === this.clientCount && (next === 0 || this.heartbeatTimer)) return;
332
+ const wasActive = this.clientCount > 0;
333
+ this.clientCount = next;
334
+ if (next > 0) {
335
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
336
+ this.sendHeartbeat(true);
337
+ this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
338
+ } else {
339
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
340
+ this.heartbeatTimer = null;
341
+ if (wasActive) this.sendHeartbeat(true);
342
+ }
343
+ }
344
+
227
345
  resizeSession(message) {
228
346
  const id = message.clientId || message.streamId;
229
347
  const session = this.sessions.get(id);
@@ -238,7 +356,8 @@ class HostConnector {
238
356
  this.sessions.clear();
239
357
  }
240
358
 
241
- sendHeartbeat() {
359
+ sendHeartbeat(force = false) {
360
+ if (!force && this.clientCount <= 0 && !this.legacyHeartbeat) return;
242
361
  const memory = process.memoryUsage();
243
362
  const load = os.loadavg();
244
363
  sendJson(this.ws, {
@@ -270,7 +389,7 @@ if (require.main === module) {
270
389
  connector.start();
271
390
  } catch (error) {
272
391
  process.stderr.write(`herdr-remote host connector failed: ${error.message}\n`);
273
- process.exitCode = 1;
392
+ process.exitCode = error.code === 'HOST_ALREADY_RUNNING' ? EXIT_REPLACED : 1;
274
393
  }
275
394
  const stop = () => { connector.stop(); process.exit(0); };
276
395
  process.once('SIGINT', stop);
package/src/i18n/en.js CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  module.exports = {
7
7
  'app.name': 'Herdr Remote',
8
- 'app.tagline': 'Remote browser access to your Herdr workspaces',
8
+ 'app.tagline': 'Remote browser access to Herdr workspaces',
9
9
 
10
10
  'common.yes': 'Yes',
11
11
  'common.no': 'No',
@@ -41,12 +41,12 @@ module.exports = {
41
41
 
42
42
  'mode.local': 'This machine only',
43
43
  'mode.lan': 'Local network / Tailscale',
44
- 'mode.official': 'Official relay (no server needed)',
44
+ 'mode.official': 'Official relay',
45
45
  'mode.remote': 'Self-hosted relay',
46
- 'mode.local.description': 'The relay listens on 127.0.0.1. Only a browser on this machine can reach it.',
47
- 'mode.lan.description': 'The relay listens on 0.0.0.0 (every interface), so phones on your LAN or tailnet can reach it.',
48
- 'mode.official.description': 'Reach this machine from anywhere through the relay run by the Herdr Remote project. Nothing to deploy. Your terminal traffic passes through a server you do not control, and only devices you pair can open it.',
49
- 'mode.remote.description': 'No local relay. The workstation dials a relay you run, which is the only way in from outside your network.',
46
+ 'mode.local.description': 'Relay listens on 127.0.0.1. Local browser only.',
47
+ 'mode.lan.description': 'Relay listens on 0.0.0.0. Accessible over LAN or Tailscale.',
48
+ 'mode.official.description': 'Connect via official relay. No server needed.',
49
+ 'mode.remote.description': 'Connect via self-hosted relay for external access.',
50
50
 
51
51
  'overview.title': 'Status',
52
52
  'overview.mode': 'Access mode',
@@ -62,21 +62,21 @@ module.exports = {
62
62
  'overview.relayRemote': 'remote, {url}',
63
63
  'overview.socketMissing': 'not found — is Herdr running?',
64
64
  'overview.unreachable': 'unreachable: {message}',
65
- 'overview.notStarted': 'Services are not running. Open the Services tab to start them.',
65
+ 'overview.notStarted': 'Services not running. Open Services tab to start.',
66
66
 
67
67
  'pair.title': 'Pair a device',
68
- 'pair.generate': 'Generate a one-time pairing code',
69
- 'pair.regenerate': 'Generate another code',
70
- 'pair.working': 'Starting services and requesting a code…',
68
+ 'pair.generate': 'Generate pairing code',
69
+ 'pair.regenerate': 'Regenerate code',
70
+ 'pair.working': 'Generating pairing code…',
71
71
  'pair.code': 'Pairing code',
72
- 'pair.url': 'Open on your phone',
72
+ 'pair.url': 'Access URL',
73
73
  'pair.expires': 'Expires in {minutes} (at {time})',
74
- 'pair.expired': 'This code has expired. Generate a new one.',
75
- 'pair.instructions': 'Open the URL, enter the code, and the browser is paired for good.',
76
- 'pair.qrHint': 'Scan the code with your phone camera to open the URL.',
77
- 'pair.qrUnavailable': 'The terminal is too narrow for a QR code; use the URL above.',
78
- 'pair.failed': 'Could not create a pairing code: {message}',
79
- 'pair.hostOffline': 'The relay has no workstation connected yet. Start the services first.',
74
+ 'pair.expired': 'Code expired. Generate a new one.',
75
+ 'pair.instructions': 'Open URL and enter code to pair.',
76
+ 'pair.qrHint': 'Scan QR code to open URL.',
77
+ 'pair.qrUnavailable': 'Terminal too narrow for QR code; use URL above.',
78
+ 'pair.failed': 'Could not create pairing code: {message}',
79
+ 'pair.hostOffline': 'Relay has no workstation connected. Start services first.',
80
80
 
81
81
  'services.title': 'Services',
82
82
  'services.start': 'Start services',
@@ -87,58 +87,61 @@ module.exports = {
87
87
  'services.restarted': 'Services restarted.',
88
88
  'services.startFailed': 'Could not start services: {message}',
89
89
  'services.stopFailed': 'Could not stop services: {message}',
90
- 'services.unsavedBlocked': 'There are unsaved changes. Save them first, or the services restart on the configuration still on disk.',
91
- 'services.managedNotice': 'These services are managed by {manager}; the keep-alive unit was used to apply the change.',
92
- 'services.logs': 'Recent log output',
93
- 'services.logEmpty': 'No log output yet.',
90
+ 'services.unsavedBlocked': 'Unsaved changes. Save before restarting.',
91
+ 'services.managedNotice': 'Managed by {manager}; applied via keep-alive service.',
92
+ 'services.logs': 'Recent logs',
93
+ 'services.logEmpty': 'No logs.',
94
94
  'services.logRelay': 'Relay',
95
95
  'services.logHost': 'Host connector',
96
96
 
97
97
  'relay.title': 'Relay settings',
98
98
  'relay.settings': 'Settings',
99
+ 'relay.locked': 'fixed',
100
+ 'relay.officialFixed': 'The official relay address is fixed and cannot be edited.',
101
+ 'relay.officialHint': 'Official relay: address is fixed and no password is needed.',
99
102
  'relay.password': 'Relay password',
100
- 'relay.passwordHint': 'Must match RELAY_PASSWORD on the relay. Leave empty for a public relay.',
103
+ 'relay.passwordHint': 'Must match RELAY_PASSWORD on relay. Leave empty if public.',
101
104
  'relay.passwordSet': 'set',
102
- 'relay.passwordEmpty': 'not set (public relay)',
103
- 'relay.passwordSaved': 'Relay password saved. Restart the services to apply it.',
104
- 'relay.showPassword': 'Show the password',
105
- 'relay.hidePassword': 'Hide the password',
106
- 'relay.identity': 'Workstation id',
107
- 'relay.regenerate': 'Generate a new workstation identity',
108
- 'relay.regenerated': 'New workstation identity generated. Restart the services to enrol again.',
109
- 'relay.envSnippet': 'Command for your relay server',
110
- 'relay.envHint': 'Run this on the relay host.',
111
- 'relay.test': 'Test the relay connection',
105
+ 'relay.passwordEmpty': 'not set (public)',
106
+ 'relay.passwordSaved': 'Relay password saved. Restart services to apply.',
107
+ 'relay.showPassword': 'Show password',
108
+ 'relay.hidePassword': 'Hide password',
109
+ 'relay.identity': 'Workstation ID',
110
+ 'relay.regenerate': 'Generate new ID',
111
+ 'relay.regenerated': 'New workstation identity generated. Restart services to re-register.',
112
+ 'relay.envSnippet': 'Relay startup command',
113
+ 'relay.envHint': 'Run this command on the relay host.',
114
+ 'relay.test': 'Test relay connection',
112
115
  'relay.testOk': 'Relay reachable: version {version}, {hosts} workstation(s) connected.',
113
116
  'relay.testFailed': 'Relay unreachable: {message}',
114
- 'relay.selectAddress': 'Choose the address to advertise',
117
+ 'relay.selectAddress': 'Choose advertised address',
115
118
  'relay.listenAddress': 'Listen address',
116
- 'relay.listenAddressHint': 'This is the server bind address. The advertised address below is what browsers open.',
119
+ 'relay.listenAddressHint': 'Server bind address. Browsers use advertised address below.',
117
120
  'relay.addressTailscale': 'Tailscale',
118
121
  'relay.addressLan': 'LAN',
119
122
  'relay.addressVirtual': 'virtual',
120
123
  'relay.addressLoopback': 'loopback',
121
- 'relay.noAddresses': 'No non-loopback addresses found. Connect to a network or start Tailscale.',
122
- 'relay.docsHint': 'Running your own relay: see docs/self-hosted-relay.md',
124
+ 'relay.noAddresses': 'No non-loopback addresses found.',
125
+ 'relay.docsHint': 'Self-hosting guide: docs/self-hosted-relay.md',
123
126
 
124
127
  'keepalive.title': 'Keep-alive service',
125
128
  'keepalive.manager': 'Manager',
126
129
  'keepalive.state': 'State',
127
130
  'keepalive.install': 'Install and start',
128
131
  'keepalive.uninstall': 'Stop and remove',
129
- 'keepalive.restart': 'Restart the service',
132
+ 'keepalive.restart': 'Restart service',
130
133
  'keepalive.installed': 'Keep-alive installed with {manager}.',
131
134
  'keepalive.uninstalled': 'Keep-alive removed.',
132
135
  'keepalive.restarted': 'Keep-alive service restarted.',
133
136
  'keepalive.failed': 'Keep-alive operation failed: {message}',
134
137
  'keepalive.unitPath': 'Unit file',
135
- 'keepalive.logsHint': 'Follow logs with: {command}',
136
- 'keepalive.linger': 'Start at boot without logging in',
137
- 'keepalive.lingerEnabled': 'Lingering is enabled: the service starts at boot.',
138
- 'keepalive.lingerDisabled': 'Lingering is off, so the service only runs while you are logged in.',
138
+ 'keepalive.logsHint': 'View logs: {command}',
139
+ 'keepalive.linger': 'Start at boot',
140
+ 'keepalive.lingerEnabled': 'Start at boot enabled.',
141
+ 'keepalive.lingerDisabled': 'Start at boot disabled (runs while logged in).',
139
142
  'keepalive.enableLinger': 'Enable start at boot',
140
- 'keepalive.lingerDone': 'Lingering enabled for {username}.',
141
- 'keepalive.fallbackNote': 'No system service manager is available; a supervised background process is used instead. It does not survive a reboot.',
143
+ 'keepalive.lingerDone': 'Start at boot enabled for {username}.',
144
+ 'keepalive.fallbackNote': 'No system service manager found; background process will not survive reboot.',
142
145
 
143
146
  'herdr.title': 'Herdr integration',
144
147
  'herdr.socketPath': 'Socket path',
@@ -146,16 +149,16 @@ module.exports = {
146
149
  'herdr.plugin': 'Plugin registration',
147
150
  'herdr.pluginRegistered': 'Registered with Herdr.',
148
151
  'herdr.pluginMissing': 'Not registered with Herdr.',
149
- 'herdr.register': 'Register this package as a Herdr plugin',
150
- 'herdr.unregister': 'Unregister the Herdr plugin',
152
+ 'herdr.register': 'Register as Herdr plugin',
153
+ 'herdr.unregister': 'Unregister plugin',
151
154
  'herdr.registerDone': 'Registered: {path}',
152
155
  'herdr.unregisterDone': 'Plugin unregistered.',
153
156
  'herdr.registerFailed': 'Registration failed: {message}',
154
- 'herdr.cliMissing': 'The herdr command was not found on PATH.',
157
+ 'herdr.cliMissing': 'Command herdr not found in PATH.',
155
158
 
156
159
  'about.title': 'Language & about',
157
160
  'about.language': 'Interface language',
158
- 'about.languageAuto': 'Follow the system ({detected})',
161
+ 'about.languageAuto': 'Follow system ({detected})',
159
162
  'about.languageZh': '中文',
160
163
  'about.languageEn': 'English',
161
164
  'about.version': 'Version',
@@ -166,20 +169,20 @@ module.exports = {
166
169
 
167
170
  'wizard.title': 'First-time setup',
168
171
  'wizard.step': 'Step {current} of {total}',
169
- 'wizard.languageTitle': 'Choose your language',
170
- 'wizard.accessTitle': 'How do you want to reach this machine?',
171
- 'wizard.accessHint': 'You can change this later on the Relay screen.',
172
- 'wizard.addressTitle': 'Which address should phones use?',
173
- 'wizard.relayTitle': 'Your relay server',
172
+ 'wizard.languageTitle': 'Choose language',
173
+ 'wizard.accessTitle': 'Access mode',
174
+ 'wizard.accessHint': 'Can be changed later in Relay settings.',
175
+ 'wizard.addressTitle': 'Browser access address',
176
+ 'wizard.relayTitle': 'Relay server',
174
177
  'wizard.relayUrlLabel': 'Relay URL (wss://…)',
175
- 'wizard.relayHint': 'The relay must already be running. See docs/self-hosted-relay.md to set one up.',
178
+ 'wizard.relayHint': 'Relay must be running. See docs/self-hosted-relay.md.',
176
179
  'wizard.passwordTitle': 'Relay password',
177
- 'wizard.passwordHint': 'The RELAY_PASSWORD your relay was started with. Leave it empty if the relay has none.',
178
- 'wizard.finishTitle': 'Ready',
180
+ 'wizard.passwordHint': 'RELAY_PASSWORD of the relay. Leave empty if unset.',
181
+ 'wizard.finishTitle': 'Finish',
179
182
  'wizard.finishHint': 'Configuration will be saved to {path}.',
180
- 'wizard.startNow': 'Start the services now',
181
- 'wizard.installKeepalive': 'Keep the services running in the background',
182
- 'wizard.finish': 'Save and continue',
183
+ 'wizard.startNow': 'Start services now',
184
+ 'wizard.installKeepalive': 'Run in background',
185
+ 'wizard.finish': 'Save and finish',
183
186
 
184
187
  'field.mode': 'Access mode',
185
188
  'field.port': 'Relay port',
@@ -195,15 +198,15 @@ module.exports = {
195
198
  'placeholder.none': 'none',
196
199
 
197
200
  'error.invalidMode': 'Unknown access mode.',
198
- 'error.invalidPort': 'The port must be a number between 1 and 65535.',
199
- 'error.invalidLanHost': 'The browser address must be a reachable LAN or Tailscale address, not loopback or 0.0.0.0.',
200
- 'error.invalidRelayUrl': 'The relay URL must start with wss://, ws://, https:// or http://.',
201
- 'error.invalidPublicUrl': 'The browser URL must start with https:// or http:// and cannot use 0.0.0.0 or ::.',
201
+ 'error.invalidPort': 'Port must be a number between 1 and 65535.',
202
+ 'error.invalidLanHost': 'Browser address must be a reachable LAN or Tailscale address, not loopback or 0.0.0.0.',
203
+ 'error.invalidRelayUrl': 'Relay URL must start with wss://, ws://, https:// or http://.',
204
+ 'error.invalidPublicUrl': 'Browser URL must start with https:// or http:// and cannot use 0.0.0.0 or ::.',
202
205
  'error.invalidLanguage': 'Unknown language.',
203
206
  'error.invalidKeepalive': 'Unknown keep-alive manager.',
204
207
  'error.unknownField': 'Unknown setting.',
205
- 'error.remoteUrlRequired': 'A relay URL is required for the self-hosted relay mode.',
206
- 'error.saveFailed': 'Could not save the configuration: {message}',
208
+ 'error.remoteUrlRequired': 'Relay URL is required for self-hosted relay mode.',
209
+ 'error.saveFailed': 'Could not save configuration: {message}',
207
210
 
208
211
  'hint.navigate': '↑↓ move',
209
212
  'hint.select': '↵ select',
@@ -214,20 +217,21 @@ module.exports = {
214
217
  'hint.mouseOn': 'm mouse off',
215
218
  'hint.mouseOff': 'm mouse on',
216
219
  'hint.mouseUnsupported': 'mouse unsupported',
217
- 'hint.restartRequired': 'Restart the services to apply these changes.',
218
- 'hint.unsavedChanges': 'Unsaved changes press s to save.',
220
+ 'hint.restartRequired': 'Restart services to apply changes.',
221
+ 'hint.unsavedChanges': 'Unsaved changes (press s to save).',
219
222
  'hint.save': 's save',
220
223
  'hint.editing': '↵ confirm · esc cancel',
221
224
  'update.title': 'Updates',
222
225
  'update.check': 'Check for updates',
223
226
  'update.checking': 'Checking npm…',
224
227
  'update.upToDate': 'Up to date ({version})',
225
- 'update.available': 'Update available: {version} press Enter to install',
228
+ 'update.available': 'Update {version} available (press Enter to install)',
226
229
  'update.updating': 'Installing {version}…',
227
230
  'update.done': 'Updated to {version}',
228
- 'update.restartHint': 'Update installed. Restart herdr-remote to use it.',
229
- 'update.errorNetwork': 'Could not reach the npm registry',
230
- 'update.errorFailed': 'The update failed. Try: npm install -g herdr-remote@latest',
231
- 'update.cannot.source': 'Running from a source checkout update with git, not npm',
232
- 'update.cannot.linked': 'Running from a linked working copy npm link manages this one',
231
+ 'update.restartHint': 'Update installed. Restart herdr-remote to apply.',
232
+ 'update.errorNetwork': 'Could not reach any npm registry',
233
+ 'update.errorNetworkDetail': 'No registry answered: {message}',
234
+ 'update.errorFailed': 'Update failed. Run: npm install -g herdr-remote@latest',
235
+ 'update.cannot.source': 'Source checkoutupdate with git',
236
+ 'update.cannot.linked': 'Linked package — managed by npm link',
233
237
  };