@decentnetwork/beagle 0.1.87 → 0.1.89
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 +6 -0
- package/dist/desktop/app.js +235 -7
- package/dist/embedded-host.js +19 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/ipc.d.ts +34 -1
- package/dist/ipc.js +58 -2
- package/dist/lan-handoff.d.ts +14 -2
- package/dist/lan-handoff.js +76 -4
- package/dist/peer-host.js +3 -3
- package/dist/server.js +13 -0
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -145,6 +145,12 @@ async function main() {
|
|
|
145
145
|
makeEmbedded: openEmbedded,
|
|
146
146
|
makeDaemon: () => makeDaemonHost(decentlanCarrierDir(args.configDir)),
|
|
147
147
|
onChange: (state) => console.log(`[LAN] backend is now: ${state}`),
|
|
148
|
+
// `--backend` has to reach the watcher, or it only ever pinned the FIRST
|
|
149
|
+
// choice: this wrapper re-detected on a 1500ms timer and overrode it a
|
|
150
|
+
// second and a half later. That is why `--backend embedded` did not
|
|
151
|
+
// protect Beagle Desktop on Windows, and why `--backend daemon` on 245
|
|
152
|
+
// still fell back and took the identity lock.
|
|
153
|
+
force: args.backend,
|
|
148
154
|
});
|
|
149
155
|
// If this machine also runs a dora, surface its allocation table.
|
|
150
156
|
const doraCandidates = args.doraDir
|
package/dist/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
1
|
+
window.__DK_UI_VERSION="0.1.89";
|
|
2
2
|
"use strict";
|
|
3
3
|
(() => {
|
|
4
4
|
// packages/beagle-ui/dist/beagle-ui.js
|
|
@@ -521,6 +521,9 @@ window.__DK_UI_VERSION="0.1.86";
|
|
|
521
521
|
minWidth: 0
|
|
522
522
|
} }, dkContactPreview(peer.lastMsg) || peer.lastMsg))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4, flexShrink: 0 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)" } }, peer.lastTime), peer.unread ? /* @__PURE__ */ React.createElement(Unread, { n: peer.unread }) : peer.pending ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 11, stroke: 2.2, color: "var(--warn, #d29922)", title: "friend request pending" }) : /* @__PURE__ */ React.createElement(StatusDot, { online: peer.online })));
|
|
523
523
|
}
|
|
524
|
+
function dkActivityTs(p) {
|
|
525
|
+
return Math.max(p.lastTs || 0, p.acceptedAt || 0, p.requestedAt || 0);
|
|
526
|
+
}
|
|
524
527
|
function PeerSidebar({ T, peers, requests, activeId, onSelect, onAct, onAdd, prefillAddr, onPrefillConsumed }) {
|
|
525
528
|
const [q, setQ] = React.useState("");
|
|
526
529
|
const [addr, setAddr] = React.useState("");
|
|
@@ -558,7 +561,7 @@ window.__DK_UI_VERSION="0.1.86";
|
|
|
558
561
|
const s = (p.alias || "") + " " + p.userId + " " + p.ip;
|
|
559
562
|
return s.toLowerCase().includes(q.toLowerCase());
|
|
560
563
|
}).slice().sort((a, b) => {
|
|
561
|
-
const ta = a
|
|
564
|
+
const ta = dkActivityTs(a), tb = dkActivityTs(b);
|
|
562
565
|
if (ta !== tb)
|
|
563
566
|
return tb - ta;
|
|
564
567
|
return String(a.alias || a.userId || "").localeCompare(String(b.alias || b.userId || ""));
|
|
@@ -2717,6 +2720,35 @@ ${peer.address}`
|
|
|
2717
2720
|
)));
|
|
2718
2721
|
return /* @__PURE__ */ React.createElement(Card, { label: zh ? "\u663E\u793A" : "Display" }, /* @__PURE__ */ React.createElement("div", { style: { ...row, borderBottom: "1px solid var(--line)" } }, /* @__PURE__ */ React.createElement("span", { style: name }, zh ? "\u8BED\u8A00" : "Language"), seg("lang", t.lang, (v) => setTweak("lang", v), [["en", "EN"], ["zh", "\u4E2D\u6587"]])), /* @__PURE__ */ React.createElement("div", { style: row }, /* @__PURE__ */ React.createElement("span", { style: name }, zh ? "\u4E3B\u9898" : "Theme"), seg("theme", t.theme, (v) => setTweak("theme", v), [["dark", zh ? "\u6DF1\u8272" : "Dark"], ["light", zh ? "\u6D45\u8272" : "Light"]])));
|
|
2719
2722
|
}
|
|
2723
|
+
function DkAlertSettings({ t, setTweak }) {
|
|
2724
|
+
const zh = t.lang === "zh";
|
|
2725
|
+
const toggle = (key, label, sub, last) => /* @__PURE__ */ React.createElement("label", { style: {
|
|
2726
|
+
display: "flex",
|
|
2727
|
+
alignItems: "center",
|
|
2728
|
+
gap: 13,
|
|
2729
|
+
padding: "13px 16px",
|
|
2730
|
+
cursor: "pointer",
|
|
2731
|
+
borderBottom: last ? "none" : "1px solid var(--line)"
|
|
2732
|
+
} }, /* @__PURE__ */ React.createElement(
|
|
2733
|
+
"input",
|
|
2734
|
+
{
|
|
2735
|
+
type: "checkbox",
|
|
2736
|
+
checked: t[key] !== false,
|
|
2737
|
+
onChange: (e) => setTweak(key, e.target.checked),
|
|
2738
|
+
style: { width: 16, height: 16, accentColor: "var(--accent)" }
|
|
2739
|
+
}
|
|
2740
|
+
), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13.5, fontWeight: 600, color: "var(--text)" } }, label), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--dim)", marginTop: 2 } }, sub)));
|
|
2741
|
+
return /* @__PURE__ */ React.createElement(Card, { label: zh ? "\u63D0\u9192" : "Alerts" }, toggle(
|
|
2742
|
+
"alertSound",
|
|
2743
|
+
zh ? "\u65B0\u6D88\u606F\u63D0\u793A\u97F3" : "Sound for new messages",
|
|
2744
|
+
zh ? "\u6536\u5230\u65B0\u6D88\u606F\u6216\u597D\u53CB\u8BF7\u6C42\u65F6\u64AD\u653E\u77ED\u4FC3\u63D0\u793A\u97F3\u3002\u6B63\u5728\u770B\u7684\u5F53\u524D\u4F1A\u8BDD\u4E0D\u4F1A\u54CD\u3002" : "A short chime when a message or friend request arrives. Silent for the conversation you are reading."
|
|
2745
|
+
), toggle(
|
|
2746
|
+
"alertFlash",
|
|
2747
|
+
zh ? "\u6807\u7B7E\u9875\u95EA\u70C1" : "Flash the tab",
|
|
2748
|
+
zh ? "\u7A97\u53E3\u4E0D\u5728\u524D\u53F0\u65F6\uFF0C\u6807\u7B7E\u6807\u9898\u548C\u56FE\u6807\u95EA\u70C1\u63D0\u793A\uFF0C\u56DE\u5230\u7A97\u53E3\u5373\u505C\u6B62\u3002" : "While this window is in the background, the tab title and icon blink until you come back.",
|
|
2749
|
+
true
|
|
2750
|
+
));
|
|
2751
|
+
}
|
|
2720
2752
|
function ProfileTab({ T, me, onEdit, t, setTweak }) {
|
|
2721
2753
|
var _a;
|
|
2722
2754
|
const [qr, setQr] = React.useState(null);
|
|
@@ -2738,7 +2770,7 @@ ${peer.address}`
|
|
|
2738
2770
|
},
|
|
2739
2771
|
style: { width: 16, height: 16, accentColor: "var(--accent)" }
|
|
2740
2772
|
}
|
|
2741
|
-
), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13.5, fontWeight: 600, color: "var(--text)" } }, T && T.autoAcceptLabel || "Auto-accept friend requests"), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--dim)", marginTop: 2 } }, T && T.autoAcceptSub || "Off: requests wait in the sidebar for your approval.")))), hostHasKeyBackup && /* @__PURE__ */ React.createElement(DkKeyBackup, { t: t || {} }), t && setTweak && /* @__PURE__ */ React.createElement(DkDisplaySettings, { t, setTweak }), /* @__PURE__ */ React.createElement(Card, { label: T.network }, /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.virtualIp, value: me.ip, copy: true }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.wireLabel, value: `${me.wire} \xB7 lossless`, mono: false }), /* @__PURE__ */ React.createElement(DkVersionRow, { T, me }), /* @__PURE__ */ React.createElement(DkUpdateCheck, { T })), /* @__PURE__ */ React.createElement(Card, { label: T.dangerZone }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 13, padding: "14px 16px" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 34, height: 34, borderRadius: 8, flexShrink: 0, background: "color-mix(in oklab, var(--danger), transparent 86%)", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--danger)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "trash", size: 17, stroke: 2 })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13.5, fontWeight: 600, color: "var(--danger)" } }, T.deleteNode), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)", marginTop: 1 } }, T.deleteSub)), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", size: "sm" }, T.delete)))), qr && /* @__PURE__ */ React.createElement(DkQrModal, { T, value: qr.value, label: qr.label, onClose: () => setQr(null) }), editing && /* @__PURE__ */ React.createElement(DkEditModal, { T, me, onClose: () => setEditing(false), onSave: (name, description) => {
|
|
2773
|
+
), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13.5, fontWeight: 600, color: "var(--text)" } }, T && T.autoAcceptLabel || "Auto-accept friend requests"), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--dim)", marginTop: 2 } }, T && T.autoAcceptSub || "Off: requests wait in the sidebar for your approval.")))), hostHasKeyBackup && /* @__PURE__ */ React.createElement(DkKeyBackup, { t: t || {} }), t && setTweak && /* @__PURE__ */ React.createElement(DkAlertSettings, { t, setTweak }), t && setTweak && /* @__PURE__ */ React.createElement(DkDisplaySettings, { t, setTweak }), /* @__PURE__ */ React.createElement(Card, { label: T.network }, /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.virtualIp, value: me.ip, copy: true }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.wireLabel, value: `${me.wire} \xB7 lossless`, mono: false }), /* @__PURE__ */ React.createElement(DkVersionRow, { T, me }), /* @__PURE__ */ React.createElement(DkUpdateCheck, { T })), /* @__PURE__ */ React.createElement(Card, { label: T.dangerZone }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 13, padding: "14px 16px" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 34, height: 34, borderRadius: 8, flexShrink: 0, background: "color-mix(in oklab, var(--danger), transparent 86%)", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--danger)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "trash", size: 17, stroke: 2 })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13.5, fontWeight: 600, color: "var(--danger)" } }, T.deleteNode), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)", marginTop: 1 } }, T.deleteSub)), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", size: "sm" }, T.delete)))), qr && /* @__PURE__ */ React.createElement(DkQrModal, { T, value: qr.value, label: qr.label, onClose: () => setQr(null) }), editing && /* @__PURE__ */ React.createElement(DkEditModal, { T, me, onClose: () => setEditing(false), onSave: (name, description) => {
|
|
2742
2774
|
if (onEdit)
|
|
2743
2775
|
onEdit(name, description);
|
|
2744
2776
|
setEditing(false);
|
|
@@ -4287,6 +4319,181 @@ ${peer.address}`
|
|
|
4287
4319
|
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 }))))));
|
|
4288
4320
|
}
|
|
4289
4321
|
Object.assign(window, { NetworkTab });
|
|
4322
|
+
var dkAudioCtx = null;
|
|
4323
|
+
function dkAudio() {
|
|
4324
|
+
if (dkAudioCtx && dkAudioCtx.state !== "closed")
|
|
4325
|
+
return dkAudioCtx;
|
|
4326
|
+
try {
|
|
4327
|
+
dkAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
4328
|
+
} catch (e) {
|
|
4329
|
+
dkAudioCtx = null;
|
|
4330
|
+
}
|
|
4331
|
+
return dkAudioCtx;
|
|
4332
|
+
}
|
|
4333
|
+
function dkUnlockAudioOnGesture() {
|
|
4334
|
+
const unlock = () => {
|
|
4335
|
+
const c = dkAudio();
|
|
4336
|
+
if (c && c.state === "suspended")
|
|
4337
|
+
c.resume().catch(() => {
|
|
4338
|
+
});
|
|
4339
|
+
};
|
|
4340
|
+
window.addEventListener("pointerdown", unlock, { passive: true });
|
|
4341
|
+
window.addEventListener("keydown", unlock, { passive: true });
|
|
4342
|
+
return () => {
|
|
4343
|
+
window.removeEventListener("pointerdown", unlock);
|
|
4344
|
+
window.removeEventListener("keydown", unlock);
|
|
4345
|
+
};
|
|
4346
|
+
}
|
|
4347
|
+
function dkChime(kind) {
|
|
4348
|
+
const c = dkAudio();
|
|
4349
|
+
if (!c)
|
|
4350
|
+
return;
|
|
4351
|
+
if (c.state === "suspended")
|
|
4352
|
+
c.resume().catch(() => {
|
|
4353
|
+
});
|
|
4354
|
+
const notes = kind === "request" ? [[660, 0], [880, 0.14]] : [[880, 0], [1175, 0.11]];
|
|
4355
|
+
const t0 = c.currentTime;
|
|
4356
|
+
for (const [freq, at] of notes) {
|
|
4357
|
+
const o = c.createOscillator(), g = c.createGain();
|
|
4358
|
+
o.type = "sine";
|
|
4359
|
+
o.frequency.value = freq;
|
|
4360
|
+
o.connect(g);
|
|
4361
|
+
g.connect(c.destination);
|
|
4362
|
+
const t = t0 + at;
|
|
4363
|
+
g.gain.setValueAtTime(1e-4, t);
|
|
4364
|
+
g.gain.exponentialRampToValueAtTime(0.18, t + 0.015);
|
|
4365
|
+
g.gain.exponentialRampToValueAtTime(1e-4, t + 0.17);
|
|
4366
|
+
o.start(t);
|
|
4367
|
+
o.stop(t + 0.2);
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
4370
|
+
var dkFlash = { timer: null, title: "", link: null, href: null, badged: null, on: false, n: 0, label: "" };
|
|
4371
|
+
function dkBadgedIcon(href) {
|
|
4372
|
+
return new Promise((res, rej) => {
|
|
4373
|
+
const img = new Image();
|
|
4374
|
+
img.onload = () => {
|
|
4375
|
+
try {
|
|
4376
|
+
if (!img.naturalWidth)
|
|
4377
|
+
return rej(new Error("icon has no intrinsic size"));
|
|
4378
|
+
const s = 64, cv = document.createElement("canvas");
|
|
4379
|
+
cv.width = s;
|
|
4380
|
+
cv.height = s;
|
|
4381
|
+
const cx = cv.getContext("2d");
|
|
4382
|
+
cx.drawImage(img, 0, 0, s, s);
|
|
4383
|
+
cx.beginPath();
|
|
4384
|
+
cx.arc(s - 14, 14, 12, 0, Math.PI * 2);
|
|
4385
|
+
cx.fillStyle = "#e5484d";
|
|
4386
|
+
cx.fill();
|
|
4387
|
+
cx.lineWidth = 3;
|
|
4388
|
+
cx.strokeStyle = "#fff";
|
|
4389
|
+
cx.stroke();
|
|
4390
|
+
res(cv.toDataURL("image/png"));
|
|
4391
|
+
} catch (e) {
|
|
4392
|
+
rej(e);
|
|
4393
|
+
}
|
|
4394
|
+
};
|
|
4395
|
+
img.onerror = () => rej(new Error("icon failed to load"));
|
|
4396
|
+
img.src = href;
|
|
4397
|
+
});
|
|
4398
|
+
}
|
|
4399
|
+
function dkFlashTick() {
|
|
4400
|
+
dkFlash.on = !dkFlash.on;
|
|
4401
|
+
document.title = `(${dkFlash.n}) ${dkFlash.on ? dkFlash.label : dkFlash.title}`;
|
|
4402
|
+
}
|
|
4403
|
+
function dkFlashStart(label, count) {
|
|
4404
|
+
dkFlash.n += count || 1;
|
|
4405
|
+
dkFlash.label = label;
|
|
4406
|
+
if (dkFlash.timer)
|
|
4407
|
+
return;
|
|
4408
|
+
dkFlash.title = document.title;
|
|
4409
|
+
dkFlash.link = document.querySelector('link[rel~="icon"]');
|
|
4410
|
+
dkFlash.href = dkFlash.link ? dkFlash.link.href : null;
|
|
4411
|
+
dkFlash.badged = null;
|
|
4412
|
+
dkFlash.on = false;
|
|
4413
|
+
if (dkFlash.href) {
|
|
4414
|
+
dkBadgedIcon(dkFlash.href).then((b) => {
|
|
4415
|
+
dkFlash.badged = b;
|
|
4416
|
+
if (dkFlash.timer && dkFlash.link)
|
|
4417
|
+
dkFlash.link.href = b;
|
|
4418
|
+
}).catch(() => {
|
|
4419
|
+
});
|
|
4420
|
+
}
|
|
4421
|
+
dkFlashTick();
|
|
4422
|
+
dkFlash.timer = setInterval(dkFlashTick, 1e3);
|
|
4423
|
+
}
|
|
4424
|
+
function dkFlashStop() {
|
|
4425
|
+
if (!dkFlash.timer)
|
|
4426
|
+
return;
|
|
4427
|
+
clearInterval(dkFlash.timer);
|
|
4428
|
+
dkFlash.timer = null;
|
|
4429
|
+
dkFlash.n = 0;
|
|
4430
|
+
document.title = dkFlash.title;
|
|
4431
|
+
if (dkFlash.link && dkFlash.href)
|
|
4432
|
+
dkFlash.link.href = dkFlash.href;
|
|
4433
|
+
}
|
|
4434
|
+
function dkLooking() {
|
|
4435
|
+
return document.visibilityState === "visible" && document.hasFocus();
|
|
4436
|
+
}
|
|
4437
|
+
function useIncomingAlerts({ T, peers, requests, loaded, activeId, sound = true, flash = true }) {
|
|
4438
|
+
const prev = React.useRef(null);
|
|
4439
|
+
React.useEffect(() => dkUnlockAudioOnGesture(), []);
|
|
4440
|
+
React.useEffect(() => {
|
|
4441
|
+
if (!flash)
|
|
4442
|
+
dkFlashStop();
|
|
4443
|
+
}, [flash]);
|
|
4444
|
+
React.useEffect(() => {
|
|
4445
|
+
const seen = () => {
|
|
4446
|
+
if (dkLooking())
|
|
4447
|
+
dkFlashStop();
|
|
4448
|
+
};
|
|
4449
|
+
window.addEventListener("focus", seen);
|
|
4450
|
+
window.addEventListener("pointerdown", seen, { passive: true });
|
|
4451
|
+
document.addEventListener("visibilitychange", seen);
|
|
4452
|
+
return () => {
|
|
4453
|
+
window.removeEventListener("focus", seen);
|
|
4454
|
+
window.removeEventListener("pointerdown", seen);
|
|
4455
|
+
document.removeEventListener("visibilitychange", seen);
|
|
4456
|
+
dkFlashStop();
|
|
4457
|
+
};
|
|
4458
|
+
}, []);
|
|
4459
|
+
React.useEffect(() => {
|
|
4460
|
+
if (!loaded)
|
|
4461
|
+
return;
|
|
4462
|
+
const byId = /* @__PURE__ */ new Map();
|
|
4463
|
+
for (const p of peers || []) {
|
|
4464
|
+
const dir = p.lastDir || (String(p.lastMsg || "").startsWith("you: ") ? "out" : "in");
|
|
4465
|
+
byId.set(p.id, { ts: p.lastTs || 0, dir });
|
|
4466
|
+
}
|
|
4467
|
+
const reqIds = new Set((requests || []).map((r) => r.id));
|
|
4468
|
+
const was = prev.current;
|
|
4469
|
+
prev.current = { byId, reqIds };
|
|
4470
|
+
if (!was)
|
|
4471
|
+
return;
|
|
4472
|
+
let msgs = 0, elsewhere = 0, reqs = 0;
|
|
4473
|
+
for (const [id, cur] of byId) {
|
|
4474
|
+
if (cur.dir !== "in" || !cur.ts)
|
|
4475
|
+
continue;
|
|
4476
|
+
const old = was.byId.get(id);
|
|
4477
|
+
if (old ? cur.ts <= old.ts : Date.now() - cur.ts > 3e4)
|
|
4478
|
+
continue;
|
|
4479
|
+
msgs += 1;
|
|
4480
|
+
if (id !== activeId)
|
|
4481
|
+
elsewhere += 1;
|
|
4482
|
+
}
|
|
4483
|
+
for (const id of reqIds)
|
|
4484
|
+
if (!was.reqIds.has(id))
|
|
4485
|
+
reqs += 1;
|
|
4486
|
+
if (!msgs && !reqs)
|
|
4487
|
+
return;
|
|
4488
|
+
const looking = dkLooking();
|
|
4489
|
+
if (sound && !(looking && !reqs && !elsewhere))
|
|
4490
|
+
dkChime(reqs && !msgs ? "request" : "message");
|
|
4491
|
+
if (flash && !looking) {
|
|
4492
|
+
const label = msgs ? T && T.alertMsg || "New message" : T && T.alertReq || "New friend request";
|
|
4493
|
+
dkFlashStart(label, msgs + reqs);
|
|
4494
|
+
}
|
|
4495
|
+
}, [peers, requests, loaded, activeId, T, sound, flash]);
|
|
4496
|
+
}
|
|
4290
4497
|
var TWEAKS_STORAGE_KEY = "decentlan.tweaks";
|
|
4291
4498
|
function useTweaks(defaults) {
|
|
4292
4499
|
const [values, setValues] = React.useState(() => {
|
|
@@ -4344,7 +4551,9 @@ ${peer.address}`
|
|
|
4344
4551
|
"accent": "#7B6CF6",
|
|
4345
4552
|
"density": "compact",
|
|
4346
4553
|
"lang": "en",
|
|
4347
|
-
"startTab": "chat"
|
|
4554
|
+
"startTab": "chat",
|
|
4555
|
+
"alertSound": true,
|
|
4556
|
+
"alertFlash": true
|
|
4348
4557
|
}
|
|
4349
4558
|
);
|
|
4350
4559
|
function dkDetectLang() {
|
|
@@ -4413,6 +4622,8 @@ ${peer.address}`
|
|
|
4413
4622
|
network: "Network",
|
|
4414
4623
|
apps: "Apps",
|
|
4415
4624
|
profile: "Profile",
|
|
4625
|
+
alertMsg: "New message",
|
|
4626
|
+
alertReq: "New friend request",
|
|
4416
4627
|
recommended: "Discover",
|
|
4417
4628
|
registered: "Names",
|
|
4418
4629
|
here: "Here",
|
|
@@ -4593,6 +4804,8 @@ ${peer.address}`
|
|
|
4593
4804
|
network: "\u7F51\u7EDC",
|
|
4594
4805
|
apps: "\u5E94\u7528",
|
|
4595
4806
|
profile: "\u6211\u7684",
|
|
4807
|
+
alertMsg: "\u65B0\u6D88\u606F",
|
|
4808
|
+
alertReq: "\u65B0\u7684\u597D\u53CB\u8BF7\u6C42",
|
|
4596
4809
|
recommended: "\u63A8\u8350",
|
|
4597
4810
|
registered: "\u540D\u5F55",
|
|
4598
4811
|
here: "\u5728\u573A",
|
|
@@ -4985,6 +5198,15 @@ ${peer.address}`
|
|
|
4985
5198
|
const T = STR[t.lang] || STR.en;
|
|
4986
5199
|
const vars = dkTheme(t.theme, t.accent);
|
|
4987
5200
|
const rowPad = t.density === "comfortable" ? "11px 12px" : "7px 10px";
|
|
5201
|
+
useIncomingAlerts({
|
|
5202
|
+
T,
|
|
5203
|
+
peers,
|
|
5204
|
+
requests,
|
|
5205
|
+
loaded: data.loaded,
|
|
5206
|
+
activeId,
|
|
5207
|
+
sound: t.alertSound !== false,
|
|
5208
|
+
flash: t.alertFlash !== false
|
|
5209
|
+
});
|
|
4988
5210
|
const callCtl = useCallController(me.userId, (peerId, video, direction) => {
|
|
4989
5211
|
const text = `WebRTC ${video ? "video" : "audio"} call: ${direction}`;
|
|
4990
5212
|
dkApi.logLocal(peerId, direction === "outgoing" ? "out" : "in", text).then(() => {
|
|
@@ -5126,10 +5348,16 @@ ${peer.address}`
|
|
|
5126
5348
|
{ id: "recommended", icon: "sparkles", label: T.recommended },
|
|
5127
5349
|
{ id: "registered", icon: "at", label: T.registered },
|
|
5128
5350
|
{ id: "network", icon: "network", label: T.network },
|
|
5129
|
-
{ id: "apps", icon: "grid", label: T.apps }
|
|
5130
|
-
{ id: "profile", icon: "userRound", label: T.profile }
|
|
5351
|
+
{ id: "apps", icon: "grid", label: T.apps }
|
|
5131
5352
|
];
|
|
5132
|
-
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("
|
|
5353
|
+
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("button", { onClick: () => setTab("profile"), title: T.profile, style: {
|
|
5354
|
+
padding: 4,
|
|
5355
|
+
borderRadius: 12,
|
|
5356
|
+
cursor: "pointer",
|
|
5357
|
+
display: "flex",
|
|
5358
|
+
border: "1px solid " + (tab === "profile" ? "var(--line)" : "transparent"),
|
|
5359
|
+
background: tab === "profile" ? "var(--panel-2)" : "transparent"
|
|
5360
|
+
} }, /* @__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(DkBrowserNotice, { lang: t.lang }), /* @__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: T.profile }).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 === "here" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "bridge", peers, meId: me.userId, onAdd, onOpenChat }), tab === "recommended" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "recommended", peers, meId: me.userId, onAdd, onOpenChat }), tab === "registered" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "registered", peers, meId: me.userId, onAdd, onOpenChat }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat, backend, onArmLan: armLan, onCancelLan: cancelLan }), tab === "apps" && /* @__PURE__ */ React.createElement(AppsTab, { T, t }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit, t, setTweak })), data.locked && /* @__PURE__ */ React.createElement(
|
|
5133
5361
|
DkLockedOut,
|
|
5134
5362
|
{
|
|
5135
5363
|
compact: true,
|
package/dist/embedded-host.js
CHANGED
|
@@ -90,19 +90,24 @@ export class EmbeddedHost {
|
|
|
90
90
|
this.#messages = new MessageStore(resolve(opts.configDir, "messages.json"));
|
|
91
91
|
this.#meta = new FriendMetaStore(resolve(opts.configDir, "friends-meta.json"));
|
|
92
92
|
}
|
|
93
|
-
/** Friend-list order: pinned first, then most recent
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
93
|
+
/** Friend-list order: pinned first, then most recent ACTIVITY, then by
|
|
94
|
+
* name. Activity is the newest of the last message, the friendship being
|
|
95
|
+
* accepted, and our request going out — so a friend you added or accepted
|
|
96
|
+
* a moment ago ranks exactly as if they had just written to you. The
|
|
97
|
+
* earlier rule sorted by last message alone and ranked new friends only
|
|
98
|
+
* among the never-messaged group at the bottom, which on a long list still
|
|
99
|
+
* put them below the fold and made the accept look like a no-op. Returns
|
|
100
|
+
* a new array — `friends()` hands back the SDK's own list and sorting it
|
|
101
|
+
* in place would reorder the friend store as a side effect of merely
|
|
102
|
+
* rendering the sidebar. */
|
|
97
103
|
#sortForList(friends, last) {
|
|
98
104
|
const rank = (f) => [
|
|
99
105
|
this.#meta.get(f.carrierId)?.pinned ? 0 : 1,
|
|
100
|
-
// Negated so a LARGER timestamp sorts first.
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
last.has(f.carrierId) ? -last.get(f.carrierId).ts : Number.MAX_SAFE_INTEGER,
|
|
106
|
+
// Negated so a LARGER timestamp sorts first. Nothing at all → 0, which
|
|
107
|
+
// parks the entry after every real one; a plain number, so two such
|
|
108
|
+
// entries compare 0 - 0 = 0 and fall through to the name (Infinity
|
|
109
|
+
// would give NaN there).
|
|
110
|
+
-Math.max(last.get(f.carrierId)?.ts ?? 0, f.acceptedAt ?? 0, f.requestedAt ?? 0),
|
|
106
111
|
(f.name || f.carrierId).toLowerCase(),
|
|
107
112
|
];
|
|
108
113
|
return [...friends].sort((a, b) => {
|
|
@@ -453,6 +458,10 @@ export class EmbeddedHost {
|
|
|
453
458
|
f.status === "online" ||
|
|
454
459
|
this.#messages.hasInbound(f.carrierId),
|
|
455
460
|
lastSeen: f.acceptedAt,
|
|
461
|
+
// When WE sent the request. Counts as activity for the sidebar
|
|
462
|
+
// order: someone you just added belongs at the top while you
|
|
463
|
+
// wait for them, not filed by name at the bottom.
|
|
464
|
+
requestedAt: f.requestedAt,
|
|
456
465
|
// The avatar they advertised over Carrier (userinfo `gender`).
|
|
457
466
|
// Peer-to-peer, so it works for a friend who never registered a
|
|
458
467
|
// beagles.eth name — until now that meant an identicon.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { startBeagleServer, type BeagleServerOptions } from "./server.js";
|
|
2
2
|
export { openPeerHost, defaultConfigDir, decentlanCarrierDir, type PeerHost, type BackendKind } from "./peer-host.js";
|
|
3
|
-
export { ipcCall, ipcSocketPath,
|
|
3
|
+
export { ipcCall, ipcSocketPath, probeDaemon, daemonSocketMayExist, type IpcRequest, type IpcResponse, type IpcOp } from "./ipc.js";
|
package/dist/index.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
// server and the backend abstraction are exported so it can be embedded.
|
|
3
3
|
export { startBeagleServer } from "./server.js";
|
|
4
4
|
export { openPeerHost, defaultConfigDir, decentlanCarrierDir } from "./peer-host.js";
|
|
5
|
-
export { ipcCall, ipcSocketPath,
|
|
5
|
+
export { ipcCall, ipcSocketPath, probeDaemon, daemonSocketMayExist } from "./ipc.js";
|
package/dist/ipc.d.ts
CHANGED
|
@@ -43,6 +43,39 @@ export declare function ipcSocketPath(dataDir: string, platform?: NodeJS.Platfor
|
|
|
43
43
|
/** fs.existsSync is meaningful for Unix-domain sockets but always false for
|
|
44
44
|
* Windows named pipes, so on Windows we connect and let net report. */
|
|
45
45
|
export declare function ipcSocketSupportsFsExistenceCheck(platform?: NodeJS.Platform): boolean;
|
|
46
|
-
|
|
46
|
+
/** How long the liveness probe waits for a connect before calling it dead.
|
|
47
|
+
* Local IPC: a listening socket accepts in microseconds, so this is a
|
|
48
|
+
* generous ceiling that still keeps the 1500ms watch tick honest. */
|
|
49
|
+
export declare const DAEMON_PROBE_TIMEOUT_MS = 400;
|
|
50
|
+
/**
|
|
51
|
+
* Is a decentlan daemon actually LISTENING for this identity?
|
|
52
|
+
*
|
|
53
|
+
* The only trustworthy answer is a connection. The previous check asked the
|
|
54
|
+
* filesystem, which cannot answer on either platform:
|
|
55
|
+
*
|
|
56
|
+
* - Windows named pipes never appear to `existsSync`, so the old code
|
|
57
|
+
* returned a hard-coded `true` — "can't tell; try it". Every beagle on
|
|
58
|
+
* Windows therefore believed in a daemon that was not there, handed its
|
|
59
|
+
* identity to a pipe nobody served, and failed every peer op with
|
|
60
|
+
* `connect ENOENT \\.\pipe\agentnet-…`. That one wrong default is why
|
|
61
|
+
* add-friend, renaming and .beagles.eth registration were all broken.
|
|
62
|
+
* - On Unix a `daemon.sock` left behind by a crashed daemon exists just as
|
|
63
|
+
* firmly as a live one, so the same promotion happened there too, only
|
|
64
|
+
* rarer.
|
|
65
|
+
*
|
|
66
|
+
* Connecting costs a round trip to a local socket and removes both. It is the
|
|
67
|
+
* same code on every platform, which is the real win: no branch that only one
|
|
68
|
+
* of us can test.
|
|
69
|
+
*/
|
|
70
|
+
export declare function probeDaemon(dataDir: string, timeoutMs?: number): Promise<boolean>;
|
|
71
|
+
/**
|
|
72
|
+
* Sync, best-effort: could a daemon socket be there?
|
|
73
|
+
*
|
|
74
|
+
* Only for callers that genuinely cannot await. It answers `false` when it
|
|
75
|
+
* cannot tell — the opposite of the old default, and the whole point. A `true`
|
|
76
|
+
* from this is still only a hint; confirm with {@link probeDaemon} before
|
|
77
|
+
* handing an identity to anything.
|
|
78
|
+
*/
|
|
79
|
+
export declare function daemonSocketMayExist(dataDir: string): boolean;
|
|
47
80
|
/** One request, one newline-delimited JSON response. */
|
|
48
81
|
export declare function ipcCall(dataDir: string, req: IpcRequest, timeoutMs?: number): Promise<IpcResponse>;
|
package/dist/ipc.js
CHANGED
|
@@ -27,9 +27,65 @@ export function ipcSocketPath(dataDir, platform = process.platform) {
|
|
|
27
27
|
export function ipcSocketSupportsFsExistenceCheck(platform = process.platform) {
|
|
28
28
|
return platform !== "win32";
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
/** How long the liveness probe waits for a connect before calling it dead.
|
|
31
|
+
* Local IPC: a listening socket accepts in microseconds, so this is a
|
|
32
|
+
* generous ceiling that still keeps the 1500ms watch tick honest. */
|
|
33
|
+
export const DAEMON_PROBE_TIMEOUT_MS = 400;
|
|
34
|
+
/**
|
|
35
|
+
* Is a decentlan daemon actually LISTENING for this identity?
|
|
36
|
+
*
|
|
37
|
+
* The only trustworthy answer is a connection. The previous check asked the
|
|
38
|
+
* filesystem, which cannot answer on either platform:
|
|
39
|
+
*
|
|
40
|
+
* - Windows named pipes never appear to `existsSync`, so the old code
|
|
41
|
+
* returned a hard-coded `true` — "can't tell; try it". Every beagle on
|
|
42
|
+
* Windows therefore believed in a daemon that was not there, handed its
|
|
43
|
+
* identity to a pipe nobody served, and failed every peer op with
|
|
44
|
+
* `connect ENOENT \\.\pipe\agentnet-…`. That one wrong default is why
|
|
45
|
+
* add-friend, renaming and .beagles.eth registration were all broken.
|
|
46
|
+
* - On Unix a `daemon.sock` left behind by a crashed daemon exists just as
|
|
47
|
+
* firmly as a live one, so the same promotion happened there too, only
|
|
48
|
+
* rarer.
|
|
49
|
+
*
|
|
50
|
+
* Connecting costs a round trip to a local socket and removes both. It is the
|
|
51
|
+
* same code on every platform, which is the real win: no branch that only one
|
|
52
|
+
* of us can test.
|
|
53
|
+
*/
|
|
54
|
+
export function probeDaemon(dataDir, timeoutMs = DAEMON_PROBE_TIMEOUT_MS) {
|
|
55
|
+
const sockPath = ipcSocketPath(dataDir);
|
|
56
|
+
// A cheap negative where the filesystem CAN answer. Never a positive: an
|
|
57
|
+
// existing socket file is not a listening daemon, which was the second bug.
|
|
58
|
+
if (ipcSocketSupportsFsExistenceCheck() && !existsSync(sockPath)) {
|
|
59
|
+
return Promise.resolve(false);
|
|
60
|
+
}
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const sock = createConnection(sockPath);
|
|
63
|
+
let settled = false;
|
|
64
|
+
const finish = (alive) => {
|
|
65
|
+
if (settled)
|
|
66
|
+
return;
|
|
67
|
+
settled = true;
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
sock.destroy();
|
|
70
|
+
resolve(alive);
|
|
71
|
+
};
|
|
72
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
73
|
+
timer.unref?.();
|
|
74
|
+
sock.on("connect", () => finish(true));
|
|
75
|
+
sock.on("error", () => finish(false));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Sync, best-effort: could a daemon socket be there?
|
|
80
|
+
*
|
|
81
|
+
* Only for callers that genuinely cannot await. It answers `false` when it
|
|
82
|
+
* cannot tell — the opposite of the old default, and the whole point. A `true`
|
|
83
|
+
* from this is still only a hint; confirm with {@link probeDaemon} before
|
|
84
|
+
* handing an identity to anything.
|
|
85
|
+
*/
|
|
86
|
+
export function daemonSocketMayExist(dataDir) {
|
|
31
87
|
if (!ipcSocketSupportsFsExistenceCheck())
|
|
32
|
-
return
|
|
88
|
+
return false;
|
|
33
89
|
return existsSync(ipcSocketPath(dataDir));
|
|
34
90
|
}
|
|
35
91
|
/** One request, one newline-delimited JSON response. */
|
package/dist/lan-handoff.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { type IpcRequest, type IpcResponse } from "./ipc.js";
|
|
2
2
|
import { Logger } from "./logger.js";
|
|
3
|
-
import type { PeerHost } from "./peer-host.js";
|
|
4
|
-
export type HandoffState = "embedded" | "releasing" | "daemon";
|
|
3
|
+
import type { BackendKind, PeerHost } from "./peer-host.js";
|
|
4
|
+
export type HandoffState = "embedded" | "releasing" | "daemon" | "waiting";
|
|
5
|
+
/** How long a vanished daemon is treated as "restarting" before auto mode
|
|
6
|
+
* gives up on it and takes the identity back. `agentnet restart` and a
|
|
7
|
+
* launchd/systemd kickstart are both back well inside this. */
|
|
8
|
+
export declare const DAEMON_GRACE_MS: number;
|
|
5
9
|
export interface SwitchableHostOptions {
|
|
6
10
|
dataDir: string;
|
|
7
11
|
/** Build a fresh embedded host. Called on first start and on every fallback,
|
|
@@ -11,6 +15,14 @@ export interface SwitchableHostOptions {
|
|
|
11
15
|
makeDaemon: () => PeerHost;
|
|
12
16
|
onChange?: (state: HandoffState, host: PeerHost | null) => void;
|
|
13
17
|
log?: Logger;
|
|
18
|
+
/** The backend the user pinned with `--backend`, if any. A forced backend is
|
|
19
|
+
* never switched away from automatically — in EITHER direction. Auto mode
|
|
20
|
+
* (undefined) keeps the full state machine. */
|
|
21
|
+
force?: BackendKind;
|
|
22
|
+
/** Grace period before auto mode falls back from a vanished daemon. */
|
|
23
|
+
graceMs?: number;
|
|
24
|
+
/** Liveness probe, injectable for tests. Defaults to a real connect. */
|
|
25
|
+
probe?: (dataDir: string) => Promise<boolean>;
|
|
14
26
|
}
|
|
15
27
|
/**
|
|
16
28
|
* A PeerHost whose backend can change under it.
|
package/dist/lan-handoff.js
CHANGED
|
@@ -13,12 +13,16 @@
|
|
|
13
13
|
// `releasing` is the honest name for the middle state: our peer is down and the
|
|
14
14
|
// daemon is not up yet, so messaging is offline and the UI must say so. Hiding
|
|
15
15
|
// that gap would make dropped messages look like a network fault.
|
|
16
|
-
import {
|
|
16
|
+
import { probeDaemon } from "./ipc.js";
|
|
17
17
|
import { Logger } from "./logger.js";
|
|
18
18
|
/** How often we look for the daemon socket appearing or vanishing. */
|
|
19
19
|
const WATCH_INTERVAL_MS = 1500;
|
|
20
20
|
/** How long we stay in `releasing` before giving up and coming back. */
|
|
21
21
|
const RELEASE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
22
|
+
/** How long a vanished daemon is treated as "restarting" before auto mode
|
|
23
|
+
* gives up on it and takes the identity back. `agentnet restart` and a
|
|
24
|
+
* launchd/systemd kickstart are both back well inside this. */
|
|
25
|
+
export const DAEMON_GRACE_MS = 60 * 1000;
|
|
22
26
|
/**
|
|
23
27
|
* A PeerHost whose backend can change under it.
|
|
24
28
|
*
|
|
@@ -34,11 +38,16 @@ export class SwitchablePeerHost {
|
|
|
34
38
|
#timer = null;
|
|
35
39
|
#releaseDeadline = 0;
|
|
36
40
|
#busy = false;
|
|
41
|
+
/** When the daemon was first seen missing, 0 while it is present. Drives the
|
|
42
|
+
* grace period; reset the moment it answers again. */
|
|
43
|
+
#daemonMissingSince = 0;
|
|
44
|
+
#probe;
|
|
37
45
|
constructor(initial, opts) {
|
|
38
46
|
this.#inner = initial;
|
|
39
47
|
this.#state = initial.kind === "daemon" ? "daemon" : "embedded";
|
|
40
48
|
this.#opts = opts;
|
|
41
49
|
this.#log = opts.log ?? new Logger({ prefix: "LAN" });
|
|
50
|
+
this.#probe = opts.probe ?? ((dir) => probeDaemon(dir));
|
|
42
51
|
this.#watch();
|
|
43
52
|
}
|
|
44
53
|
get kind() {
|
|
@@ -62,7 +71,9 @@ export class SwitchablePeerHost {
|
|
|
62
71
|
// freezing, which reads as a bug rather than as "you have a step to do".
|
|
63
72
|
return {
|
|
64
73
|
ok: false,
|
|
65
|
-
error:
|
|
74
|
+
error: this.#state === "waiting"
|
|
75
|
+
? "The decentlan daemon is restarting. Beagle is holding this identity for it and will re-attach automatically."
|
|
76
|
+
: "Beagle released its peer and is waiting for the decentlan daemon to start. Messaging resumes when it does.",
|
|
66
77
|
};
|
|
67
78
|
}
|
|
68
79
|
return this.#inner.call(req);
|
|
@@ -122,7 +133,39 @@ export class SwitchablePeerHost {
|
|
|
122
133
|
async #tick() {
|
|
123
134
|
if (this.#busy)
|
|
124
135
|
return;
|
|
125
|
-
const daemonUp =
|
|
136
|
+
const daemonUp = await this.#probe(this.#opts.dataDir);
|
|
137
|
+
if (daemonUp)
|
|
138
|
+
this.#daemonMissingSince = 0;
|
|
139
|
+
else if (this.#daemonMissingSince === 0)
|
|
140
|
+
this.#daemonMissingSince = Date.now();
|
|
141
|
+
// A tick can await; make sure nothing started while we were probing.
|
|
142
|
+
if (this.#busy)
|
|
143
|
+
return;
|
|
144
|
+
// `--backend daemon` means "this box's identity belongs to the daemon".
|
|
145
|
+
// Honour that at RUNTIME too, not just at startup. Falling back here is
|
|
146
|
+
// what deadlocked 245 twice: the embedded peer took <dataDir>/daemon.pid,
|
|
147
|
+
// launchd then could not restart the daemon (it refuses when a live
|
|
148
|
+
// process holds the identity), and the "way back" could never fire because
|
|
149
|
+
// it waits for a daemon that our own lock is keeping down. `waiting` is
|
|
150
|
+
// the honest state — no peer, no lock, ops rejected with a reason — and it
|
|
151
|
+
// costs a minute of messaging instead of needing a human to kill beagle
|
|
152
|
+
// and rm the pidfile.
|
|
153
|
+
if (this.#opts.force === "daemon") {
|
|
154
|
+
if (this.#state === "daemon" && !daemonUp) {
|
|
155
|
+
this.#log.info("the daemon went away — waiting for it to come back (forced daemon backend)");
|
|
156
|
+
const old = this.#inner;
|
|
157
|
+
this.#inner = null;
|
|
158
|
+
this.#setState("waiting");
|
|
159
|
+
await old?.stop();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (this.#state === "waiting" && daemonUp) {
|
|
163
|
+
this.#log.info("the daemon is back — re-attaching");
|
|
164
|
+
this.#inner = this.#opts.makeDaemon();
|
|
165
|
+
this.#setState("daemon");
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
126
169
|
if (this.#state === "releasing") {
|
|
127
170
|
if (daemonUp) {
|
|
128
171
|
this.#busy = true;
|
|
@@ -143,7 +186,18 @@ export class SwitchablePeerHost {
|
|
|
143
186
|
// The daemon went away (stopped, uninstalled, crashed). Take the identity
|
|
144
187
|
// back so messaging keeps working — a user who stops the LAN service should
|
|
145
188
|
// not silently lose chat.
|
|
189
|
+
//
|
|
190
|
+
// But WAIT first. A daemon that was here a second ago is far more often
|
|
191
|
+
// restarting than gone: `agentnet restart` is the documented upgrade step,
|
|
192
|
+
// and a supervisor kickstart looks identical. Grabbing the identity lock in
|
|
193
|
+
// that gap is what stops the daemon coming back at all, so the fast path
|
|
194
|
+
// was the deadlock. One minute of the daemon's own downtime is cheap; the
|
|
195
|
+
// deadlock cost 20 minutes and a human.
|
|
146
196
|
if (this.#state === "daemon" && !daemonUp) {
|
|
197
|
+
const downMs = Date.now() - this.#daemonMissingSince;
|
|
198
|
+
const graceMs = this.#opts.graceMs ?? DAEMON_GRACE_MS;
|
|
199
|
+
if (downMs < graceMs)
|
|
200
|
+
return; // probably a restart — say nothing, just wait
|
|
147
201
|
await this.#backToEmbedded("the daemon is no longer running");
|
|
148
202
|
return;
|
|
149
203
|
}
|
|
@@ -155,7 +209,12 @@ export class SwitchablePeerHost {
|
|
|
155
209
|
// was running the whole time, invisible to it. The version panel then
|
|
156
210
|
// reported the APP's peer as the daemon's, so an update that had actually
|
|
157
211
|
// landed looked like it had not.
|
|
158
|
-
|
|
212
|
+
//
|
|
213
|
+
// Not when the user pinned `--backend embedded`, though. That flag used to
|
|
214
|
+
// stop at startup: cli.ts wrapped the forced host in this watcher, which
|
|
215
|
+
// promoted it right back. Beagle Desktop passes `--backend embedded` and
|
|
216
|
+
// was demoted within 1.5s anyway — on Windows to a pipe nobody served.
|
|
217
|
+
if (this.#state === "embedded" && daemonUp && this.#opts.force !== "embedded") {
|
|
159
218
|
await this.#backToDaemon();
|
|
160
219
|
}
|
|
161
220
|
}
|
|
@@ -189,6 +248,19 @@ export class SwitchablePeerHost {
|
|
|
189
248
|
return;
|
|
190
249
|
this.#busy = true;
|
|
191
250
|
try {
|
|
251
|
+
// Last look before we commit. makeEmbedded() takes <dataDir>/daemon.pid,
|
|
252
|
+
// and that write is the point of no return: from then on the daemon
|
|
253
|
+
// cannot start, so it cannot prove us wrong. Rather than thread a probe
|
|
254
|
+
// into acquireIdentityLock — which would have to be sync, and a sync
|
|
255
|
+
// check is exactly what could not answer on Windows — close the window
|
|
256
|
+
// here, where the decision is actually made and awaiting is free.
|
|
257
|
+
if (await this.#probe(this.#opts.dataDir)) {
|
|
258
|
+
this.#log.info("the daemon answered after all — staying off the identity lock");
|
|
259
|
+
this.#daemonMissingSince = 0;
|
|
260
|
+
this.#inner = this.#opts.makeDaemon();
|
|
261
|
+
this.#setState("daemon");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
192
264
|
this.#log.info(`falling back to the embedded peer: ${why}`);
|
|
193
265
|
this.#inner = null;
|
|
194
266
|
this.#inner = await this.#opts.makeEmbedded();
|
package/dist/peer-host.js
CHANGED
|
@@ -21,7 +21,7 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { resolve } from "node:path";
|
|
22
22
|
import { existsSync, readFileSync } from "node:fs";
|
|
23
23
|
import yaml from "js-yaml";
|
|
24
|
-
import {
|
|
24
|
+
import { ipcCall, ipcSocketPath, probeDaemon } from "./ipc.js";
|
|
25
25
|
/** Where decentlan keeps its Carrier identity and daemon socket. Beagle reuses
|
|
26
26
|
* this identity when it exists so a user's friends carry over — the whole
|
|
27
27
|
* point of the split is that it is the same node, not a new one. */
|
|
@@ -73,7 +73,7 @@ export function makeDaemonHost(dataDir) {
|
|
|
73
73
|
*/
|
|
74
74
|
export async function openPeerHost(opts) {
|
|
75
75
|
const dataDir = decentlanCarrierDir(opts.configDir);
|
|
76
|
-
if (opts.force !== "embedded" &&
|
|
76
|
+
if (opts.force !== "embedded" && (await probeDaemon(dataDir))) {
|
|
77
77
|
return { host: new DaemonHost(dataDir), why: `decentlan daemon at ${dataDir}` };
|
|
78
78
|
}
|
|
79
79
|
if (opts.force === "daemon") {
|
|
@@ -91,7 +91,7 @@ export async function openPeerHost(opts) {
|
|
|
91
91
|
const waitMs = Math.max(0, opts.waitForDaemonMs ?? 60_000);
|
|
92
92
|
const deadline = Date.now() + waitMs;
|
|
93
93
|
let waited = false;
|
|
94
|
-
while (!
|
|
94
|
+
while (!(await probeDaemon(dataDir))) {
|
|
95
95
|
if (Date.now() >= deadline) {
|
|
96
96
|
throw new Error(`--backend daemon was requested but no decentlan daemon appeared within ${Math.round(waitMs / 1000)}s ` +
|
|
97
97
|
`(looked for ${ipcSocketPath(dataDir)}).`);
|
package/dist/server.js
CHANGED
|
@@ -1975,6 +1975,19 @@ export function startBeagleServer(opts) {
|
|
|
1975
1975
|
// be ordered, which is why the sidebar never reordered on new
|
|
1976
1976
|
// activity.
|
|
1977
1977
|
lastTs: lm?.ts ?? 0,
|
|
1978
|
+
// Which way the last message went. The UI's new-message alert
|
|
1979
|
+
// needs to know an inbound stamp moved, and inferring that from
|
|
1980
|
+
// the "you: " prefix on the preview is a convention, not a field.
|
|
1981
|
+
lastDir: lm?.dir ?? null,
|
|
1982
|
+
// When the friendship was established, and when we asked for it.
|
|
1983
|
+
// The sidebar ranks a friend by the newest of these and the last
|
|
1984
|
+
// message, so someone you just accepted — or just added — lands
|
|
1985
|
+
// at the top like a fresh message would, not in a no-history
|
|
1986
|
+
// group at the bottom. A daemon backend reports only lastSeen
|
|
1987
|
+
// (its acceptedAt); requestedAt is embedded-only and reads 0
|
|
1988
|
+
// elsewhere.
|
|
1989
|
+
acceptedAt: f.lastSeen ?? 0,
|
|
1990
|
+
requestedAt: f.requestedAt ?? 0,
|
|
1978
1991
|
wire: "163",
|
|
1979
1992
|
avatarUrl: ensPub.get(uid)?.avatarUrl ?? null,
|
|
1980
1993
|
// What they advertised over Carrier wins over the directory: it is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/beagle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.89",
|
|
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",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "npm run build -w @decentnetwork/beagle-ui && tsc -p tsconfig.json && chmod +x dist/cli.js && node scripts/build-ui.mjs",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
|
+
"test": "tsc -p tsconfig.test.json && node --test .test-build/test/*.test.js",
|
|
24
25
|
"clean": "rm -rf dist",
|
|
25
26
|
"start": "node dist/cli.js"
|
|
26
27
|
},
|