herdr-remote 0.2.15 → 0.2.17
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/config.example.json +2 -1
- package/dist/tui.mjs +294 -269
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/config.js +4 -0
- package/src/herdr-server.js +201 -0
- package/src/host-connector.js +172 -6
- package/src/i18n/en.js +9 -9
- package/src/i18n/zh.js +9 -9
- package/src/lifecycle.js +6 -2
- package/src/settings-model.js +11 -0
- package/src/socket-discovery.js +6 -1
- package/src/updater.js +203 -61
package/herdr-plugin.toml
CHANGED
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -77,6 +77,9 @@ const DEFAULTS = {
|
|
|
77
77
|
socketPath: null,
|
|
78
78
|
args: [],
|
|
79
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,
|
|
80
83
|
},
|
|
81
84
|
auth: {
|
|
82
85
|
pairingTtlMs: 10 * 60 * 1000,
|
|
@@ -288,6 +291,7 @@ function validate(config) {
|
|
|
288
291
|
if (typeof config.herdr.cwd !== 'string' || config.herdr.cwd.length === 0) {
|
|
289
292
|
config.herdr.cwd = os.homedir();
|
|
290
293
|
}
|
|
294
|
+
config.herdr.autoStart = config.herdr.autoStart === true;
|
|
291
295
|
|
|
292
296
|
// A "remote" config without a relay URL cannot reach anything; fall back to
|
|
293
297
|
// local rather than silently starting a host connector that dials nowhere.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Whether Herdr's server is running on this workstation, and starting it when
|
|
4
|
+
// the user asked for that.
|
|
5
|
+
//
|
|
6
|
+
// herdr-remote never starts Herdr on its own initiative. It starts one when a
|
|
7
|
+
// browser paired to this workstation asks for it, or at service start when the
|
|
8
|
+
// user switched that on — and both go through here.
|
|
9
|
+
//
|
|
10
|
+
// Two traps shape this file:
|
|
11
|
+
//
|
|
12
|
+
// - A socket file is not a server. Herdr leaves `herdr.sock` behind when it dies
|
|
13
|
+
// without cleaning up (a crash, a power cut), and a `herdr` client pointed at
|
|
14
|
+
// a dead socket quietly starts a server of its own. So "running" means a
|
|
15
|
+
// connection was accepted, not that the path exists.
|
|
16
|
+
// - A server started from inside herdr-remote's systemd service lives in that
|
|
17
|
+
// service's cgroup, and `systemctl restart herdr-remote` — an update, a keep-
|
|
18
|
+
// alive restart, Herdr's own plugin startup hook — would kill it along with
|
|
19
|
+
// every agent in it. Under a service it is therefore started in a transient
|
|
20
|
+
// scope of its own, the same place it would live had the user started it
|
|
21
|
+
// from a terminal.
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const net = require('node:net');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const { spawn } = require('node:child_process');
|
|
27
|
+
const { inspectSocket } = require('./socket-discovery');
|
|
28
|
+
const { ensureDir } = require('./state');
|
|
29
|
+
|
|
30
|
+
/** A Unix socket either accepts at once or is dead; this only guards a hang. */
|
|
31
|
+
const PROBE_TIMEOUT_MS = 1_000;
|
|
32
|
+
/** Herdr restores the saved session before its API socket opens. */
|
|
33
|
+
const START_TIMEOUT_MS = 20_000;
|
|
34
|
+
const START_POLL_MS = 200;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Is Herdr's server answering on `socketPath`?
|
|
38
|
+
*
|
|
39
|
+
* Resolves `{ state }`: `running`, `stopped` (no socket, or a socket nothing is
|
|
40
|
+
* listening on), or `unavailable` with a `reason` — a path that is not a socket
|
|
41
|
+
* or belongs to another user. That last one is never "stopped": the socket may
|
|
42
|
+
* well be live, just not ours to use, and starting a second server beside it
|
|
43
|
+
* would be starting somebody else's Herdr.
|
|
44
|
+
*/
|
|
45
|
+
function probeHerdrServer(socketPath, { connect = net.connect, timeout = PROBE_TIMEOUT_MS } = {}) {
|
|
46
|
+
const info = inspectSocket(socketPath);
|
|
47
|
+
if (!info.ok) {
|
|
48
|
+
return Promise.resolve(info.missing ? { state: 'stopped' } : { state: 'unavailable', reason: info.reason });
|
|
49
|
+
}
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
let socket;
|
|
52
|
+
const finish = (result) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
if (socket) {
|
|
55
|
+
socket.removeAllListeners();
|
|
56
|
+
socket.on('error', () => {});
|
|
57
|
+
socket.destroy();
|
|
58
|
+
}
|
|
59
|
+
resolve(result);
|
|
60
|
+
};
|
|
61
|
+
// A socket that neither accepts nor refuses has a listener behind it that
|
|
62
|
+
// is slow, not absent; starting another server would only fight it.
|
|
63
|
+
const timer = setTimeout(() => finish({ state: 'running' }), timeout);
|
|
64
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
65
|
+
socket = connect(socketPath);
|
|
66
|
+
socket.once('connect', () => finish({ state: 'running' }));
|
|
67
|
+
socket.once('error', (error) => finish(
|
|
68
|
+
error.code === 'ECONNREFUSED' || error.code === 'ENOENT'
|
|
69
|
+
? { state: 'stopped', stale: true }
|
|
70
|
+
: { state: 'unavailable', reason: error.message },
|
|
71
|
+
));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* True when this process is a systemd service (herdr-remote's keep-alive), so
|
|
77
|
+
* anything it starts would be killed with it.
|
|
78
|
+
*/
|
|
79
|
+
function runningInSystemdService({ platform = process.platform, readCgroup = () => fs.readFileSync('/proc/self/cgroup', 'utf8') } = {}) {
|
|
80
|
+
if (platform !== 'linux') return false;
|
|
81
|
+
try {
|
|
82
|
+
const unified = readCgroup().split('\n').find((line) => line.startsWith('0::'));
|
|
83
|
+
return Boolean(unified) && /\.service$/.test(unified.trim());
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The server's environment: the one a Herdr pane would give its client. */
|
|
90
|
+
function serverEnv(socketPath, env = process.env) {
|
|
91
|
+
const next = { ...env };
|
|
92
|
+
for (const key of Object.keys(next)) {
|
|
93
|
+
if (key.startsWith('HERDR_')) delete next[key];
|
|
94
|
+
}
|
|
95
|
+
if (socketPath) next.HERDR_SOCKET_PATH = socketPath;
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The argv that starts Herdr's server: Herdr's own global arguments (a
|
|
101
|
+
* `--session`, say) followed by `server`, wrapped in `systemd-run --scope` when
|
|
102
|
+
* the caller is a systemd service.
|
|
103
|
+
*/
|
|
104
|
+
function serverCommand({ command, args = [], inService = runningInSystemdService() }) {
|
|
105
|
+
const herdr = [command, ...args, 'server'];
|
|
106
|
+
if (!inService) return { file: herdr[0], args: herdr.slice(1) };
|
|
107
|
+
return {
|
|
108
|
+
file: 'systemd-run',
|
|
109
|
+
args: ['--user', '--scope', '--quiet', '--collect', '--description=Herdr server started by herdr-remote', '--', ...herdr],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Start Herdr's server in the background. Detached from this process, with its
|
|
115
|
+
* output appended to `logPath` rather than piped here: a pipe would break the
|
|
116
|
+
* moment herdr-remote exits and take the server with it.
|
|
117
|
+
*/
|
|
118
|
+
function launchHerdrServer({ command, args = [], socketPath, cwd, logPath, spawnProcess = spawn, inService }) {
|
|
119
|
+
const { file, args: argv } = serverCommand({ command, args, inService });
|
|
120
|
+
ensureDir(path.dirname(logPath));
|
|
121
|
+
const out = fs.openSync(logPath, 'a');
|
|
122
|
+
let child;
|
|
123
|
+
try {
|
|
124
|
+
child = spawnProcess(file, argv, {
|
|
125
|
+
cwd,
|
|
126
|
+
env: serverEnv(socketPath),
|
|
127
|
+
detached: true,
|
|
128
|
+
stdio: ['ignore', out, out],
|
|
129
|
+
});
|
|
130
|
+
} finally {
|
|
131
|
+
fs.closeSync(out);
|
|
132
|
+
}
|
|
133
|
+
child.on('error', () => {});
|
|
134
|
+
child.unref();
|
|
135
|
+
return child;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readLogTail(logPath, bytes = 2_000) {
|
|
139
|
+
try {
|
|
140
|
+
const text = fs.readFileSync(logPath, 'utf8');
|
|
141
|
+
return text.slice(-bytes).trim();
|
|
142
|
+
} catch {
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Make sure Herdr's server is running, starting it if it is not.
|
|
149
|
+
*
|
|
150
|
+
* Resolves `{ started }`, or rejects with a message that says what happened:
|
|
151
|
+
* the socket belongs to someone else, the server exited, or it never opened
|
|
152
|
+
* its socket. The last lines Herdr printed ride along, because "did not start"
|
|
153
|
+
* with no reason is a message nobody can act on.
|
|
154
|
+
*/
|
|
155
|
+
async function ensureHerdrServer({
|
|
156
|
+
command,
|
|
157
|
+
args = [],
|
|
158
|
+
socketPath,
|
|
159
|
+
cwd,
|
|
160
|
+
logPath,
|
|
161
|
+
probe = probeHerdrServer,
|
|
162
|
+
launch = launchHerdrServer,
|
|
163
|
+
timeout = START_TIMEOUT_MS,
|
|
164
|
+
interval = START_POLL_MS,
|
|
165
|
+
}) {
|
|
166
|
+
const before = await probe(socketPath);
|
|
167
|
+
if (before.state === 'running') return { started: false };
|
|
168
|
+
if (before.state === 'unavailable') {
|
|
169
|
+
throw new Error(`Herdr socket at ${socketPath} cannot be used: ${before.reason}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const child = launch({ command, args, socketPath, cwd, logPath });
|
|
173
|
+
let exit = null;
|
|
174
|
+
child.once('exit', (code, signal) => { exit = { code, signal }; });
|
|
175
|
+
|
|
176
|
+
const deadline = Date.now() + timeout;
|
|
177
|
+
for (;;) {
|
|
178
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
179
|
+
const now = await probe(socketPath);
|
|
180
|
+
if (now.state === 'running') return { started: true };
|
|
181
|
+
const tail = readLogTail(logPath);
|
|
182
|
+
const detail = tail ? `\n${tail}` : '';
|
|
183
|
+
if (exit) {
|
|
184
|
+
throw new Error(`Herdr server exited (${exit.signal || `code ${exit.code}`}) before opening ${socketPath}.${detail}`);
|
|
185
|
+
}
|
|
186
|
+
if (Date.now() >= deadline) {
|
|
187
|
+
throw new Error(`Herdr server did not open ${socketPath} within ${Math.round(timeout / 1000)}s.${detail}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
PROBE_TIMEOUT_MS,
|
|
194
|
+
START_TIMEOUT_MS,
|
|
195
|
+
probeHerdrServer,
|
|
196
|
+
runningInSystemdService,
|
|
197
|
+
serverEnv,
|
|
198
|
+
serverCommand,
|
|
199
|
+
launchHerdrServer,
|
|
200
|
+
ensureHerdrServer,
|
|
201
|
+
};
|
package/src/host-connector.js
CHANGED
|
@@ -30,6 +30,17 @@ const { requestHerdr, subscribeHerdr } = require('./herdr-api');
|
|
|
30
30
|
const { sameSummary, summarizeAgents } = require('./agent-status');
|
|
31
31
|
const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
|
|
32
32
|
const { savePastedFile, cleanPastedDir } = require('./pasted-files');
|
|
33
|
+
const { ensureHerdrServer, probeHerdrServer } = require('./herdr-server');
|
|
34
|
+
const {
|
|
35
|
+
checkForUpdate,
|
|
36
|
+
compareVersions,
|
|
37
|
+
currentVersion,
|
|
38
|
+
installedVersionOnDisk,
|
|
39
|
+
updateChecksEnabled,
|
|
40
|
+
} = require('./updater');
|
|
41
|
+
|
|
42
|
+
/** Windows opening within this long of a check reuse its answer. */
|
|
43
|
+
const UPDATE_RECHECK_MS = 60_000;
|
|
33
44
|
|
|
34
45
|
/** How often the agent summary is re-read while a browser is watching. */
|
|
35
46
|
const AGENT_STATUS_POLL_MS = 5_000;
|
|
@@ -118,6 +129,28 @@ class HostConnector {
|
|
|
118
129
|
this.sessions = new Map();
|
|
119
130
|
this.streamIndexToId = new Map();
|
|
120
131
|
this.PtySession = options.PtySession || PtySession;
|
|
132
|
+
/** Is Herdr's server answering, and how to start it; injectable for tests. */
|
|
133
|
+
this.probeHerdr = options.probeHerdr || probeHerdrServer;
|
|
134
|
+
this.ensureHerdr = options.ensureHerdr || ensureHerdrServer;
|
|
135
|
+
this.herdrLogPath = options.herdrLogPath || path.join(stateDir(), 'herdr-server.log');
|
|
136
|
+
/** Windows whose session waits on a Herdr that is not running, by stream. */
|
|
137
|
+
this.waitingForHerdr = new Map();
|
|
138
|
+
/** Session starts between their liveness check and their PTY, by stream. */
|
|
139
|
+
this.probingStarts = new Map();
|
|
140
|
+
/** One start at a time, however many windows asked for it. */
|
|
141
|
+
this.herdrLaunch = null;
|
|
142
|
+
/**
|
|
143
|
+
* Whether a newer herdr-remote is out, asked when a window opens. The
|
|
144
|
+
* version this process runs is fixed at start; the one on disk moves when
|
|
145
|
+
* somebody updates without restarting.
|
|
146
|
+
*/
|
|
147
|
+
this.checkUpdate = options.checkUpdate !== undefined
|
|
148
|
+
? options.checkUpdate
|
|
149
|
+
: (updateChecksEnabled() ? checkForUpdate : null);
|
|
150
|
+
this.readInstalledVersion = options.readInstalledVersion || installedVersionOnDisk;
|
|
151
|
+
this.runningVersion = options.runningVersion || currentVersion();
|
|
152
|
+
this.updateCheckedAt = 0;
|
|
153
|
+
this.updateCheck = null;
|
|
121
154
|
/** The side channel that answers "does anything need me?"; see startAgentStatus. */
|
|
122
155
|
this.requestHerdr = options.requestHerdr || requestHerdr;
|
|
123
156
|
this.subscribeHerdr = options.subscribeHerdr || subscribeHerdr;
|
|
@@ -190,6 +223,12 @@ class HostConnector {
|
|
|
190
223
|
this.acquireLock();
|
|
191
224
|
this.stopping = false;
|
|
192
225
|
this.connect();
|
|
226
|
+
// Off unless the user switched it on: Herdr is theirs to start.
|
|
227
|
+
if (this.config.herdr?.autoStart) {
|
|
228
|
+
this.startHerdr().catch((error) => {
|
|
229
|
+
process.stderr.write(`herdr-remote host connector: could not start Herdr: ${error.message}\n`);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
193
232
|
}
|
|
194
233
|
|
|
195
234
|
stop() {
|
|
@@ -342,8 +381,9 @@ class HostConnector {
|
|
|
342
381
|
this.setClientCount(message.clientCount);
|
|
343
382
|
return;
|
|
344
383
|
}
|
|
345
|
-
if (message.type === 'session_start') this.startSession(message);
|
|
346
|
-
|
|
384
|
+
if (message.type === 'session_start') return this.startSession(message);
|
|
385
|
+
if (message.type === 'herdr_start') return this.handleHerdrStart(message);
|
|
386
|
+
if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
|
|
347
387
|
else if (message.type === 'resize') this.resizeSession(message);
|
|
348
388
|
else if (message.type === 'paste_file') this.handlePasteFile(message);
|
|
349
389
|
}
|
|
@@ -394,20 +434,136 @@ class HostConnector {
|
|
|
394
434
|
|
|
395
435
|
startSession(message) {
|
|
396
436
|
const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
|
|
397
|
-
if (!streamId) return;
|
|
437
|
+
if (!streamId) return undefined;
|
|
398
438
|
this.stopSession(streamId);
|
|
399
|
-
|
|
439
|
+
// A window opening is when a person is looking; that is when to ask.
|
|
440
|
+
this.reportUpdateStatus();
|
|
400
441
|
const missingHerdr = this.ensureHerdrCommand();
|
|
401
442
|
if (missingHerdr) {
|
|
402
443
|
process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
|
|
403
444
|
sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_not_found', message: missingHerdr });
|
|
404
|
-
return;
|
|
445
|
+
return undefined;
|
|
405
446
|
}
|
|
406
447
|
const socketInfo = inspectSocket(this.socketPath);
|
|
407
|
-
if (!socketInfo.ok) {
|
|
448
|
+
if (!socketInfo.ok && !socketInfo.missing) {
|
|
408
449
|
sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: socketInfo.reason });
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
if (!socketInfo.ok) {
|
|
453
|
+
this.waitForHerdr(streamId, message);
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
// The socket file alone proves nothing: a `herdr` client pointed at a dead
|
|
457
|
+
// one starts a server of its own, inside this process's service, where the
|
|
458
|
+
// next restart of herdr-remote would kill it. Only a server that accepts a
|
|
459
|
+
// connection gets a client.
|
|
460
|
+
const pending = { message };
|
|
461
|
+
this.probingStarts.set(streamId, pending);
|
|
462
|
+
return this.probeHerdr(this.socketPath).then((probe) => {
|
|
463
|
+
// Stopped, or started again, while the answer was in flight.
|
|
464
|
+
if (this.probingStarts.get(streamId) !== pending) return;
|
|
465
|
+
this.probingStarts.delete(streamId);
|
|
466
|
+
if (probe.state === 'running') this.spawnSession(pending.message);
|
|
467
|
+
else if (probe.state === 'stopped') this.waitForHerdr(streamId, pending.message);
|
|
468
|
+
else sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: probe.reason });
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Tell every window whether this workstation's herdr-remote is behind the
|
|
474
|
+
* newest release. Asked at most once a minute however many windows open;
|
|
475
|
+
* the relay keeps the last answer for windows that open in between. A failed
|
|
476
|
+
* check says nothing: offline is not news.
|
|
477
|
+
*/
|
|
478
|
+
reportUpdateStatus({ now = Date.now() } = {}) {
|
|
479
|
+
if (!this.checkUpdate || this.updateCheck) return this.updateCheck;
|
|
480
|
+
if (now - this.updateCheckedAt < UPDATE_RECHECK_MS) return null;
|
|
481
|
+
this.updateCheckedAt = now;
|
|
482
|
+
this.updateCheck = Promise.resolve()
|
|
483
|
+
.then(() => this.checkUpdate())
|
|
484
|
+
.then((result) => {
|
|
485
|
+
if (!result?.ok || !result.latest) return;
|
|
486
|
+
const running = this.runningVersion;
|
|
487
|
+
const installed = this.readInstalledVersion() || running;
|
|
488
|
+
sendJson(this.ws, {
|
|
489
|
+
type: 'update_status',
|
|
490
|
+
current: running,
|
|
491
|
+
installed,
|
|
492
|
+
latest: result.latest,
|
|
493
|
+
updateAvailable: compareVersions(result.latest, running) > 0,
|
|
494
|
+
// Updated on disk, still running the old code: a restart finishes it.
|
|
495
|
+
restartPending: compareVersions(installed, running) > 0,
|
|
496
|
+
});
|
|
497
|
+
})
|
|
498
|
+
.catch(() => {})
|
|
499
|
+
.finally(() => { this.updateCheck = null; });
|
|
500
|
+
return this.updateCheck;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Hold a window's session until Herdr runs, and ask the browser whether it
|
|
505
|
+
* should be started. Nothing starts until somebody says yes.
|
|
506
|
+
*/
|
|
507
|
+
waitForHerdr(streamId, message) {
|
|
508
|
+
this.waitingForHerdr.set(streamId, message);
|
|
509
|
+
sendJson(this.ws, {
|
|
510
|
+
type: 'error',
|
|
511
|
+
clientId: streamId,
|
|
512
|
+
code: 'herdr_not_running',
|
|
513
|
+
message: `Herdr is not running on this workstation (${this.socketPath}).`,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Start this workstation's Herdr server, once, however many windows or
|
|
519
|
+
* callers ask at the same time.
|
|
520
|
+
*/
|
|
521
|
+
startHerdr() {
|
|
522
|
+
if (!this.herdrLaunch) {
|
|
523
|
+
this.herdrLaunch = Promise.resolve()
|
|
524
|
+
.then(() => {
|
|
525
|
+
const missing = this.ensureHerdrCommand();
|
|
526
|
+
if (missing) throw new Error(missing);
|
|
527
|
+
return this.ensureHerdr({
|
|
528
|
+
command: this.herdrCommand,
|
|
529
|
+
args: this.herdrArgs,
|
|
530
|
+
socketPath: this.socketPath,
|
|
531
|
+
cwd: this.cwd,
|
|
532
|
+
logPath: this.herdrLogPath,
|
|
533
|
+
});
|
|
534
|
+
})
|
|
535
|
+
.finally(() => { this.herdrLaunch = null; });
|
|
536
|
+
}
|
|
537
|
+
return this.herdrLaunch;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* A browser said yes. The relay only ever routes this from a window paired
|
|
542
|
+
* to *this* workstation, so what starts is this user's own Herdr, at the
|
|
543
|
+
* socket this connector is configured for — never one named by the message.
|
|
544
|
+
* Once it runs, every window waiting on it gets its session.
|
|
545
|
+
*/
|
|
546
|
+
async handleHerdrStart(message) {
|
|
547
|
+
const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
|
|
548
|
+
try {
|
|
549
|
+
const result = await this.startHerdr();
|
|
550
|
+
if (result.started) process.stderr.write('herdr-remote host connector: started Herdr at a browser\'s request\n');
|
|
551
|
+
} catch (error) {
|
|
552
|
+
process.stderr.write(`herdr-remote host connector: could not start Herdr: ${error.message}\n`);
|
|
553
|
+
if (streamId) {
|
|
554
|
+
sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_start_failed', message: error.message });
|
|
555
|
+
}
|
|
409
556
|
return;
|
|
410
557
|
}
|
|
558
|
+
const waiting = [...this.waitingForHerdr.values()];
|
|
559
|
+
this.waitingForHerdr.clear();
|
|
560
|
+
for (const held of waiting) this.spawnSession(held);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Start the PTY for a window once Herdr is known to be running. */
|
|
564
|
+
spawnSession(message) {
|
|
565
|
+
const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
|
|
566
|
+
const streamIndex = typeof message.streamIndex === 'number' ? message.streamIndex : null;
|
|
411
567
|
const pty = new this.PtySession({
|
|
412
568
|
command: this.herdrCommand,
|
|
413
569
|
args: this.herdrArgs,
|
|
@@ -516,6 +672,8 @@ class HostConnector {
|
|
|
516
672
|
}
|
|
517
673
|
|
|
518
674
|
stopSession(streamId) {
|
|
675
|
+
this.waitingForHerdr.delete(streamId);
|
|
676
|
+
this.probingStarts.delete(streamId);
|
|
519
677
|
const session = this.sessions.get(streamId);
|
|
520
678
|
if (!session) return;
|
|
521
679
|
this.sessions.delete(streamId);
|
|
@@ -676,6 +834,12 @@ class HostConnector {
|
|
|
676
834
|
|
|
677
835
|
resizeSession(message) {
|
|
678
836
|
const id = message.clientId || message.streamId;
|
|
837
|
+
// A window still waiting on Herdr starts at the size it has by then.
|
|
838
|
+
const held = this.waitingForHerdr.get(id) || this.probingStarts.get(id)?.message;
|
|
839
|
+
if (held) {
|
|
840
|
+
if (Number.isInteger(message.cols)) held.cols = message.cols;
|
|
841
|
+
if (Number.isInteger(message.rows)) held.rows = message.rows;
|
|
842
|
+
}
|
|
679
843
|
const session = this.sessions.get(id);
|
|
680
844
|
if (!session) return;
|
|
681
845
|
session.cols = Number.isInteger(message.cols) ? Math.min(500, Math.max(2, message.cols)) : session.cols;
|
|
@@ -716,6 +880,8 @@ class HostConnector {
|
|
|
716
880
|
}
|
|
717
881
|
this.sessions.clear();
|
|
718
882
|
this.streamIndexToId.clear();
|
|
883
|
+
this.waitingForHerdr.clear();
|
|
884
|
+
this.probingStarts.clear();
|
|
719
885
|
}
|
|
720
886
|
|
|
721
887
|
sendHeartbeat(force = false) {
|
package/src/i18n/en.js
CHANGED
|
@@ -146,14 +146,7 @@ module.exports = {
|
|
|
146
146
|
'herdr.title': 'Herdr integration',
|
|
147
147
|
'herdr.socketPath': 'Socket path',
|
|
148
148
|
'herdr.args': 'Extra arguments',
|
|
149
|
-
'herdr.
|
|
150
|
-
'herdr.pluginRegistered': 'Registered with Herdr.',
|
|
151
|
-
'herdr.pluginMissing': 'Not registered with Herdr.',
|
|
152
|
-
'herdr.register': 'Register as Herdr plugin',
|
|
153
|
-
'herdr.unregister': 'Unregister plugin',
|
|
154
|
-
'herdr.registerDone': 'Registered: {path}',
|
|
155
|
-
'herdr.unregisterDone': 'Plugin unregistered.',
|
|
156
|
-
'herdr.registerFailed': 'Registration failed: {message}',
|
|
149
|
+
'herdr.autoStartHint': 'Off: Herdr starts only when you run it, or when you confirm it from a browser. On: herdr-remote also starts it every time it starts, at boot included. Takes effect the next time herdr-remote starts.',
|
|
157
150
|
'herdr.cliMissing': 'Command herdr not found. Set HERDR_BIN_PATH to its full path.',
|
|
158
151
|
'herdr.version': 'Herdr version',
|
|
159
152
|
'herdr.versionOutdated': 'Herdr {version} is older than {minimum}. Run "herdr update": window titles, background machine activation and large pastes misbehave in browser windows before that release.',
|
|
@@ -193,6 +186,7 @@ module.exports = {
|
|
|
193
186
|
'field.publicUrl': 'Browser URL override',
|
|
194
187
|
'field.socketPath': 'Herdr socket path',
|
|
195
188
|
'field.herdrArgs': 'Herdr arguments',
|
|
189
|
+
'field.herdrAutoStart': 'Auto-start Herdr',
|
|
196
190
|
'field.language': 'Language',
|
|
197
191
|
'field.keepalive': 'Keep-alive manager',
|
|
198
192
|
|
|
@@ -225,16 +219,22 @@ module.exports = {
|
|
|
225
219
|
'hint.save': 's save',
|
|
226
220
|
'hint.editing': '↵ confirm · esc cancel',
|
|
227
221
|
'update.title': 'Updates',
|
|
222
|
+
'update.banner': 'herdr-remote {latest} is out (running {current}). Press 7 and Enter on the update row to install.',
|
|
228
223
|
'update.check': 'Check for updates',
|
|
229
224
|
'update.checking': 'Checking npm…',
|
|
230
225
|
'update.upToDate': 'Up to date ({version})',
|
|
231
226
|
'update.available': 'Update {version} available (press Enter to install)',
|
|
232
227
|
'update.updating': 'Installing {version}…',
|
|
228
|
+
'update.updatingRetry': 'Installing {version}… (npm has not caught up yet, attempt {attempt})',
|
|
233
229
|
'update.done': 'Updated to {version}',
|
|
234
230
|
'update.restartHint': 'Update installed. Restart herdr-remote to apply.',
|
|
235
231
|
'update.errorNetwork': 'Could not reach any npm registry',
|
|
236
232
|
'update.errorNetworkDetail': 'No registry answered: {message}',
|
|
237
|
-
'update.errorFailed': 'Update failed. Run: npm install -g herdr-remote@
|
|
233
|
+
'update.errorFailed': 'Update failed. Run: npm install -g herdr-remote@{version} --prefer-online',
|
|
234
|
+
'update.errorFailedDetail': 'npm: {message}',
|
|
235
|
+
'update.errorNotYetPublished': 'npm has not caught up with {version} yet (common in the first minutes after a release). Press Enter to try again shortly.',
|
|
236
|
+
'update.errorNotApplied': 'npm reported success, but {installed} is still installed',
|
|
237
|
+
'update.mirrorBehind': '{registries} still has an older version; installing from {source}',
|
|
238
238
|
'update.cannot.source': 'Source checkout — update with git',
|
|
239
239
|
'update.cannot.linked': 'Linked package — managed by npm link',
|
|
240
240
|
};
|
package/src/i18n/zh.js
CHANGED
|
@@ -145,14 +145,7 @@ module.exports = {
|
|
|
145
145
|
'herdr.title': 'Herdr 集成',
|
|
146
146
|
'herdr.socketPath': '套接字路径',
|
|
147
147
|
'herdr.args': '附加参数',
|
|
148
|
-
'herdr.
|
|
149
|
-
'herdr.pluginRegistered': '已注册到 Herdr。',
|
|
150
|
-
'herdr.pluginMissing': '尚未注册到 Herdr。',
|
|
151
|
-
'herdr.register': '注册为 Herdr 插件',
|
|
152
|
-
'herdr.unregister': '取消注册',
|
|
153
|
-
'herdr.registerDone': '已注册:{path}',
|
|
154
|
-
'herdr.unregisterDone': '已取消注册。',
|
|
155
|
-
'herdr.registerFailed': '注册失败:{message}',
|
|
148
|
+
'herdr.autoStartHint': '关:Herdr 只在你手动运行,或在浏览器里确认后才启动。开:herdr-remote 每次启动(包括开机)时一并启动 Herdr。下次 herdr-remote 启动时生效。',
|
|
156
149
|
'herdr.cliMissing': '未找到 herdr 命令,可将 HERDR_BIN_PATH 设为其完整路径。',
|
|
157
150
|
'herdr.version': 'Herdr 版本',
|
|
158
151
|
'herdr.versionOutdated': 'Herdr {version} 低于 {minimum},请运行 “herdr update”:在该版本之前,浏览器窗口的标题、后台机器激活与大段粘贴都会出问题。',
|
|
@@ -192,6 +185,7 @@ module.exports = {
|
|
|
192
185
|
'field.publicUrl': '浏览器地址(覆盖)',
|
|
193
186
|
'field.socketPath': 'Herdr 套接字路径',
|
|
194
187
|
'field.herdrArgs': 'Herdr 参数',
|
|
188
|
+
'field.herdrAutoStart': '随服务启动 Herdr',
|
|
195
189
|
'field.language': '语言',
|
|
196
190
|
'field.keepalive': '保活管理方式',
|
|
197
191
|
|
|
@@ -224,16 +218,22 @@ module.exports = {
|
|
|
224
218
|
'hint.save': 's 保存',
|
|
225
219
|
'hint.editing': '↵ 确认 · esc 取消',
|
|
226
220
|
'update.title': '更新',
|
|
221
|
+
'update.banner': 'herdr-remote {latest} 已发布(当前 {current}),按 7 进入「语言与关于」,在更新一行按 Enter 安装。',
|
|
227
222
|
'update.check': '检查更新',
|
|
228
223
|
'update.checking': '查询 npm 中…',
|
|
229
224
|
'update.upToDate': '已是最新({version})',
|
|
230
225
|
'update.available': '新版本 {version} 可用(按 Enter 安装)',
|
|
231
226
|
'update.updating': '安装 {version} 中…',
|
|
227
|
+
'update.updatingRetry': '安装 {version} 中…(npm 尚未同步,第 {attempt} 次尝试)',
|
|
232
228
|
'update.done': '已更新至 {version}',
|
|
233
229
|
'update.restartHint': '更新已安装,重启 herdr-remote 生效。',
|
|
234
230
|
'update.errorNetwork': '所有 npm registry 均无法连接',
|
|
235
231
|
'update.errorNetworkDetail': '无 registry 应答:{message}',
|
|
236
|
-
'update.errorFailed': '更新失败,可手动执行:npm install -g herdr-remote@
|
|
232
|
+
'update.errorFailed': '更新失败,可手动执行:npm install -g herdr-remote@{version} --prefer-online',
|
|
233
|
+
'update.errorFailedDetail': 'npm:{message}',
|
|
234
|
+
'update.errorNotYetPublished': 'npm 还没同步到 {version}(刚发布的几分钟内常见),稍后按 Enter 重试。',
|
|
235
|
+
'update.errorNotApplied': 'npm 报告成功,但安装的仍是 {installed}',
|
|
236
|
+
'update.mirrorBehind': '{registries} 仍是旧版本,将从 {source} 安装',
|
|
237
237
|
'update.cannot.source': '源码目录运行,请使用 git 更新',
|
|
238
238
|
'update.cannot.linked': 'npm link 工作副本,由 npm link 管理',
|
|
239
239
|
};
|
package/src/lifecycle.js
CHANGED
|
@@ -19,8 +19,12 @@ function managerInUse(config) {
|
|
|
19
19
|
function startAll(config = loadConfig()) {
|
|
20
20
|
const managed = managerInUse(config);
|
|
21
21
|
if (managed) {
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// Start means "make it run", not "restart it". Herdr runs this through the
|
|
23
|
+
// plugin's startup hook every time a Herdr server starts, and a restart
|
|
24
|
+
// there dropped every browser attached to the workstation — including the
|
|
25
|
+
// one that had just asked for that Herdr to be started.
|
|
26
|
+
if (!managed.active) keepalive.restart(config);
|
|
27
|
+
return { ok: true, managed: true, manager: managed.manager, alreadyRunning: Boolean(managed.active) };
|
|
24
28
|
}
|
|
25
29
|
return { ...startServices(), managed: false };
|
|
26
30
|
}
|
package/src/settings-model.js
CHANGED
|
@@ -35,6 +35,7 @@ const FIELDS = [
|
|
|
35
35
|
{ id: 'publicUrl', kind: 'text', labelKey: 'field.publicUrl' },
|
|
36
36
|
{ id: 'socketPath', kind: 'text', labelKey: 'field.socketPath' },
|
|
37
37
|
{ id: 'herdrArgs', kind: 'text', labelKey: 'field.herdrArgs' },
|
|
38
|
+
{ id: 'herdrAutoStart', kind: 'toggle', labelKey: 'field.herdrAutoStart' },
|
|
38
39
|
{ id: 'language', kind: 'choice', choices: LANGUAGES, labelKey: 'field.language' },
|
|
39
40
|
{ id: 'keepaliveManager', kind: 'choice', choices: KEEPALIVE_MANAGERS, labelKey: 'field.keepalive' },
|
|
40
41
|
];
|
|
@@ -94,6 +95,8 @@ function getField(draft, id) {
|
|
|
94
95
|
return draft.herdr.socketPath || EMPTY;
|
|
95
96
|
case 'herdrArgs':
|
|
96
97
|
return (draft.herdr.args || []).join(' ');
|
|
98
|
+
case 'herdrAutoStart':
|
|
99
|
+
return draft.herdr.autoStart ? 'on' : 'off';
|
|
97
100
|
case 'language':
|
|
98
101
|
return draft.ui.language;
|
|
99
102
|
case 'keepaliveManager':
|
|
@@ -211,6 +214,13 @@ function setField(draft, id, rawValue) {
|
|
|
211
214
|
next.herdr.args = args;
|
|
212
215
|
break;
|
|
213
216
|
}
|
|
217
|
+
case 'herdrAutoStart': {
|
|
218
|
+
if (value !== true && value !== false && value !== 'on' && value !== 'off') {
|
|
219
|
+
return { draft, errorKey: 'error.unknownField' };
|
|
220
|
+
}
|
|
221
|
+
next.herdr.autoStart = value === true || value === 'on';
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
214
224
|
case 'language': {
|
|
215
225
|
if (!LANGUAGES.includes(value)) return { draft, errorKey: 'error.invalidLanguage' };
|
|
216
226
|
next.ui.language = value;
|
|
@@ -273,6 +283,7 @@ function saveDraft(draft) {
|
|
|
273
283
|
...(current.herdr || {}),
|
|
274
284
|
socketPath: draft.herdr.socketPath,
|
|
275
285
|
args: draft.herdr.args,
|
|
286
|
+
autoStart: draft.herdr.autoStart === true,
|
|
276
287
|
};
|
|
277
288
|
if (!merged.herdr.socketPath) delete merged.herdr.socketPath;
|
|
278
289
|
merged.keepalive = { ...(current.keepalive || {}), manager: draft.keepalive.manager };
|
package/src/socket-discovery.js
CHANGED
|
@@ -32,7 +32,12 @@ function inspectSocket(socketPath, options = {}) {
|
|
|
32
32
|
}
|
|
33
33
|
return { ok: true, path: socketPath, uid: stat.uid, mode: stat.mode };
|
|
34
34
|
} catch (error) {
|
|
35
|
-
return {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
missing: error.code === 'ENOENT',
|
|
38
|
+
reason: error.code === 'ENOENT' ? 'socket does not exist' : error.message,
|
|
39
|
+
path: socketPath,
|
|
40
|
+
};
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
|