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/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.15"
3
+ version = "0.2.17"
4
4
  min_herdr_version = "0.9.1"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "repository": {
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
+ };
@@ -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
- else if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
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
- const streamIndex = typeof message.streamIndex === 'number' ? message.streamIndex : null;
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.plugin': 'Plugin registration',
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@latest',
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.plugin': '插件注册',
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@latest',
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
- keepalive.restart(config);
23
- return { ok: true, managed: true, manager: managed.manager };
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
  }
@@ -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 };
@@ -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 { ok: false, reason: error.code === 'ENOENT' ? 'socket does not exist' : error.message, path: socketPath };
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