@decentnetwork/beagle 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -9,7 +9,8 @@ import { fileURLToPath } from "node:url";
9
9
  import { dirname, join, resolve } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import { startBeagleServer } from "./server.js";
12
- import { openPeerHost, defaultConfigDir } from "./peer-host.js";
12
+ import { openPeerHost, defaultConfigDir, decentlanCarrierDir, makeDaemonHost } from "./peer-host.js";
13
+ import { SwitchablePeerHost } from "./lan-handoff.js";
13
14
  import { loadNodeConfig } from "./node-config.js";
14
15
  function parseArgs(argv) {
15
16
  const get = (flag) => {
@@ -72,6 +73,25 @@ async function main() {
72
73
  console.error(error.message);
73
74
  process.exit(1);
74
75
  }
76
+ // Wrap the chosen backend so it can be swapped at runtime: enabling the
77
+ // virtual LAN hands this identity to a decentlan daemon, and stopping that
78
+ // daemon hands it back. The server above keeps ONE reference — a backend
79
+ // change must not require restarting beagle.
80
+ const openEmbedded = async () => (await openPeerHost({
81
+ configDir: args.configDir,
82
+ bootstrapNodes,
83
+ expressNodes,
84
+ nickname,
85
+ statusMessage,
86
+ autoAcceptFriends: autoAccept,
87
+ force: "embedded",
88
+ })).host;
89
+ const lanHost = new SwitchablePeerHost(peerHost, {
90
+ dataDir: decentlanCarrierDir(args.configDir),
91
+ makeEmbedded: openEmbedded,
92
+ makeDaemon: () => makeDaemonHost(decentlanCarrierDir(args.configDir)),
93
+ onChange: (state) => console.log(`[LAN] backend is now: ${state}`),
94
+ });
75
95
  const moduleDir = dirname(fileURLToPath(import.meta.url));
76
96
  // Version lookup for the "my node" panel.
77
97
  //
@@ -122,7 +142,8 @@ async function main() {
122
142
  // actually bound. Printing it here first meant a port clash left a bogus
123
143
  // invitation on screen directly above the error explaining it was wrong.
124
144
  startBeagleServer({
125
- call: (r) => peerHost.call(r),
145
+ call: (r) => lanHost.call(r),
146
+ lanHost,
126
147
  routesPath: resolve(args.configDir, "routes.yaml"),
127
148
  doraRosterPath,
128
149
  downloadsDir: resolve(args.configDir, "downloads"),
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.2";
1
+ window.__DK_UI_VERSION="0.1.4";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
@@ -1313,9 +1313,27 @@ function dkSafeHref(s) {
1313
1313
  }
1314
1314
  return "";
1315
1315
  }
1316
+ function dkTrimUrlTail(raw) {
1317
+ let url = raw;
1318
+ const TAIL = `.,;:!?'"\u3002\uFF0C\u3001\uFF1B\uFF1A\uFF01\uFF1F\u2026\u201D\u2019\u300D\u300F`;
1319
+ const PAIRS = [["(", ")"], ["[", "]"], ["{", "}"], ["\uFF08", "\uFF09"], ["\u3010", "\u3011"], ["\u300A", "\u300B"]];
1320
+ const count = (s, ch) => s.split(ch).length - 1;
1321
+ for (; ; ) {
1322
+ const before = url;
1323
+ while (url && TAIL.indexOf(url[url.length - 1]) >= 0)
1324
+ url = url.slice(0, -1);
1325
+ for (let i = 0; i < PAIRS.length; i += 1) {
1326
+ const open = PAIRS[i][0], close = PAIRS[i][1];
1327
+ if (url.slice(-1) === close && count(url, open) < count(url, close))
1328
+ url = url.slice(0, -1);
1329
+ }
1330
+ if (url === before)
1331
+ return url;
1332
+ }
1333
+ }
1316
1334
  function dkInlineMarkdown(s) {
1317
1335
  const text = String(s == null ? "" : s);
1318
- const tokenRe = /(`[^`\n]+`|\[[^\]\n]+\]\([^) \n]+(?: [^)]+)?\)|\*\*[^*\n]+?\*\*|\*[^*\n]+?\*)/g;
1336
+ const tokenRe = /(`[^`\n]+`|\[[^\]\n]+\]\([^) \n]+(?: [^)]+)?\)|https?:\/\/[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+|\*\*[^*\n]+?\*\*|\*[^*\n]+?\*)/g;
1319
1337
  let out = "";
1320
1338
  let last = 0;
1321
1339
  let m;
@@ -1328,6 +1346,11 @@ function dkInlineMarkdown(s) {
1328
1346
  const lm = /^\[([^\]\n]+)\]\(([^) \n]+)(?: [^)]+)?\)$/.exec(tok);
1329
1347
  const href = lm && dkSafeHref(lm[2]);
1330
1348
  out += href ? '<a href="' + dkHtmlEscape(href) + '" target="_blank" rel="noopener">' + dkHtmlEscape(lm[1]) + "</a>" : dkHtmlEscape(tok);
1349
+ } else if (/^https?:\/\//.test(tok)) {
1350
+ const url = dkTrimUrlTail(tok);
1351
+ const href = dkSafeHref(url);
1352
+ const tail = tok.slice(url.length);
1353
+ out += href ? '<a href="' + dkHtmlEscape(href) + '" target="_blank" rel="noopener">' + dkHtmlEscape(url) + "</a>" + dkHtmlEscape(tail) : dkHtmlEscape(tok);
1331
1354
  } else if (tok.startsWith("**")) {
1332
1355
  out += "<strong>" + dkHtmlEscape(tok.slice(2, -2)) + "</strong>";
1333
1356
  } else if (tok.startsWith("*")) {
@@ -2291,7 +2314,111 @@ function ExitCard({ T, region, activeExit, onSetExit }) {
2291
2314
  return /* @__PURE__ */ React.createElement("div", { key: n.ip, style: { display: "flex", alignItems: "center", gap: 12, padding: "10px 14px", borderBottom: i < region.nodes.length - 1 ? "1px solid var(--line)" : "none", background: active ? "color-mix(in oklab, var(--accent), transparent 92%)" : "transparent" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: n.reachable }), /* @__PURE__ */ React.createElement(Mono, { size: 13, copy: n.ip }, n.ip), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)" } }, n.host), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), n.reachable ? /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: n.ping > 200 ? "var(--warn)" : "var(--dim)" } }, n.ping, "ms") : stuck ? /* @__PURE__ */ React.createElement(Tag, { tone: "warn", title: "Announces online but IP won't pass \u2014 session desync or NAT-blocked" }, "online \xB7 no route") : /* @__PURE__ */ React.createElement(Tag, { tone: "off" }, "offline"), active ? /* @__PURE__ */ React.createElement(Btn, { tone: "ok", icon: "check", size: "sm", onClick: () => onSetExit(null) }, T.routing) : /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "route", onClick: () => n.reachable && onSetExit(n.ip), style: { opacity: n.reachable ? 1 : 0.4 } }, T.routeThru));
2292
2315
  })));
2293
2316
  }
2294
- function NetworkTab({ T, me, peers, exits, activeExit, reqCount, onSetExit, onOpenChat }) {
2317
+ function LanEnableCard({ backend, onArm, onCancel }) {
2318
+ const [busy, setBusy] = React.useState(false);
2319
+ const [copied, setCopied] = React.useState(false);
2320
+ const releasing = backend.state === "releasing";
2321
+ const cmd = backend.command || "sudo agentnet service install";
2322
+ const copy = () => {
2323
+ navigator.clipboard.writeText(cmd).then(() => {
2324
+ setCopied(true);
2325
+ setTimeout(() => setCopied(false), 1600);
2326
+ }).catch(() => {
2327
+ });
2328
+ };
2329
+ return /* @__PURE__ */ React.createElement("div", { style: {
2330
+ display: "flex",
2331
+ flexDirection: "column",
2332
+ gap: 14,
2333
+ padding: "20px 22px",
2334
+ borderRadius: 12,
2335
+ background: "var(--panel)",
2336
+ border: "1px solid var(--line)"
2337
+ } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 15, fontWeight: 700, color: "var(--text)" } }, "\u865A\u62DF\u5C40\u57DF\u7F51 / Virtual LAN"), /* @__PURE__ */ React.createElement(Tag, { tone: "warn" }, "advanced \xB7 needs admin")), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 13, lineHeight: 1.6, color: "var(--dim)" } }, "\u804A\u5929\u3001\u6587\u4EF6\u3001\u901A\u8BDD\u90FD", /* @__PURE__ */ React.createElement("b", null, "\u4E0D\u9700\u8981"), "\u5B83 \u2014\u2014 \u90A3\u4E9B\u5DF2\u7ECF\u5728\u5DE5\u4F5C\u4E86\u3002\u865A\u62DF\u5C40\u57DF\u7F51\u989D\u5916\u63D0\u4F9B", /* @__PURE__ */ React.createElement("b", null, "\u79C1\u6709 IP \u4E92\u901A"), "\u548C", /* @__PURE__ */ React.createElement("b", null, "\u51FA\u53E3\u4EE3\u7406\u8DEF\u7531"), ",\u8981\u521B\u5EFA TUN \u8BBE\u5907,\u6240\u4EE5\u9700\u8981\u7BA1\u7406\u5458\u6743\u9650\u3002", /* @__PURE__ */ React.createElement("br", null), "Beagle \u4E0D\u4F1A\u66FF\u4F60\u63D0\u6743:\u547D\u4EE4\u7531\u4F60\u81EA\u5DF1\u8FD0\u884C,\u5BC6\u7801\u7531\u7CFB\u7EDF\u5411\u4F60\u8981\u3002"), !releasing ? /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 9 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--faint)", lineHeight: 1.5 } }, "\u7B2C 1 \u6B65 \xB7 Beagle \u5148\u4EA4\u51FA\u5F53\u524D\u8EAB\u4EFD \u2014\u2014 \u540C\u4E00\u8EAB\u4EFD\u4E0D\u80FD\u540C\u65F6\u8DD1\u4E24\u4E2A peer, \u6240\u4EE5\u8FD9\u4E00\u6B65\u4F1A\u8BA9\u6D88\u606F", /* @__PURE__ */ React.createElement("b", null, "\u77ED\u6682\u79BB\u7EBF"), "\u3002"), /* @__PURE__ */ React.createElement(
2338
+ "button",
2339
+ {
2340
+ onClick: async () => {
2341
+ setBusy(true);
2342
+ try {
2343
+ await onArm();
2344
+ } finally {
2345
+ setBusy(false);
2346
+ }
2347
+ },
2348
+ disabled: busy,
2349
+ style: {
2350
+ alignSelf: "flex-start",
2351
+ padding: "9px 16px",
2352
+ borderRadius: 10,
2353
+ border: "none",
2354
+ cursor: busy ? "default" : "pointer",
2355
+ background: "var(--accent)",
2356
+ color: "#fff",
2357
+ fontFamily: "var(--mono)",
2358
+ fontSize: 13,
2359
+ fontWeight: 700,
2360
+ opacity: busy ? 0.6 : 1
2361
+ }
2362
+ },
2363
+ busy ? "\u2026" : "\u542F\u7528\u865A\u62DF\u5C40\u57DF\u7F51"
2364
+ )) : /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 10 } }, /* @__PURE__ */ React.createElement("div", { style: {
2365
+ display: "flex",
2366
+ alignItems: "center",
2367
+ gap: 8,
2368
+ padding: "9px 12px",
2369
+ borderRadius: 9,
2370
+ background: "rgba(245,158,11,0.14)",
2371
+ border: "1px solid rgba(245,158,11,0.5)"
2372
+ } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--text)", lineHeight: 1.5 } }, "\u6D88\u606F\u5DF2\u79BB\u7EBF \u2014\u2014 \u8EAB\u4EFD\u5DF2\u4EA4\u51FA,\u7B49\u5F85\u5B88\u62A4\u8FDB\u7A0B\u63A5\u7BA1", backend.releaseSecondsLeft > 0 ? `(${Math.floor(backend.releaseSecondsLeft / 60)} \u5206 ${backend.releaseSecondsLeft % 60} \u79D2\u540E\u81EA\u52A8\u6062\u590D)` : "")), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--faint)" } }, "\u7B2C 2 \u6B65 \xB7 \u5728\u7EC8\u7AEF\u91CC\u8FD0\u884C(\u7CFB\u7EDF\u4F1A\u5411\u4F60\u8981\u5BC6\u7801):"), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement("code", { style: {
2373
+ flex: 1,
2374
+ fontFamily: "var(--mono)",
2375
+ fontSize: 13,
2376
+ padding: "10px 12px",
2377
+ borderRadius: 8,
2378
+ background: "var(--panel-2)",
2379
+ border: "1px solid var(--line)",
2380
+ color: "var(--text)",
2381
+ overflowX: "auto"
2382
+ } }, cmd), /* @__PURE__ */ React.createElement(
2383
+ "button",
2384
+ {
2385
+ onClick: copy,
2386
+ style: {
2387
+ flexShrink: 0,
2388
+ padding: "9px 13px",
2389
+ borderRadius: 8,
2390
+ border: "1px solid var(--line)",
2391
+ cursor: "pointer",
2392
+ background: "var(--panel-2)",
2393
+ color: "var(--text)",
2394
+ fontFamily: "var(--mono)",
2395
+ fontSize: 12
2396
+ }
2397
+ },
2398
+ copied ? "\u5DF2\u590D\u5236" : "\u590D\u5236"
2399
+ )), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--faint)" } }, "\u7B2C 3 \u6B65 \xB7 \u5B88\u62A4\u8FDB\u7A0B\u4E00\u8D77\u6765,Beagle \u81EA\u5DF1\u5207\u8FC7\u53BB\u5E76\u6062\u590D\u6D88\u606F \u2014\u2014 \u4E0D\u7528\u91CD\u542F\u3002"), /* @__PURE__ */ React.createElement(
2400
+ "button",
2401
+ {
2402
+ onClick: () => onCancel(),
2403
+ style: {
2404
+ alignSelf: "flex-start",
2405
+ padding: "7px 13px",
2406
+ borderRadius: 8,
2407
+ border: "1px solid var(--line)",
2408
+ cursor: "pointer",
2409
+ background: "transparent",
2410
+ color: "var(--dim)",
2411
+ fontFamily: "var(--mono)",
2412
+ fontSize: 12
2413
+ }
2414
+ },
2415
+ "\u53D6\u6D88,\u6062\u590D\u6D88\u606F"
2416
+ )));
2417
+ }
2418
+ function NetworkTab({ T, me, peers, exits, activeExit, reqCount, onSetExit, onOpenChat, backend, onArmLan, onCancelLan }) {
2419
+ if (backend && backend.switchable && !backend.hasVirtualLan) {
2420
+ return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 1040, margin: "0 auto", padding: "24px 28px 60px", display: "flex", flexDirection: "column", gap: 26 } }, /* @__PURE__ */ React.createElement(MyNode, { T, me, activeExit, peers, reqCount }), /* @__PURE__ */ React.createElement(LanEnableCard, { backend, onArm: onArmLan, onCancel: onCancelLan })));
2421
+ }
2295
2422
  return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 1040, margin: "0 auto", padding: "24px 28px 60px", display: "flex", flexDirection: "column", gap: 26 } }, /* @__PURE__ */ React.createElement(MyNode, { T, me, activeExit, peers, reqCount }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 12 } }, /* @__PURE__ */ React.createElement(Section, { label: T.peerRouting, count: peers.length }), /* @__PURE__ */ React.createElement(PeerTable, { T, peers, onOpenChat })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 12 } }, /* @__PURE__ */ React.createElement(Section, { label: T.exitNodes, trailing: /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(Btn, { icon: "plus", size: "sm" }, T.addExit)) }), activeExit && /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "11px 14px", borderRadius: 10, background: "color-mix(in oklab, var(--warn), transparent 90%)", border: "1px solid color-mix(in oklab, var(--warn), transparent 70%)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "route", size: 17, color: "var(--warn)", stroke: 2 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)" } }, T.egressVia), /* @__PURE__ */ React.createElement(Mono, { size: 13, copy: activeExit }, activeExit), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", icon: "unlink", size: "sm", onClick: () => onSetExit(null) }, T.stopRouting)), /* @__PURE__ */ React.createElement("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 } }, exits.map((r) => /* @__PURE__ */ React.createElement(ExitCard, { key: r.region, T, region: r, activeExit, onSetExit }))))));
2296
2423
  }
2297
2424
  Object.assign(window, { NetworkTab });
@@ -3367,6 +3494,28 @@ function DkApp() {
3367
3494
  const [activeId, setActiveId] = React.useState(null);
3368
3495
  const data = useDaemonData();
3369
3496
  const me = data.me;
3497
+ const [backend, setBackend] = React.useState(null);
3498
+ const refreshBackend = React.useCallback(() => {
3499
+ dkGet("/api/backend").then((b) => {
3500
+ if (b && b.ok)
3501
+ setBackend(b);
3502
+ }).catch(() => {
3503
+ });
3504
+ }, []);
3505
+ React.useEffect(() => {
3506
+ refreshBackend();
3507
+ const ms = backend && backend.state === "releasing" ? 1500 : 8e3;
3508
+ const t2 = setInterval(refreshBackend, ms);
3509
+ return () => clearInterval(t2);
3510
+ }, [refreshBackend, backend && backend.state]);
3511
+ const armLan = React.useCallback(async () => {
3512
+ await dkPost("/api/lan-arm", {});
3513
+ refreshBackend();
3514
+ }, [refreshBackend]);
3515
+ const cancelLan = React.useCallback(async () => {
3516
+ await dkPost("/api/lan-cancel", {});
3517
+ refreshBackend();
3518
+ }, [refreshBackend]);
3370
3519
  React.useEffect(() => {
3371
3520
  const mine = window.__DK_UI_VERSION;
3372
3521
  const served = me && me.lanVer;
@@ -3516,7 +3665,7 @@ function DkApp() {
3516
3665
  { id: "network", icon: "network", label: T.network },
3517
3666
  { id: "profile", icon: "userRound", label: T.profile }
3518
3667
  ];
3519
- return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("svg", { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "var(--accent)", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", style: { display: "block", flexShrink: 0 } }, /* @__PURE__ */ React.createElement("path", { d: "m4.5 17 6-6-6-6" }), /* @__PURE__ */ React.createElement("path", { d: "M12 18.5h7.5" })), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "beagle"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", nav.find((n) => n.id === tab).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), prefillAddr: pendingAddr, onPrefillConsumed: () => setPendingAddr("") }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit })), /* @__PURE__ */ React.createElement(TweaksPanel, null, /* @__PURE__ */ React.createElement(TweakSection, { label: t.lang === "zh" ? "\u5916\u89C2" : "Appearance" }), /* @__PURE__ */ React.createElement(
3668
+ return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("svg", { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "var(--accent)", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", style: { display: "block", flexShrink: 0 } }, /* @__PURE__ */ React.createElement("path", { d: "m4.5 17 6-6-6-6" }), /* @__PURE__ */ React.createElement("path", { d: "M12 18.5h7.5" })), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "beagle"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", nav.find((n) => n.id === tab).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), prefillAddr: pendingAddr, onPrefillConsumed: () => setPendingAddr("") }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat, backend, onArmLan: armLan, onCancelLan: cancelLan }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit })), /* @__PURE__ */ React.createElement(TweaksPanel, null, /* @__PURE__ */ React.createElement(TweakSection, { label: t.lang === "zh" ? "\u5916\u89C2" : "Appearance" }), /* @__PURE__ */ React.createElement(
3520
3669
  TweakRadio,
3521
3670
  {
3522
3671
  label: t.lang === "zh" ? "\u4E3B\u9898" : "Theme",
@@ -36,7 +36,12 @@
36
36
  .dk-md code { font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; font-size: 0.92em; background: rgba(0,0,0,0.18); border-radius: 4px; padding: 0.08em 0.3em; }
37
37
  .dk-md pre { margin: 0.45em 0 0; padding: 8px 10px; overflow: auto; border-radius: 8px; background: rgba(0,0,0,0.22); }
38
38
  .dk-md pre code { display: block; background: transparent; padding: 0; white-space: pre; }
39
- .dk-md a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
39
+ /* color:inherit is deliberate the same rule has to read well on the dark
40
+ incoming bubble AND on the accent-coloured outgoing one, where a blue link
41
+ would clash. The affordance comes from the underline and the cursor. */
42
+ .dk-md a { color: inherit; text-decoration: underline; text-underline-offset: 2px;
43
+ cursor: pointer; overflow-wrap: anywhere; }
44
+ .dk-md a:hover { text-decoration-thickness: 2px; opacity: 0.85; }
40
45
  @keyframes dkpulse { 0% { transform: scale(0.9); opacity: 0.7; } 70% { transform: scale(1.5); opacity: 0; } 100% { opacity: 0; } }
41
46
  </style>
42
47
  </head>
@@ -3,6 +3,8 @@ import type { PeerHost } from "./peer-host.js";
3
3
  export interface EmbeddedHostOptions {
4
4
  configDir: string;
5
5
  keyFile: string;
6
+ /** Carrier data dir — where the identity and its lock live. */
7
+ dataDir: string;
6
8
  bootstrapNodes: {
7
9
  host: string;
8
10
  port: number;
@@ -16,6 +16,7 @@ import { CarrierNode } from "./carrier-node.js";
16
16
  import { MessageStore } from "./message-store.js";
17
17
  import { FriendMetaStore } from "./friend-meta.js";
18
18
  import { Logger } from "./logger.js";
19
+ import { acquireIdentityLock } from "./identity-lock.js";
19
20
  /** Files bigger than this are not queued for an offline friend — the bytes sit
20
21
  * on disk until they reconnect, and an unbounded queue is how you fill a
21
22
  * user's disk by accident. Live transfers have no such limit. */
@@ -54,6 +55,8 @@ export class EmbeddedHost {
54
55
  /** fileId -> which chat message it belongs to, so progress can patch it. */
55
56
  #activeSends = new Map();
56
57
  #autoAccept;
58
+ /** Released when this peer stops, so the daemon may take the identity. */
59
+ #releaseIdentity = null;
57
60
  constructor(opts) {
58
61
  this.#opts = opts;
59
62
  this.#autoAccept = opts.autoAcceptFriends;
@@ -67,6 +70,20 @@ export class EmbeddedHost {
67
70
  return resolve(this.#opts.configDir, "outbox");
68
71
  }
69
72
  async start() {
73
+ // Take the identity lock BEFORE creating the peer. decentlan's daemon
74
+ // checks this same file, so holding it is what stops `agentnet up` from
75
+ // starting a second peer on our keypair while we are live.
76
+ this.#releaseIdentity = acquireIdentityLock(this.#opts.dataDir);
77
+ try {
78
+ await this.#startPeer();
79
+ }
80
+ catch (err) {
81
+ this.#releaseIdentity?.();
82
+ this.#releaseIdentity = null;
83
+ throw err;
84
+ }
85
+ }
86
+ async #startPeer() {
70
87
  await this.#node.create({
71
88
  keyFile: this.#opts.keyFile,
72
89
  bootstrapNodes: this.#opts.bootstrapNodes,
@@ -225,7 +242,11 @@ export class EmbeddedHost {
225
242
  return () => this.#events.off("event", fn);
226
243
  }
227
244
  async stop() {
245
+ // Stop the peer FIRST, then release the lock. The reverse order would open
246
+ // a window where a daemon could start while our peer is still live.
228
247
  await this.#node.stop();
248
+ this.#releaseIdentity?.();
249
+ this.#releaseIdentity = null;
229
250
  }
230
251
  // -------------------------------------------------------------------------
231
252
  // The op surface — same contract as the daemon's IPC.
@@ -0,0 +1,18 @@
1
+ /** Same path decentlan uses: `<carrier dataDir>/daemon.pid`. */
2
+ export declare function identityLockPath(dataDir: string): string;
3
+ /** The live pid holding this identity, or null when it is free. */
4
+ export declare function identityLockHolder(dataDir: string): number | null;
5
+ export declare class IdentityLockedError extends Error {
6
+ readonly holderPid: number;
7
+ constructor(holderPid: number);
8
+ }
9
+ /**
10
+ * Take the lock for this process. Throws {@link IdentityLockedError} when a
11
+ * live holder exists — refusing to start is always better than starting a
12
+ * second peer, because the damage from the latter shows up later, as
13
+ * unexplained message loss, rather than here as a clear error.
14
+ *
15
+ * A stale file (holder gone) is taken over silently: that is a crash we already
16
+ * survived, not a conflict.
17
+ */
18
+ export declare function acquireIdentityLock(dataDir: string): () => void;
@@ -0,0 +1,85 @@
1
+ // The one hard constraint of the split: an identity must never have two live
2
+ // Carrier peers. Two peers on one keypair scramble each other's net_crypto
3
+ // sessions — the failure mode we have already paid for twice.
4
+ //
5
+ // decentlan enforces this with a pidfile next to the keypair
6
+ // (<dataDir>/daemon.pid): its daemon refuses to start when that file names a
7
+ // LIVE process. Beagle's embedded peer holds the same identity, so it must take
8
+ // the same lock — otherwise `sudo agentnet service install` happily starts a
9
+ // daemon alongside it and both are live on one keypair, which is exactly the
10
+ // state the pidfile exists to prevent.
11
+ //
12
+ // So this is not beagle bookkeeping. It is beagle joining an existing mutual
13
+ // exclusion protocol whose other participant we cannot modify (decentlan is
14
+ // sealed) and must not fork.
15
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
16
+ import { resolve } from "node:path";
17
+ /** Same path decentlan uses: `<carrier dataDir>/daemon.pid`. */
18
+ export function identityLockPath(dataDir) {
19
+ return resolve(dataDir, "daemon.pid");
20
+ }
21
+ /** The live pid holding this identity, or null when it is free. */
22
+ export function identityLockHolder(dataDir) {
23
+ const file = identityLockPath(dataDir);
24
+ if (!existsSync(file))
25
+ return null;
26
+ const pid = Number.parseInt(readFileSync(file, "utf-8").trim(), 10);
27
+ if (!Number.isInteger(pid) || pid <= 0)
28
+ return null;
29
+ try {
30
+ // Signal 0 tests for existence without touching the process.
31
+ process.kill(pid, 0);
32
+ return pid;
33
+ }
34
+ catch (err) {
35
+ // EPERM means it exists but belongs to another user (a root-owned daemon,
36
+ // the normal case) — that still counts as held. ESRCH means it is gone.
37
+ return err.code === "EPERM" ? pid : null;
38
+ }
39
+ }
40
+ export class IdentityLockedError extends Error {
41
+ holderPid;
42
+ constructor(holderPid) {
43
+ super(`This identity is already in use by process ${holderPid} — probably the decentlan daemon. ` +
44
+ `Beagle will use it over IPC instead of starting a second peer.`);
45
+ this.name = "IdentityLockedError";
46
+ this.holderPid = holderPid;
47
+ }
48
+ }
49
+ /**
50
+ * Take the lock for this process. Throws {@link IdentityLockedError} when a
51
+ * live holder exists — refusing to start is always better than starting a
52
+ * second peer, because the damage from the latter shows up later, as
53
+ * unexplained message loss, rather than here as a clear error.
54
+ *
55
+ * A stale file (holder gone) is taken over silently: that is a crash we already
56
+ * survived, not a conflict.
57
+ */
58
+ export function acquireIdentityLock(dataDir) {
59
+ const holder = identityLockHolder(dataDir);
60
+ if (holder !== null && holder !== process.pid)
61
+ throw new IdentityLockedError(holder);
62
+ // On a fresh install the data dir does not exist yet — the SDK creates it
63
+ // when it writes the keypair, which happens AFTER this point. We must take
64
+ // the lock before the peer starts, so we create the directory ourselves.
65
+ mkdirSync(dataDir, { recursive: true });
66
+ const file = identityLockPath(dataDir);
67
+ writeFileSync(file, String(process.pid), "utf-8");
68
+ let released = false;
69
+ return () => {
70
+ if (released)
71
+ return;
72
+ released = true;
73
+ // Only remove it if it is still OURS. A daemon may have taken over after we
74
+ // released the peer during the LAN handoff, and deleting its lock would
75
+ // silently re-open the door this whole module exists to keep shut.
76
+ try {
77
+ const now = Number.parseInt(readFileSync(file, "utf-8").trim(), 10);
78
+ if (now === process.pid)
79
+ unlinkSync(file);
80
+ }
81
+ catch {
82
+ // already gone, or unreadable — nothing to release
83
+ }
84
+ };
85
+ }
@@ -0,0 +1,48 @@
1
+ import { type IpcRequest, type IpcResponse } from "./ipc.js";
2
+ import { Logger } from "./logger.js";
3
+ import type { PeerHost } from "./peer-host.js";
4
+ export type HandoffState = "embedded" | "releasing" | "daemon";
5
+ export interface SwitchableHostOptions {
6
+ dataDir: string;
7
+ /** Build a fresh embedded host. Called on first start and on every fallback,
8
+ * because a stopped peer is not restartable — the SDK ties its lifetime to
9
+ * the instance. */
10
+ makeEmbedded: () => Promise<PeerHost>;
11
+ makeDaemon: () => PeerHost;
12
+ onChange?: (state: HandoffState, host: PeerHost | null) => void;
13
+ log?: Logger;
14
+ }
15
+ /**
16
+ * A PeerHost whose backend can change under it.
17
+ *
18
+ * Everything above this (the HTTP server, the UI) keeps one reference and never
19
+ * learns that the peer was swapped — which is the point: a backend change must
20
+ * not be a restart.
21
+ */
22
+ export declare class SwitchablePeerHost implements PeerHost {
23
+ #private;
24
+ constructor(initial: PeerHost, opts: SwitchableHostOptions);
25
+ get kind(): PeerHost["kind"];
26
+ get hasVirtualLan(): boolean;
27
+ get state(): HandoffState;
28
+ /** Seconds left before `releasing` gives up; 0 when not releasing. */
29
+ get releaseSecondsLeft(): number;
30
+ call(req: IpcRequest): Promise<IpcResponse>;
31
+ stop(): Promise<void>;
32
+ /**
33
+ * Step 1 of enabling: give up the identity so a daemon can take it.
34
+ *
35
+ * We do NOT run the installer. It needs root, and beagle escalating on the
36
+ * user's behalf — even via a prompt we trigger — is a privilege we do not
37
+ * want this app to have. The UI shows the command; the OS asks for the
38
+ * password; the user decides.
39
+ */
40
+ armForDaemon(): Promise<{
41
+ state: HandoffState;
42
+ command: string;
43
+ }>;
44
+ /** Abandon the handoff and bring the embedded peer back. */
45
+ cancelArm(): Promise<HandoffState>;
46
+ }
47
+ /** The command the user runs. Shown, never executed by us. */
48
+ export declare const INSTALL_COMMAND = "sudo agentnet service install";
@@ -0,0 +1,171 @@
1
+ // Phase 3 — turning the virtual LAN on, without ever having two live peers.
2
+ //
3
+ // Beagle runs its own Carrier peer when no decentlan daemon exists. Enabling
4
+ // the LAN means a daemon takes over that identity. Those two cannot overlap
5
+ // (see identity-lock.ts), and the daemon needs root, which beagle must never
6
+ // acquire on the user's behalf. So the handoff is a SEQUENCE with a human step
7
+ // in the middle, not a button that does everything:
8
+ //
9
+ // embedded --arm--> releasing --(user runs sudo)--> daemon
10
+ // ^ |
11
+ // +---------------- (daemon stops) ---------------------+
12
+ //
13
+ // `releasing` is the honest name for the middle state: our peer is down and the
14
+ // daemon is not up yet, so messaging is offline and the UI must say so. Hiding
15
+ // that gap would make dropped messages look like a network fault.
16
+ import { daemonIsRunning } from "./ipc.js";
17
+ import { Logger } from "./logger.js";
18
+ /** How often we look for the daemon socket appearing or vanishing. */
19
+ const WATCH_INTERVAL_MS = 1500;
20
+ /** How long we stay in `releasing` before giving up and coming back. */
21
+ const RELEASE_TIMEOUT_MS = 10 * 60 * 1000;
22
+ /**
23
+ * A PeerHost whose backend can change under it.
24
+ *
25
+ * Everything above this (the HTTP server, the UI) keeps one reference and never
26
+ * learns that the peer was swapped — which is the point: a backend change must
27
+ * not be a restart.
28
+ */
29
+ export class SwitchablePeerHost {
30
+ #inner;
31
+ #state;
32
+ #opts;
33
+ #log;
34
+ #timer = null;
35
+ #releaseDeadline = 0;
36
+ #busy = false;
37
+ constructor(initial, opts) {
38
+ this.#inner = initial;
39
+ this.#state = initial.kind === "daemon" ? "daemon" : "embedded";
40
+ this.#opts = opts;
41
+ this.#log = opts.log ?? new Logger({ prefix: "LAN" });
42
+ this.#watch();
43
+ }
44
+ get kind() {
45
+ return this.#inner?.kind ?? "embedded";
46
+ }
47
+ get hasVirtualLan() {
48
+ return this.#inner?.hasVirtualLan ?? false;
49
+ }
50
+ get state() {
51
+ return this.#state;
52
+ }
53
+ /** Seconds left before `releasing` gives up; 0 when not releasing. */
54
+ get releaseSecondsLeft() {
55
+ if (this.#state !== "releasing")
56
+ return 0;
57
+ return Math.max(0, Math.round((this.#releaseDeadline - Date.now()) / 1000));
58
+ }
59
+ async call(req) {
60
+ if (!this.#inner) {
61
+ // Fail fast and say why. A hung call here would surface as the UI
62
+ // freezing, which reads as a bug rather than as "you have a step to do".
63
+ return {
64
+ ok: false,
65
+ error: "Beagle released its peer and is waiting for the decentlan daemon to start. Messaging resumes when it does.",
66
+ };
67
+ }
68
+ return this.#inner.call(req);
69
+ }
70
+ async stop() {
71
+ if (this.#timer)
72
+ clearInterval(this.#timer);
73
+ this.#timer = null;
74
+ await this.#inner?.stop();
75
+ }
76
+ /**
77
+ * Step 1 of enabling: give up the identity so a daemon can take it.
78
+ *
79
+ * We do NOT run the installer. It needs root, and beagle escalating on the
80
+ * user's behalf — even via a prompt we trigger — is a privilege we do not
81
+ * want this app to have. The UI shows the command; the OS asks for the
82
+ * password; the user decides.
83
+ */
84
+ async armForDaemon() {
85
+ if (this.#state === "daemon")
86
+ return { state: "daemon", command: INSTALL_COMMAND };
87
+ if (this.#busy)
88
+ return { state: this.#state, command: INSTALL_COMMAND };
89
+ this.#busy = true;
90
+ try {
91
+ this.#log.info("releasing the embedded peer so a daemon can take this identity");
92
+ const old = this.#inner;
93
+ this.#inner = null; // reject calls from here on, rather than racing a stopping peer
94
+ this.#setState("releasing");
95
+ this.#releaseDeadline = Date.now() + RELEASE_TIMEOUT_MS;
96
+ await old?.stop();
97
+ return { state: this.#state, command: INSTALL_COMMAND };
98
+ }
99
+ finally {
100
+ this.#busy = false;
101
+ }
102
+ }
103
+ /** Abandon the handoff and bring the embedded peer back. */
104
+ async cancelArm() {
105
+ if (this.#state !== "releasing")
106
+ return this.#state;
107
+ await this.#backToEmbedded("cancelled by the user");
108
+ return this.#state;
109
+ }
110
+ #setState(next) {
111
+ if (this.#state === next)
112
+ return;
113
+ this.#state = next;
114
+ this.#opts.onChange?.(next, this.#inner);
115
+ }
116
+ #watch() {
117
+ this.#timer = setInterval(() => {
118
+ void this.#tick().catch((err) => this.#log.warn(`watch failed: ${err.message}`));
119
+ }, WATCH_INTERVAL_MS);
120
+ this.#timer.unref?.();
121
+ }
122
+ async #tick() {
123
+ if (this.#busy)
124
+ return;
125
+ const daemonUp = daemonIsRunning(this.#opts.dataDir);
126
+ if (this.#state === "releasing") {
127
+ if (daemonUp) {
128
+ this.#busy = true;
129
+ try {
130
+ this.#log.info("daemon is up — switching to the IPC backend");
131
+ this.#inner = this.#opts.makeDaemon();
132
+ this.#setState("daemon");
133
+ }
134
+ finally {
135
+ this.#busy = false;
136
+ }
137
+ }
138
+ else if (Date.now() > this.#releaseDeadline) {
139
+ await this.#backToEmbedded("timed out waiting for the daemon");
140
+ }
141
+ return;
142
+ }
143
+ // The daemon went away (stopped, uninstalled, crashed). Take the identity
144
+ // back so messaging keeps working — a user who stops the LAN service should
145
+ // not silently lose chat.
146
+ if (this.#state === "daemon" && !daemonUp) {
147
+ await this.#backToEmbedded("the daemon is no longer running");
148
+ }
149
+ }
150
+ async #backToEmbedded(why) {
151
+ if (this.#busy)
152
+ return;
153
+ this.#busy = true;
154
+ try {
155
+ this.#log.info(`falling back to the embedded peer: ${why}`);
156
+ this.#inner = null;
157
+ this.#inner = await this.#opts.makeEmbedded();
158
+ this.#setState("embedded");
159
+ }
160
+ catch (err) {
161
+ // Leaving #inner null is correct: call() then returns a clear error
162
+ // instead of pretending. The next tick retries.
163
+ this.#log.error(`could not restart the embedded peer: ${err.message}`);
164
+ }
165
+ finally {
166
+ this.#busy = false;
167
+ }
168
+ }
169
+ }
170
+ /** The command the user runs. Shown, never executed by us. */
171
+ export const INSTALL_COMMAND = "sudo agentnet service install";
@@ -13,6 +13,9 @@ export interface PeerHost {
13
13
  * point of the split is that it is the same node, not a new one. */
14
14
  export declare function decentlanCarrierDir(configDir: string): string;
15
15
  export declare function defaultConfigDir(): string;
16
+ /** Build an IPC-backed host directly. Used by the LAN handoff, which knows a
17
+ * daemon has appeared and needs to switch to it without re-running detection. */
18
+ export declare function makeDaemonHost(dataDir: string): PeerHost;
16
19
  export interface OpenPeerHostResult {
17
20
  host: PeerHost;
18
21
  /** Human-readable reason for the choice, for the startup banner. */
package/dist/peer-host.js CHANGED
@@ -58,6 +58,11 @@ class DaemonHost {
58
58
  // never start or stop the user's daemon as a side effect.
59
59
  }
60
60
  }
61
+ /** Build an IPC-backed host directly. Used by the LAN handoff, which knows a
62
+ * daemon has appeared and needs to switch to it without re-running detection. */
63
+ export function makeDaemonHost(dataDir) {
64
+ return new DaemonHost(dataDir);
65
+ }
61
66
  /**
62
67
  * Pick a backend.
63
68
  *
@@ -86,6 +91,7 @@ export async function openPeerHost(opts) {
86
91
  const host = new EmbeddedHost({
87
92
  configDir: opts.configDir,
88
93
  keyFile,
94
+ dataDir,
89
95
  bootstrapNodes: opts.bootstrapNodes,
90
96
  expressNodes: opts.expressNodes,
91
97
  nickname: opts.nickname,
package/dist/server.d.ts CHANGED
@@ -20,6 +20,20 @@ export interface BeagleServerOptions {
20
20
  /** Directory where the daemon saves received files (<configDir>/downloads).
21
21
  * Used to serve GET /api/file-download. Undefined disables downloads. */
22
22
  downloadsDir?: string;
23
+ /** The swappable backend, so the UI can show which one is live and drive the
24
+ * LAN handoff. Optional: a caller that pins one backend just omits it and
25
+ * the network panel falls back to "no virtual LAN here". */
26
+ lanHost?: {
27
+ readonly kind: string;
28
+ readonly hasVirtualLan: boolean;
29
+ readonly state: string;
30
+ readonly releaseSecondsLeft: number;
31
+ armForDaemon: () => Promise<{
32
+ state: string;
33
+ command: string;
34
+ }>;
35
+ cancelArm: () => Promise<string>;
36
+ };
23
37
  listenHost?: string;
24
38
  listenPort?: number;
25
39
  log?: (msg: string) => void;
package/dist/server.js CHANGED
@@ -17,6 +17,7 @@ import net from "node:net";
17
17
  import { existsSync, readFileSync, writeFileSync, statSync, createReadStream } from "node:fs";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { dirname, join } from "node:path";
20
+ import { INSTALL_COMMAND as LAN_INSTALL_COMMAND } from "./lan-handoff.js";
20
21
  import yaml from "js-yaml";
21
22
  import { DEFAULT_EXITS } from "./exits.js";
22
23
  // Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
@@ -798,6 +799,41 @@ export function startBeagleServer(opts) {
798
799
  }
799
800
  // Brief 30: submit a rendered form. The values are the user's own input;
800
801
  // the host validates and bounds them before they hit the wire.
802
+ // Which backend is live, and where we are in the LAN handoff. Polled by
803
+ // the network panel, so it must be cheap and must never throw.
804
+ if (req.method === "GET" && url === "/api/backend") {
805
+ sendJson(res, 200, {
806
+ ok: true,
807
+ kind: opts.lanHost?.kind ?? "embedded",
808
+ hasVirtualLan: opts.lanHost?.hasVirtualLan ?? false,
809
+ state: opts.lanHost?.state ?? "embedded",
810
+ releaseSecondsLeft: opts.lanHost?.releaseSecondsLeft ?? 0,
811
+ command: LAN_INSTALL_COMMAND,
812
+ switchable: Boolean(opts.lanHost),
813
+ });
814
+ return;
815
+ }
816
+ // Step 1 of enabling the LAN: release the identity. We stop our peer and
817
+ // then wait — we do NOT run the installer, because it needs root and
818
+ // beagle escalating for the user is a privilege this app should not have.
819
+ if (req.method === "POST" && url === "/api/lan-arm") {
820
+ if (!opts.lanHost) {
821
+ sendJson(res, 400, { ok: false, error: "this backend cannot be switched" });
822
+ return;
823
+ }
824
+ const r = await opts.lanHost.armForDaemon();
825
+ sendJson(res, 200, { ok: true, ...r });
826
+ return;
827
+ }
828
+ if (req.method === "POST" && url === "/api/lan-cancel") {
829
+ if (!opts.lanHost) {
830
+ sendJson(res, 400, { ok: false, error: "this backend cannot be switched" });
831
+ return;
832
+ }
833
+ const state = await opts.lanHost.cancelArm();
834
+ sendJson(res, 200, { ok: true, state });
835
+ return;
836
+ }
801
837
  if (req.method === "POST" && url === "/api/chat-send") {
802
838
  const { userid, text } = await readBody(req);
803
839
  const r = await opts.call({ op: "chat-send", userid, text });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",