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/README.md +16 -121
- package/README.zh-CN.md +50 -0
- package/bin/herdr-remote.js +14 -15
- package/config.example.json +5 -1
- package/dist/tui.mjs +353 -191
- package/herdr-plugin.toml +6 -6
- package/package.json +4 -3
- package/src/config.js +22 -0
- package/src/exit-codes.js +3 -1
- package/src/host-connector.js +130 -11
- package/src/i18n/en.js +76 -72
- package/src/i18n/zh.js +77 -73
- package/src/service.js +18 -6
- package/src/settings-model.js +44 -1
- package/src/supervisor.js +9 -1
- package/src/updater.js +127 -23
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 = "
|
|
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
|
|
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
|
|
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
|
|
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 = "
|
|
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 = "
|
|
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.
|
|
4
|
-
"description": "
|
|
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 };
|
package/src/host-connector.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
44
|
+
'mode.official': 'Official relay',
|
|
45
45
|
'mode.remote': 'Self-hosted relay',
|
|
46
|
-
'mode.local.description': '
|
|
47
|
-
'mode.lan.description': '
|
|
48
|
-
'mode.official.description': '
|
|
49
|
-
'mode.remote.description': '
|
|
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
|
|
65
|
+
'overview.notStarted': 'Services not running. Open Services tab to start.',
|
|
66
66
|
|
|
67
67
|
'pair.title': 'Pair a device',
|
|
68
|
-
'pair.generate': 'Generate
|
|
69
|
-
'pair.regenerate': '
|
|
70
|
-
'pair.working': '
|
|
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': '
|
|
72
|
+
'pair.url': 'Access URL',
|
|
73
73
|
'pair.expires': 'Expires in {minutes} (at {time})',
|
|
74
|
-
'pair.expired': '
|
|
75
|
-
'pair.instructions': 'Open
|
|
76
|
-
'pair.qrHint': 'Scan
|
|
77
|
-
'pair.qrUnavailable': '
|
|
78
|
-
'pair.failed': 'Could not create
|
|
79
|
-
'pair.hostOffline': '
|
|
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': '
|
|
91
|
-
'services.managedNotice': '
|
|
92
|
-
'services.logs': 'Recent
|
|
93
|
-
'services.logEmpty': 'No
|
|
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
|
|
103
|
+
'relay.passwordHint': 'Must match RELAY_PASSWORD on relay. Leave empty if public.',
|
|
101
104
|
'relay.passwordSet': 'set',
|
|
102
|
-
'relay.passwordEmpty': 'not set (public
|
|
103
|
-
'relay.passwordSaved': 'Relay password saved. Restart
|
|
104
|
-
'relay.showPassword': 'Show
|
|
105
|
-
'relay.hidePassword': 'Hide
|
|
106
|
-
'relay.identity': 'Workstation
|
|
107
|
-
'relay.regenerate': 'Generate
|
|
108
|
-
'relay.regenerated': 'New workstation identity generated. Restart
|
|
109
|
-
'relay.envSnippet': '
|
|
110
|
-
'relay.envHint': 'Run this on the relay host.',
|
|
111
|
-
'relay.test': 'Test
|
|
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
|
|
117
|
+
'relay.selectAddress': 'Choose advertised address',
|
|
115
118
|
'relay.listenAddress': 'Listen address',
|
|
116
|
-
'relay.listenAddressHint': '
|
|
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.
|
|
122
|
-
'relay.docsHint': '
|
|
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
|
|
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': '
|
|
136
|
-
'keepalive.linger': 'Start at boot
|
|
137
|
-
'keepalive.lingerEnabled': '
|
|
138
|
-
'keepalive.lingerDisabled': '
|
|
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': '
|
|
141
|
-
'keepalive.fallbackNote': 'No system service manager
|
|
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
|
|
150
|
-
'herdr.unregister': 'Unregister
|
|
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': '
|
|
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
|
|
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
|
|
170
|
-
'wizard.accessTitle': '
|
|
171
|
-
'wizard.accessHint': '
|
|
172
|
-
'wizard.addressTitle': '
|
|
173
|
-
'wizard.relayTitle': '
|
|
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': '
|
|
178
|
+
'wizard.relayHint': 'Relay must be running. See docs/self-hosted-relay.md.',
|
|
176
179
|
'wizard.passwordTitle': 'Relay password',
|
|
177
|
-
'wizard.passwordHint': '
|
|
178
|
-
'wizard.finishTitle': '
|
|
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
|
|
181
|
-
'wizard.installKeepalive': '
|
|
182
|
-
'wizard.finish': 'Save and
|
|
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': '
|
|
199
|
-
'error.invalidLanHost': '
|
|
200
|
-
'error.invalidRelayUrl': '
|
|
201
|
-
'error.invalidPublicUrl': '
|
|
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': '
|
|
206
|
-
'error.saveFailed': 'Could not save
|
|
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
|
|
218
|
-
'hint.unsavedChanges': 'Unsaved changes
|
|
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
|
|
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
|
|
229
|
-
'update.errorNetwork': 'Could not reach
|
|
230
|
-
'update.
|
|
231
|
-
'update.
|
|
232
|
-
'update.cannot.
|
|
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 checkout — update with git',
|
|
236
|
+
'update.cannot.linked': 'Linked package — managed by npm link',
|
|
233
237
|
};
|