herdr-remote 0.2.14 → 0.2.16

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.
@@ -15,7 +15,8 @@
15
15
  },
16
16
  "herdr": {
17
17
  "socketPath": null,
18
- "args": []
18
+ "args": [],
19
+ "autoStart": false
19
20
  },
20
21
  "keepalive": {
21
22
  "manager": "auto"
package/dist/tui.mjs CHANGED
@@ -90,7 +90,10 @@ var require_config = __commonJS({
90
90
  herdr: {
91
91
  socketPath: null,
92
92
  args: [],
93
- cwd: os.homedir()
93
+ cwd: os.homedir(),
94
+ // Start Herdr's server when herdr-remote starts (at boot, under keep-alive).
95
+ // Off: Herdr starts when the user runs it, or confirms it from the WebUI.
96
+ autoStart: false
94
97
  },
95
98
  auth: {
96
99
  pairingTtlMs: 10 * 60 * 1e3,
@@ -251,6 +254,7 @@ var require_config = __commonJS({
251
254
  if (typeof config.herdr.cwd !== "string" || config.herdr.cwd.length === 0) {
252
255
  config.herdr.cwd = os.homedir();
253
256
  }
257
+ config.herdr.autoStart = config.herdr.autoStart === true;
254
258
  if (config.relay.mode === "remote" && !config.relay.remoteUrl) {
255
259
  config.relay.mode = "local";
256
260
  }
@@ -659,7 +663,7 @@ var require_herdr_command = __commonJS({
659
663
  if (!version) return true;
660
664
  return compareVersions(version, minimum) >= 0;
661
665
  }
662
- function herdrVersion({ command, timeout = 1e4, ...lookup } = {}) {
666
+ function herdrVersion2({ command, timeout = 1e4, ...lookup } = {}) {
663
667
  const resolved = command || resolveHerdrCommand(lookup);
664
668
  const unknown = { command: resolved, version: null, raw: null, supported: true, ok: false };
665
669
  let result;
@@ -692,7 +696,7 @@ var require_herdr_command = __commonJS({
692
696
  findHerdrCommand,
693
697
  herdrNotFoundMessage,
694
698
  herdrOutdatedMessage,
695
- herdrVersion,
699
+ herdrVersion: herdrVersion2,
696
700
  meetsMinimum,
697
701
  parseHerdrVersion,
698
702
  resolveHerdrCommand,
@@ -839,14 +843,7 @@ var require_en = __commonJS({
839
843
  "herdr.title": "Herdr integration",
840
844
  "herdr.socketPath": "Socket path",
841
845
  "herdr.args": "Extra arguments",
842
- "herdr.plugin": "Plugin registration",
843
- "herdr.pluginRegistered": "Registered with Herdr.",
844
- "herdr.pluginMissing": "Not registered with Herdr.",
845
- "herdr.register": "Register as Herdr plugin",
846
- "herdr.unregister": "Unregister plugin",
847
- "herdr.registerDone": "Registered: {path}",
848
- "herdr.unregisterDone": "Plugin unregistered.",
849
- "herdr.registerFailed": "Registration failed: {message}",
846
+ "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.",
850
847
  "herdr.cliMissing": "Command herdr not found. Set HERDR_BIN_PATH to its full path.",
851
848
  "herdr.version": "Herdr version",
852
849
  "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.',
@@ -883,6 +880,7 @@ var require_en = __commonJS({
883
880
  "field.publicUrl": "Browser URL override",
884
881
  "field.socketPath": "Herdr socket path",
885
882
  "field.herdrArgs": "Herdr arguments",
883
+ "field.herdrAutoStart": "Auto-start Herdr",
886
884
  "field.language": "Language",
887
885
  "field.keepalive": "Keep-alive manager",
888
886
  "placeholder.autoDiscovered": "auto-discovered",
@@ -1066,14 +1064,7 @@ var require_zh = __commonJS({
1066
1064
  "herdr.title": "Herdr \u96C6\u6210",
1067
1065
  "herdr.socketPath": "\u5957\u63A5\u5B57\u8DEF\u5F84",
1068
1066
  "herdr.args": "\u9644\u52A0\u53C2\u6570",
1069
- "herdr.plugin": "\u63D2\u4EF6\u6CE8\u518C",
1070
- "herdr.pluginRegistered": "\u5DF2\u6CE8\u518C\u5230 Herdr\u3002",
1071
- "herdr.pluginMissing": "\u5C1A\u672A\u6CE8\u518C\u5230 Herdr\u3002",
1072
- "herdr.register": "\u6CE8\u518C\u4E3A Herdr \u63D2\u4EF6",
1073
- "herdr.unregister": "\u53D6\u6D88\u6CE8\u518C",
1074
- "herdr.registerDone": "\u5DF2\u6CE8\u518C\uFF1A{path}",
1075
- "herdr.unregisterDone": "\u5DF2\u53D6\u6D88\u6CE8\u518C\u3002",
1076
- "herdr.registerFailed": "\u6CE8\u518C\u5931\u8D25\uFF1A{message}",
1067
+ "herdr.autoStartHint": "\u5173\uFF1AHerdr \u53EA\u5728\u4F60\u624B\u52A8\u8FD0\u884C\uFF0C\u6216\u5728\u6D4F\u89C8\u5668\u91CC\u786E\u8BA4\u540E\u624D\u542F\u52A8\u3002\u5F00\uFF1Aherdr-remote \u6BCF\u6B21\u542F\u52A8\uFF08\u5305\u62EC\u5F00\u673A\uFF09\u65F6\u4E00\u5E76\u542F\u52A8 Herdr\u3002\u4E0B\u6B21 herdr-remote \u542F\u52A8\u65F6\u751F\u6548\u3002",
1077
1068
  "herdr.cliMissing": "\u672A\u627E\u5230 herdr \u547D\u4EE4\uFF0C\u53EF\u5C06 HERDR_BIN_PATH \u8BBE\u4E3A\u5176\u5B8C\u6574\u8DEF\u5F84\u3002",
1078
1069
  "herdr.version": "Herdr \u7248\u672C",
1079
1070
  "herdr.versionOutdated": "Herdr {version} \u4F4E\u4E8E {minimum}\uFF0C\u8BF7\u8FD0\u884C \u201Cherdr update\u201D\uFF1A\u5728\u8BE5\u7248\u672C\u4E4B\u524D\uFF0C\u6D4F\u89C8\u5668\u7A97\u53E3\u7684\u6807\u9898\u3001\u540E\u53F0\u673A\u5668\u6FC0\u6D3B\u4E0E\u5927\u6BB5\u7C98\u8D34\u90FD\u4F1A\u51FA\u95EE\u9898\u3002",
@@ -1110,6 +1101,7 @@ var require_zh = __commonJS({
1110
1101
  "field.publicUrl": "\u6D4F\u89C8\u5668\u5730\u5740\uFF08\u8986\u76D6\uFF09",
1111
1102
  "field.socketPath": "Herdr \u5957\u63A5\u5B57\u8DEF\u5F84",
1112
1103
  "field.herdrArgs": "Herdr \u53C2\u6570",
1104
+ "field.herdrAutoStart": "\u968F\u670D\u52A1\u542F\u52A8 Herdr",
1113
1105
  "field.language": "\u8BED\u8A00",
1114
1106
  "field.keepalive": "\u4FDD\u6D3B\u7BA1\u7406\u65B9\u5F0F",
1115
1107
  "placeholder.autoDiscovered": "\u81EA\u52A8\u63A2\u6D4B",
@@ -1334,6 +1326,7 @@ var require_settings_model = __commonJS({
1334
1326
  { id: "publicUrl", kind: "text", labelKey: "field.publicUrl" },
1335
1327
  { id: "socketPath", kind: "text", labelKey: "field.socketPath" },
1336
1328
  { id: "herdrArgs", kind: "text", labelKey: "field.herdrArgs" },
1329
+ { id: "herdrAutoStart", kind: "toggle", labelKey: "field.herdrAutoStart" },
1337
1330
  { id: "language", kind: "choice", choices: LANGUAGES, labelKey: "field.language" },
1338
1331
  { id: "keepaliveManager", kind: "choice", choices: KEEPALIVE_MANAGERS, labelKey: "field.keepalive" }
1339
1332
  ];
@@ -1371,6 +1364,8 @@ var require_settings_model = __commonJS({
1371
1364
  return draft.herdr.socketPath || EMPTY;
1372
1365
  case "herdrArgs":
1373
1366
  return (draft.herdr.args || []).join(" ");
1367
+ case "herdrAutoStart":
1368
+ return draft.herdr.autoStart ? "on" : "off";
1374
1369
  case "language":
1375
1370
  return draft.ui.language;
1376
1371
  case "keepaliveManager":
@@ -1464,6 +1459,13 @@ var require_settings_model = __commonJS({
1464
1459
  next.herdr.args = args;
1465
1460
  break;
1466
1461
  }
1462
+ case "herdrAutoStart": {
1463
+ if (value !== true && value !== false && value !== "on" && value !== "off") {
1464
+ return { draft, errorKey: "error.unknownField" };
1465
+ }
1466
+ next.herdr.autoStart = value === true || value === "on";
1467
+ break;
1468
+ }
1467
1469
  case "language": {
1468
1470
  if (!LANGUAGES.includes(value)) return { draft, errorKey: "error.invalidLanguage" };
1469
1471
  next.ui.language = value;
@@ -1509,7 +1511,8 @@ var require_settings_model = __commonJS({
1509
1511
  merged.herdr = {
1510
1512
  ...current.herdr || {},
1511
1513
  socketPath: draft.herdr.socketPath,
1512
- args: draft.herdr.args
1514
+ args: draft.herdr.args,
1515
+ autoStart: draft.herdr.autoStart === true
1513
1516
  };
1514
1517
  if (!merged.herdr.socketPath) delete merged.herdr.socketPath;
1515
1518
  merged.keepalive = { ...current.keepalive || {}, manager: draft.keepalive.manager };
@@ -1758,7 +1761,12 @@ var require_socket_discovery = __commonJS({
1758
1761
  }
1759
1762
  return { ok: true, path: socketPath, uid: stat.uid, mode: stat.mode };
1760
1763
  } catch (error) {
1761
- return { ok: false, reason: error.code === "ENOENT" ? "socket does not exist" : error.message, path: socketPath };
1764
+ return {
1765
+ ok: false,
1766
+ missing: error.code === "ENOENT",
1767
+ reason: error.code === "ENOENT" ? "socket does not exist" : error.message,
1768
+ path: socketPath
1769
+ };
1762
1770
  }
1763
1771
  }
1764
1772
  function assertSocket(socketPath, options = {}) {
@@ -1810,7 +1818,7 @@ var require_service = __commonJS({
1810
1818
  } = require_terminal_palette();
1811
1819
  var { resolveSocketPath } = require_socket_discovery();
1812
1820
  var { preferredLanAddress: preferredLanAddress2 } = require_net_interfaces();
1813
- var { MIN_HERDR_VERSION: MIN_HERDR_VERSION2, findHerdrCommand, herdrVersion, resolveHerdrCommand } = require_herdr_command();
1821
+ var { MIN_HERDR_VERSION: MIN_HERDR_VERSION2, findHerdrCommand, herdrVersion: herdrVersion2, resolveHerdrCommand } = require_herdr_command();
1814
1822
  var RUNTIME_VERSION = 2;
1815
1823
  function pidAlive(pid) {
1816
1824
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -2117,7 +2125,7 @@ var require_service = __commonJS({
2117
2125
  }
2118
2126
  const socketPath = resolveSocketPath(config.herdr.socketPath);
2119
2127
  const herdr = findHerdrCommand();
2120
- const installed = herdr.found ? herdrVersion({ command: herdr.command }) : { version: null, supported: true };
2128
+ const installed = herdr.found ? herdrVersion2({ command: herdr.command }) : { version: null, supported: true };
2121
2129
  return {
2122
2130
  ok: true,
2123
2131
  mode: config.relay.mode,
@@ -2580,8 +2588,8 @@ var require_lifecycle = __commonJS({
2580
2588
  function startAll2(config = loadConfig2()) {
2581
2589
  const managed = managerInUse(config);
2582
2590
  if (managed) {
2583
- keepalive2.restart(config);
2584
- return { ok: true, managed: true, manager: managed.manager };
2591
+ if (!managed.active) keepalive2.restart(config);
2592
+ return { ok: true, managed: true, manager: managed.manager, alreadyRunning: Boolean(managed.active) };
2585
2593
  }
2586
2594
  return { ...startServices(), managed: false };
2587
2595
  }
@@ -2613,111 +2621,6 @@ var require_lifecycle = __commonJS({
2613
2621
  }
2614
2622
  });
2615
2623
 
2616
- // src/herdr-plugin.js
2617
- var require_herdr_plugin = __commonJS({
2618
- "src/herdr-plugin.js"(exports, module) {
2619
- "use strict";
2620
- var fs = __require("node:fs");
2621
- var path = __require("node:path");
2622
- var { spawnSync } = __require("node:child_process");
2623
- var { PACKAGE_ROOT } = require_config();
2624
- var { herdrVersion, resolveHerdrCommand } = require_herdr_command();
2625
- var PLUGIN_ID = "herdr.remote.web";
2626
- var MANIFEST_NAME = "herdr-plugin.toml";
2627
- function manifestPath() {
2628
- return path.join(PACKAGE_ROOT, MANIFEST_NAME);
2629
- }
2630
- function herdrAvailable() {
2631
- return herdrVersion().ok;
2632
- }
2633
- function runHerdr(args, { timeout = 15e3 } = {}) {
2634
- const result = spawnSync(resolveHerdrCommand(), args, { encoding: "utf8", timeout });
2635
- if (result.error && result.error.code === "ENOENT") {
2636
- const error = new Error("herdr command not found");
2637
- error.code = "HERDR_NOT_FOUND";
2638
- throw error;
2639
- }
2640
- return result;
2641
- }
2642
- function parsePluginList(output) {
2643
- const plugins = [];
2644
- for (const line of String(output || "").split("\n")) {
2645
- const match = /^-\s+(\S+)\s+\((.*?)\)\s+(\S+)(?:\s+\[(.*)\])?/.exec(line.trim());
2646
- if (!match) continue;
2647
- const [, id, name, state, rawSource] = match;
2648
- const source = rawSource ? rawSource.split(";")[0].trim() : null;
2649
- plugins.push({
2650
- id,
2651
- name,
2652
- enabled: state === "enabled",
2653
- source,
2654
- warnings: rawSource && rawSource.includes(";") ? rawSource.slice(rawSource.indexOf(";") + 1).trim() : null,
2655
- localPath: source && source.startsWith("local:") ? source.slice("local:".length) : null
2656
- });
2657
- }
2658
- return plugins;
2659
- }
2660
- function registrationStatus() {
2661
- if (!fs.existsSync(manifestPath())) {
2662
- return { available: false, registered: false, reason: "manifest missing" };
2663
- }
2664
- const { version, supported } = herdrVersion();
2665
- const installed = { version, versionSupported: supported };
2666
- let result;
2667
- try {
2668
- result = runHerdr(["plugin", "list"]);
2669
- } catch (error) {
2670
- if (error.code === "HERDR_NOT_FOUND") return { available: false, registered: false, reason: "herdr not found" };
2671
- throw error;
2672
- }
2673
- if (result.status !== 0) {
2674
- return { available: true, registered: false, reason: String(result.stderr || "").trim(), ...installed };
2675
- }
2676
- const plugins = parsePluginList(result.stdout);
2677
- const entry = plugins.find((plugin) => plugin.id === PLUGIN_ID);
2678
- if (!entry) return { available: true, registered: false, packageRoot: PACKAGE_ROOT, ...installed };
2679
- return {
2680
- available: true,
2681
- registered: true,
2682
- enabled: entry.enabled,
2683
- linkedPath: entry.localPath,
2684
- // A stale link pointing at an old checkout is the main failure mode after
2685
- // switching from a source install to npm.
2686
- stale: Boolean(entry.localPath) && path.resolve(entry.localPath) !== path.resolve(PACKAGE_ROOT),
2687
- packageRoot: PACKAGE_ROOT,
2688
- ...installed
2689
- };
2690
- }
2691
- function register() {
2692
- const current = registrationStatus();
2693
- if (current.registered && current.stale) {
2694
- runHerdr(["plugin", "unlink", PLUGIN_ID]);
2695
- }
2696
- const result = runHerdr(["plugin", "link", PACKAGE_ROOT]);
2697
- if (result.status !== 0) {
2698
- throw new Error(String(result.stderr || result.stdout || "").trim() || "herdr plugin link failed");
2699
- }
2700
- return { ok: true, path: PACKAGE_ROOT, output: String(result.stdout || "").trim() };
2701
- }
2702
- function unregister() {
2703
- const result = runHerdr(["plugin", "unlink", PLUGIN_ID]);
2704
- if (result.status !== 0) {
2705
- throw new Error(String(result.stderr || result.stdout || "").trim() || "herdr plugin unlink failed");
2706
- }
2707
- return { ok: true, output: String(result.stdout || "").trim() };
2708
- }
2709
- module.exports = {
2710
- PLUGIN_ID,
2711
- manifestPath,
2712
- herdrAvailable,
2713
- parsePluginList,
2714
- registrationStatus,
2715
- register,
2716
- unregister
2717
- };
2718
- }
2719
- });
2720
-
2721
2624
  // tui/src/index.tsx
2722
2625
  import { render } from "ink";
2723
2626
 
@@ -3026,7 +2929,6 @@ var import_settings_model = __toESM(require_settings_model());
3026
2929
  var import_service = __toESM(require_service());
3027
2930
  var import_lifecycle = __toESM(require_lifecycle());
3028
2931
  var keepalive = __toESM(require_keepalive());
3029
- var herdrPlugin = __toESM(require_herdr_plugin());
3030
2932
  var import_updater = __toESM(require_updater());
3031
2933
  function detectLocale(options = {}) {
3032
2934
  return (0, import_i18n.detectLocale)(options);
@@ -3808,24 +3710,15 @@ import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
3808
3710
  function HerdrScreen({ ctx }) {
3809
3711
  const { t, draft, editingId } = ctx;
3810
3712
  const [selected, setSelected] = useState7("socketPath");
3811
- const [registration, setRegistration] = useState7(null);
3812
- const reloadRegistration = () => {
3813
- try {
3814
- setRegistration(herdrPlugin.registrationStatus());
3815
- } catch (error) {
3816
- ctx.notify(error.message, "error");
3817
- }
3818
- };
3819
- useEffect5(reloadRegistration, []);
3713
+ const [installed, setInstalled] = useState7(null);
3714
+ useEffect5(() => {
3715
+ setInstalled((0, import_herdr_command.herdrVersion)());
3716
+ }, []);
3820
3717
  const entries = [
3821
3718
  { id: "socketPath", kind: "field", label: t("herdr.socketPath") },
3822
3719
  { id: "herdrArgs", kind: "field", label: t("herdr.args") },
3823
- { id: "save", kind: "action", label: t("common.save") },
3824
- {
3825
- id: registration?.registered && !registration?.stale ? "unregister" : "register",
3826
- kind: "action",
3827
- label: registration?.registered && !registration?.stale ? t("herdr.unregister") : t("herdr.register")
3828
- }
3720
+ { id: "herdrAutoStart", kind: "toggle", label: t("field.herdrAutoStart") },
3721
+ { id: "save", kind: "action", label: t("common.save") }
3829
3722
  ];
3830
3723
  const applyField = (id, value) => {
3831
3724
  const result = (0, import_settings_model.setField)(draft, id, value);
@@ -3850,26 +3743,11 @@ function HerdrScreen({ ctx }) {
3850
3743
  ctx.setEditing(id);
3851
3744
  return;
3852
3745
  }
3853
- if (id === "save") {
3854
- save();
3746
+ if (id === "herdrAutoStart") {
3747
+ applyField(id, draft.herdr.autoStart ? "off" : "on");
3855
3748
  return;
3856
3749
  }
3857
- ctx.run(() => {
3858
- try {
3859
- if (id === "register") {
3860
- const result = herdrPlugin.register();
3861
- ctx.notify(t("herdr.registerDone", { path: result.path }), "success");
3862
- } else {
3863
- herdrPlugin.unregister();
3864
- ctx.notify(t("herdr.unregisterDone"), "success");
3865
- }
3866
- } catch (error) {
3867
- const failure = error;
3868
- throw new Error(failure.code === "HERDR_NOT_FOUND" ? t("herdr.cliMissing") : t("herdr.registerFailed", { message: failure.message }));
3869
- } finally {
3870
- reloadRegistration();
3871
- }
3872
- });
3750
+ if (id === "save") save();
3873
3751
  };
3874
3752
  useInput6((input, key) => {
3875
3753
  const index = entries.findIndex((entry) => entry.id === selected);
@@ -3880,7 +3758,20 @@ function HerdrScreen({ ctx }) {
3880
3758
  else if (input === "s") save();
3881
3759
  }, { isActive: editingId === null });
3882
3760
  return /* @__PURE__ */ jsxs8(Panel, { title: t("herdr.title"), children: [
3883
- entries.map((entry) => entry.kind === "field" ? /* @__PURE__ */ jsx9(
3761
+ entries.map((entry) => entry.kind === "toggle" ? /* @__PURE__ */ jsx9(
3762
+ FieldRow,
3763
+ {
3764
+ label: entry.label,
3765
+ selected: selected === entry.id,
3766
+ onSelect: () => {
3767
+ setSelected(entry.id);
3768
+ activate(entry.id);
3769
+ },
3770
+ onHover: () => setSelected(entry.id),
3771
+ children: /* @__PURE__ */ jsx9(Text8, { color: draft.herdr.autoStart ? theme.ok : theme.muted, children: draft.herdr.autoStart ? `[\xD7] ${t("common.on")}` : `[ ] ${t("common.off")}` })
3772
+ },
3773
+ entry.id
3774
+ ) : entry.kind === "field" ? /* @__PURE__ */ jsx9(
3884
3775
  FieldRow,
3885
3776
  {
3886
3777
  label: entry.label,
@@ -3913,27 +3804,15 @@ function HerdrScreen({ ctx }) {
3913
3804
  children: entry.label
3914
3805
  }
3915
3806
  ) }, entry.id)),
3807
+ selected === "herdrAutoStart" ? /* @__PURE__ */ jsx9(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text8, { color: theme.muted, children: t("herdr.autoStartHint") }) }) : null,
3916
3808
  ctx.dirty ? /* @__PURE__ */ jsx9(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text8, { color: theme.warn, children: t("hint.unsavedChanges") }) }) : null,
3917
3809
  /* @__PURE__ */ jsxs8(Box8, { marginTop: 1, flexDirection: "column", children: [
3918
- /* @__PURE__ */ jsx9(Row, { label: t("herdr.plugin"), children: /* @__PURE__ */ jsxs8(Box8, { children: [
3919
- /* @__PURE__ */ jsx9(StatusDot, { level: registration?.registered && !registration?.stale ? "ok" : registration?.stale ? "warn" : "idle" }),
3920
- /* @__PURE__ */ jsxs8(Text8, { children: [
3921
- " ",
3922
- registration?.registered ? t("herdr.pluginRegistered") : t("herdr.pluginMissing")
3923
- ] })
3924
- ] }) }),
3925
- registration?.packageRoot ? /* @__PURE__ */ jsx9(Row, { label: "", children: /* @__PURE__ */ jsx9(Text8, { color: theme.muted, children: registration.packageRoot }) }) : null,
3926
- registration?.version ? /* @__PURE__ */ jsx9(Row, { label: t("herdr.version"), children: /* @__PURE__ */ jsxs8(Box8, { children: [
3927
- /* @__PURE__ */ jsx9(StatusDot, { level: registration.versionSupported === false ? "warn" : "ok" }),
3928
- /* @__PURE__ */ jsx9(Text8, { children: ` ${registration.version}` })
3810
+ installed?.version ? /* @__PURE__ */ jsx9(Row, { label: t("herdr.version"), children: /* @__PURE__ */ jsxs8(Box8, { children: [
3811
+ /* @__PURE__ */ jsx9(StatusDot, { level: installed.supported ? "ok" : "warn" }),
3812
+ /* @__PURE__ */ jsx9(Text8, { children: ` ${installed.version}` })
3929
3813
  ] }) }) : null,
3930
- registration?.versionSupported === false ? /* @__PURE__ */ jsx9(Text8, { color: theme.warn, children: t("herdr.versionOutdated", { version: registration.version ?? "", minimum: import_herdr_command.MIN_HERDR_VERSION }) }) : null,
3931
- registration && !registration.available ? /* @__PURE__ */ jsx9(Text8, { color: theme.warn, children: t("herdr.cliMissing") }) : null,
3932
- registration?.stale && registration.linkedPath ? (
3933
- // A leftover link to an old source checkout is the usual state after
3934
- // switching to the npm install; re-registering repoints it.
3935
- /* @__PURE__ */ jsx9(Text8, { color: theme.warn, children: registration.linkedPath })
3936
- ) : null
3814
+ installed?.version && !installed.supported ? /* @__PURE__ */ jsx9(Text8, { color: theme.warn, children: t("herdr.versionOutdated", { version: installed.version, minimum: import_herdr_command.MIN_HERDR_VERSION }) }) : null,
3815
+ installed && !installed.ok ? /* @__PURE__ */ jsx9(Text8, { color: theme.warn, children: t("herdr.cliMissing") }) : null
3937
3816
  ] }),
3938
3817
  /* @__PURE__ */ jsx9(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? "info" })
3939
3818
  ] });
@@ -4064,7 +3943,7 @@ function About({ ctx }) {
4064
3943
  children: updateLabel
4065
3944
  }
4066
3945
  ) }),
4067
- /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.14" }) }),
3946
+ /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.16" }) }),
4068
3947
  /* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
4069
3948
  /* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
4070
3949
  /* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
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.14"
3
+ version = "0.2.16"
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.14",
3
+ "version": "0.2.16",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -56,13 +56,21 @@ function emptyCounts() {
56
56
  */
57
57
  function summarizeAgents(snapshot) {
58
58
  const entries = Array.isArray(snapshot?.agents) ? snapshot.agents : [];
59
- const focusedPaneId = typeof snapshot?.focused_pane_id === 'string' ? snapshot.focused_pane_id : null;
60
- const focusedPane = focusedPaneId && Array.isArray(snapshot?.panes)
61
- ? snapshot.panes.find((pane) => pane?.pane_id === focusedPaneId)
62
- : null;
63
- const focusedAgentEntry = focusedPaneId
64
- ? entries.find((entry) => entry?.pane_id === focusedPaneId)
59
+ const panes = Array.isArray(snapshot?.panes) ? snapshot.panes : [];
60
+ const reportedFocusId = typeof snapshot?.focused_pane_id === 'string' && snapshot.focused_pane_id
61
+ ? snapshot.focused_pane_id
65
62
  : null;
63
+ // `focused_pane_id` is the primary signal in Herdr snapshots. The focused
64
+ // flags are a fallback for snapshots where that top-level field is absent.
65
+ const focusedPane = (reportedFocusId
66
+ ? panes.find((pane) => pane?.pane_id === reportedFocusId)
67
+ : panes.find((pane) => pane?.focused === true)) || null;
68
+ const focusedAgentEntry = (reportedFocusId
69
+ ? entries.find((entry) => entry?.pane_id === reportedFocusId)
70
+ : entries.find((entry) => entry?.focused === true)) || null;
71
+ const focusedPaneId = reportedFocusId
72
+ || (typeof focusedPane?.pane_id === 'string' ? focusedPane.pane_id : null)
73
+ || (typeof focusedAgentEntry?.pane_id === 'string' ? focusedAgentEntry.pane_id : null);
66
74
  const focusedAgent = typeof focusedPane?.agent === 'string'
67
75
  ? focusedPane.agent
68
76
  : typeof focusedAgentEntry?.agent === 'string'
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,7 @@ 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');
33
34
 
34
35
  /** How often the agent summary is re-read while a browser is watching. */
35
36
  const AGENT_STATUS_POLL_MS = 5_000;
@@ -118,6 +119,16 @@ class HostConnector {
118
119
  this.sessions = new Map();
119
120
  this.streamIndexToId = new Map();
120
121
  this.PtySession = options.PtySession || PtySession;
122
+ /** Is Herdr's server answering, and how to start it; injectable for tests. */
123
+ this.probeHerdr = options.probeHerdr || probeHerdrServer;
124
+ this.ensureHerdr = options.ensureHerdr || ensureHerdrServer;
125
+ this.herdrLogPath = options.herdrLogPath || path.join(stateDir(), 'herdr-server.log');
126
+ /** Windows whose session waits on a Herdr that is not running, by stream. */
127
+ this.waitingForHerdr = new Map();
128
+ /** Session starts between their liveness check and their PTY, by stream. */
129
+ this.probingStarts = new Map();
130
+ /** One start at a time, however many windows asked for it. */
131
+ this.herdrLaunch = null;
121
132
  /** The side channel that answers "does anything need me?"; see startAgentStatus. */
122
133
  this.requestHerdr = options.requestHerdr || requestHerdr;
123
134
  this.subscribeHerdr = options.subscribeHerdr || subscribeHerdr;
@@ -190,6 +201,12 @@ class HostConnector {
190
201
  this.acquireLock();
191
202
  this.stopping = false;
192
203
  this.connect();
204
+ // Off unless the user switched it on: Herdr is theirs to start.
205
+ if (this.config.herdr?.autoStart) {
206
+ this.startHerdr().catch((error) => {
207
+ process.stderr.write(`herdr-remote host connector: could not start Herdr: ${error.message}\n`);
208
+ });
209
+ }
193
210
  }
194
211
 
195
212
  stop() {
@@ -342,8 +359,9 @@ class HostConnector {
342
359
  this.setClientCount(message.clientCount);
343
360
  return;
344
361
  }
345
- if (message.type === 'session_start') this.startSession(message);
346
- else if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
362
+ if (message.type === 'session_start') return this.startSession(message);
363
+ if (message.type === 'herdr_start') return this.handleHerdrStart(message);
364
+ if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
347
365
  else if (message.type === 'resize') this.resizeSession(message);
348
366
  else if (message.type === 'paste_file') this.handlePasteFile(message);
349
367
  }
@@ -394,20 +412,103 @@ class HostConnector {
394
412
 
395
413
  startSession(message) {
396
414
  const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
397
- if (!streamId) return;
415
+ if (!streamId) return undefined;
398
416
  this.stopSession(streamId);
399
- const streamIndex = typeof message.streamIndex === 'number' ? message.streamIndex : null;
400
417
  const missingHerdr = this.ensureHerdrCommand();
401
418
  if (missingHerdr) {
402
419
  process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
403
420
  sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_not_found', message: missingHerdr });
404
- return;
421
+ return undefined;
405
422
  }
406
423
  const socketInfo = inspectSocket(this.socketPath);
407
- if (!socketInfo.ok) {
424
+ if (!socketInfo.ok && !socketInfo.missing) {
408
425
  sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: socketInfo.reason });
426
+ return undefined;
427
+ }
428
+ if (!socketInfo.ok) {
429
+ this.waitForHerdr(streamId, message);
430
+ return undefined;
431
+ }
432
+ // The socket file alone proves nothing: a `herdr` client pointed at a dead
433
+ // one starts a server of its own, inside this process's service, where the
434
+ // next restart of herdr-remote would kill it. Only a server that accepts a
435
+ // connection gets a client.
436
+ const pending = { message };
437
+ this.probingStarts.set(streamId, pending);
438
+ return this.probeHerdr(this.socketPath).then((probe) => {
439
+ // Stopped, or started again, while the answer was in flight.
440
+ if (this.probingStarts.get(streamId) !== pending) return;
441
+ this.probingStarts.delete(streamId);
442
+ if (probe.state === 'running') this.spawnSession(pending.message);
443
+ else if (probe.state === 'stopped') this.waitForHerdr(streamId, pending.message);
444
+ else sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: probe.reason });
445
+ });
446
+ }
447
+
448
+ /**
449
+ * Hold a window's session until Herdr runs, and ask the browser whether it
450
+ * should be started. Nothing starts until somebody says yes.
451
+ */
452
+ waitForHerdr(streamId, message) {
453
+ this.waitingForHerdr.set(streamId, message);
454
+ sendJson(this.ws, {
455
+ type: 'error',
456
+ clientId: streamId,
457
+ code: 'herdr_not_running',
458
+ message: `Herdr is not running on this workstation (${this.socketPath}).`,
459
+ });
460
+ }
461
+
462
+ /**
463
+ * Start this workstation's Herdr server, once, however many windows or
464
+ * callers ask at the same time.
465
+ */
466
+ startHerdr() {
467
+ if (!this.herdrLaunch) {
468
+ this.herdrLaunch = Promise.resolve()
469
+ .then(() => {
470
+ const missing = this.ensureHerdrCommand();
471
+ if (missing) throw new Error(missing);
472
+ return this.ensureHerdr({
473
+ command: this.herdrCommand,
474
+ args: this.herdrArgs,
475
+ socketPath: this.socketPath,
476
+ cwd: this.cwd,
477
+ logPath: this.herdrLogPath,
478
+ });
479
+ })
480
+ .finally(() => { this.herdrLaunch = null; });
481
+ }
482
+ return this.herdrLaunch;
483
+ }
484
+
485
+ /**
486
+ * A browser said yes. The relay only ever routes this from a window paired
487
+ * to *this* workstation, so what starts is this user's own Herdr, at the
488
+ * socket this connector is configured for — never one named by the message.
489
+ * Once it runs, every window waiting on it gets its session.
490
+ */
491
+ async handleHerdrStart(message) {
492
+ const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
493
+ try {
494
+ const result = await this.startHerdr();
495
+ if (result.started) process.stderr.write('herdr-remote host connector: started Herdr at a browser\'s request\n');
496
+ } catch (error) {
497
+ process.stderr.write(`herdr-remote host connector: could not start Herdr: ${error.message}\n`);
498
+ if (streamId) {
499
+ sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_start_failed', message: error.message });
500
+ }
409
501
  return;
410
502
  }
503
+ const waiting = [...this.waitingForHerdr.values()];
504
+ this.waitingForHerdr.clear();
505
+ for (const held of waiting) this.spawnSession(held);
506
+ }
507
+
508
+ /** Start the PTY for a window once Herdr is known to be running. */
509
+ spawnSession(message) {
510
+ const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
511
+ const streamIndex = typeof message.streamIndex === 'number' ? message.streamIndex : null;
411
512
  const pty = new this.PtySession({
412
513
  command: this.herdrCommand,
413
514
  args: this.herdrArgs,
@@ -516,6 +617,8 @@ class HostConnector {
516
617
  }
517
618
 
518
619
  stopSession(streamId) {
620
+ this.waitingForHerdr.delete(streamId);
621
+ this.probingStarts.delete(streamId);
519
622
  const session = this.sessions.get(streamId);
520
623
  if (!session) return;
521
624
  this.sessions.delete(streamId);
@@ -676,6 +779,12 @@ class HostConnector {
676
779
 
677
780
  resizeSession(message) {
678
781
  const id = message.clientId || message.streamId;
782
+ // A window still waiting on Herdr starts at the size it has by then.
783
+ const held = this.waitingForHerdr.get(id) || this.probingStarts.get(id)?.message;
784
+ if (held) {
785
+ if (Number.isInteger(message.cols)) held.cols = message.cols;
786
+ if (Number.isInteger(message.rows)) held.rows = message.rows;
787
+ }
679
788
  const session = this.sessions.get(id);
680
789
  if (!session) return;
681
790
  session.cols = Number.isInteger(message.cols) ? Math.min(500, Math.max(2, message.cols)) : session.cols;
@@ -716,6 +825,8 @@ class HostConnector {
716
825
  }
717
826
  this.sessions.clear();
718
827
  this.streamIndexToId.clear();
828
+ this.waitingForHerdr.clear();
829
+ this.probingStarts.clear();
719
830
  }
720
831
 
721
832
  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
 
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
 
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