@decentnetwork/beagle 0.1.3 → 0.1.4
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 +23 -2
- package/dist/desktop/app.js +129 -3
- package/dist/embedded-host.d.ts +2 -0
- package/dist/embedded-host.js +21 -0
- package/dist/identity-lock.d.ts +18 -0
- package/dist/identity-lock.js +85 -0
- package/dist/lan-handoff.d.ts +48 -0
- package/dist/lan-handoff.js +171 -0
- package/dist/peer-host.d.ts +3 -0
- package/dist/peer-host.js +6 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.js +36 -0
- package/package.json +1 -1
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) =>
|
|
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"),
|
package/dist/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
1
|
+
window.__DK_UI_VERSION="0.1.3";
|
|
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"/>',
|
|
@@ -2291,7 +2291,111 @@ function ExitCard({ T, region, activeExit, onSetExit }) {
|
|
|
2291
2291
|
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
2292
|
})));
|
|
2293
2293
|
}
|
|
2294
|
-
function
|
|
2294
|
+
function LanEnableCard({ backend, onArm, onCancel }) {
|
|
2295
|
+
const [busy, setBusy] = React.useState(false);
|
|
2296
|
+
const [copied, setCopied] = React.useState(false);
|
|
2297
|
+
const releasing = backend.state === "releasing";
|
|
2298
|
+
const cmd = backend.command || "sudo agentnet service install";
|
|
2299
|
+
const copy = () => {
|
|
2300
|
+
navigator.clipboard.writeText(cmd).then(() => {
|
|
2301
|
+
setCopied(true);
|
|
2302
|
+
setTimeout(() => setCopied(false), 1600);
|
|
2303
|
+
}).catch(() => {
|
|
2304
|
+
});
|
|
2305
|
+
};
|
|
2306
|
+
return /* @__PURE__ */ React.createElement("div", { style: {
|
|
2307
|
+
display: "flex",
|
|
2308
|
+
flexDirection: "column",
|
|
2309
|
+
gap: 14,
|
|
2310
|
+
padding: "20px 22px",
|
|
2311
|
+
borderRadius: 12,
|
|
2312
|
+
background: "var(--panel)",
|
|
2313
|
+
border: "1px solid var(--line)"
|
|
2314
|
+
} }, /* @__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(
|
|
2315
|
+
"button",
|
|
2316
|
+
{
|
|
2317
|
+
onClick: async () => {
|
|
2318
|
+
setBusy(true);
|
|
2319
|
+
try {
|
|
2320
|
+
await onArm();
|
|
2321
|
+
} finally {
|
|
2322
|
+
setBusy(false);
|
|
2323
|
+
}
|
|
2324
|
+
},
|
|
2325
|
+
disabled: busy,
|
|
2326
|
+
style: {
|
|
2327
|
+
alignSelf: "flex-start",
|
|
2328
|
+
padding: "9px 16px",
|
|
2329
|
+
borderRadius: 10,
|
|
2330
|
+
border: "none",
|
|
2331
|
+
cursor: busy ? "default" : "pointer",
|
|
2332
|
+
background: "var(--accent)",
|
|
2333
|
+
color: "#fff",
|
|
2334
|
+
fontFamily: "var(--mono)",
|
|
2335
|
+
fontSize: 13,
|
|
2336
|
+
fontWeight: 700,
|
|
2337
|
+
opacity: busy ? 0.6 : 1
|
|
2338
|
+
}
|
|
2339
|
+
},
|
|
2340
|
+
busy ? "\u2026" : "\u542F\u7528\u865A\u62DF\u5C40\u57DF\u7F51"
|
|
2341
|
+
)) : /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 10 } }, /* @__PURE__ */ React.createElement("div", { style: {
|
|
2342
|
+
display: "flex",
|
|
2343
|
+
alignItems: "center",
|
|
2344
|
+
gap: 8,
|
|
2345
|
+
padding: "9px 12px",
|
|
2346
|
+
borderRadius: 9,
|
|
2347
|
+
background: "rgba(245,158,11,0.14)",
|
|
2348
|
+
border: "1px solid rgba(245,158,11,0.5)"
|
|
2349
|
+
} }, /* @__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: {
|
|
2350
|
+
flex: 1,
|
|
2351
|
+
fontFamily: "var(--mono)",
|
|
2352
|
+
fontSize: 13,
|
|
2353
|
+
padding: "10px 12px",
|
|
2354
|
+
borderRadius: 8,
|
|
2355
|
+
background: "var(--panel-2)",
|
|
2356
|
+
border: "1px solid var(--line)",
|
|
2357
|
+
color: "var(--text)",
|
|
2358
|
+
overflowX: "auto"
|
|
2359
|
+
} }, cmd), /* @__PURE__ */ React.createElement(
|
|
2360
|
+
"button",
|
|
2361
|
+
{
|
|
2362
|
+
onClick: copy,
|
|
2363
|
+
style: {
|
|
2364
|
+
flexShrink: 0,
|
|
2365
|
+
padding: "9px 13px",
|
|
2366
|
+
borderRadius: 8,
|
|
2367
|
+
border: "1px solid var(--line)",
|
|
2368
|
+
cursor: "pointer",
|
|
2369
|
+
background: "var(--panel-2)",
|
|
2370
|
+
color: "var(--text)",
|
|
2371
|
+
fontFamily: "var(--mono)",
|
|
2372
|
+
fontSize: 12
|
|
2373
|
+
}
|
|
2374
|
+
},
|
|
2375
|
+
copied ? "\u5DF2\u590D\u5236" : "\u590D\u5236"
|
|
2376
|
+
)), /* @__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(
|
|
2377
|
+
"button",
|
|
2378
|
+
{
|
|
2379
|
+
onClick: () => onCancel(),
|
|
2380
|
+
style: {
|
|
2381
|
+
alignSelf: "flex-start",
|
|
2382
|
+
padding: "7px 13px",
|
|
2383
|
+
borderRadius: 8,
|
|
2384
|
+
border: "1px solid var(--line)",
|
|
2385
|
+
cursor: "pointer",
|
|
2386
|
+
background: "transparent",
|
|
2387
|
+
color: "var(--dim)",
|
|
2388
|
+
fontFamily: "var(--mono)",
|
|
2389
|
+
fontSize: 12
|
|
2390
|
+
}
|
|
2391
|
+
},
|
|
2392
|
+
"\u53D6\u6D88,\u6062\u590D\u6D88\u606F"
|
|
2393
|
+
)));
|
|
2394
|
+
}
|
|
2395
|
+
function NetworkTab({ T, me, peers, exits, activeExit, reqCount, onSetExit, onOpenChat, backend, onArmLan, onCancelLan }) {
|
|
2396
|
+
if (backend && backend.switchable && !backend.hasVirtualLan) {
|
|
2397
|
+
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 })));
|
|
2398
|
+
}
|
|
2295
2399
|
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
2400
|
}
|
|
2297
2401
|
Object.assign(window, { NetworkTab });
|
|
@@ -3367,6 +3471,28 @@ function DkApp() {
|
|
|
3367
3471
|
const [activeId, setActiveId] = React.useState(null);
|
|
3368
3472
|
const data = useDaemonData();
|
|
3369
3473
|
const me = data.me;
|
|
3474
|
+
const [backend, setBackend] = React.useState(null);
|
|
3475
|
+
const refreshBackend = React.useCallback(() => {
|
|
3476
|
+
dkGet("/api/backend").then((b) => {
|
|
3477
|
+
if (b && b.ok)
|
|
3478
|
+
setBackend(b);
|
|
3479
|
+
}).catch(() => {
|
|
3480
|
+
});
|
|
3481
|
+
}, []);
|
|
3482
|
+
React.useEffect(() => {
|
|
3483
|
+
refreshBackend();
|
|
3484
|
+
const ms = backend && backend.state === "releasing" ? 1500 : 8e3;
|
|
3485
|
+
const t2 = setInterval(refreshBackend, ms);
|
|
3486
|
+
return () => clearInterval(t2);
|
|
3487
|
+
}, [refreshBackend, backend && backend.state]);
|
|
3488
|
+
const armLan = React.useCallback(async () => {
|
|
3489
|
+
await dkPost("/api/lan-arm", {});
|
|
3490
|
+
refreshBackend();
|
|
3491
|
+
}, [refreshBackend]);
|
|
3492
|
+
const cancelLan = React.useCallback(async () => {
|
|
3493
|
+
await dkPost("/api/lan-cancel", {});
|
|
3494
|
+
refreshBackend();
|
|
3495
|
+
}, [refreshBackend]);
|
|
3370
3496
|
React.useEffect(() => {
|
|
3371
3497
|
const mine = window.__DK_UI_VERSION;
|
|
3372
3498
|
const served = me && me.lanVer;
|
|
@@ -3516,7 +3642,7 @@ function DkApp() {
|
|
|
3516
3642
|
{ id: "network", icon: "network", label: T.network },
|
|
3517
3643
|
{ id: "profile", icon: "userRound", label: T.profile }
|
|
3518
3644
|
];
|
|
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(
|
|
3645
|
+
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
3646
|
TweakRadio,
|
|
3521
3647
|
{
|
|
3522
3648
|
label: t.lang === "zh" ? "\u4E3B\u9898" : "Theme",
|
package/dist/embedded-host.d.ts
CHANGED
package/dist/embedded-host.js
CHANGED
|
@@ -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";
|
package/dist/peer-host.d.ts
CHANGED
|
@@ -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