@decentnetwork/beagle 0.1.126 → 0.1.128
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 +2 -0
- package/dist/desktop/app.js +195 -20
- package/dist/exit-probe.d.ts +81 -0
- package/dist/exit-probe.js +228 -0
- package/dist/friend-greetings.d.ts +45 -0
- package/dist/friend-greetings.js +119 -0
- package/dist/pending-requests.d.ts +3 -0
- package/dist/pending-requests.js +13 -1
- package/dist/server.d.ts +11 -0
- package/dist/server.js +131 -34
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -173,6 +173,8 @@ async function main() {
|
|
|
173
173
|
downloadsDir: resolve(args.configDir, "downloads"),
|
|
174
174
|
messagesPath: resolve(args.configDir, "messages.json"),
|
|
175
175
|
servicePeersPath: resolve(args.configDir, "service-peers-local.json"),
|
|
176
|
+
greetingsPath: resolve(args.configDir, "friend-greetings.json"),
|
|
177
|
+
exitStatePath: resolve(args.configDir, "beagle-exit-state.json"),
|
|
176
178
|
callIce: {
|
|
177
179
|
keyFile: resolve(decentlanCarrierDir(args.configDir), "keypair.json"),
|
|
178
180
|
bootstrapNodes,
|
package/dist/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
1
|
+
window.__DK_UI_VERSION="0.1.128";
|
|
2
2
|
"use strict";
|
|
3
3
|
(() => {
|
|
4
4
|
// packages/beagle-ui/dist/beagle-ui.js
|
|
@@ -272,7 +272,7 @@ window.__DK_UI_VERSION="0.1.126";
|
|
|
272
272
|
};
|
|
273
273
|
(thread || []).forEach((m, i) => {
|
|
274
274
|
const hit = m && !m.day ? isProto({ text: m.text, dir: m && m.from === "me" ? "out" : "in", ts: m && m.ts }) : false;
|
|
275
|
-
if (!hit || m && m.file) {
|
|
275
|
+
if (!hit || m && (m.file || m.kind === "friend-request")) {
|
|
276
276
|
flush();
|
|
277
277
|
out.push(m);
|
|
278
278
|
return;
|
|
@@ -442,6 +442,10 @@ window.__DK_UI_VERSION="0.1.126";
|
|
|
442
442
|
cancelSend: (userid, ids) => dkPost("/api/file-cancel", { userid, ids }),
|
|
443
443
|
retrySend: (userid, ids) => dkPost("/api/file-retry", { userid, ids }),
|
|
444
444
|
setProfile: (name, description) => dkPost("/api/set-profile", { name, description }),
|
|
445
|
+
// Probe a failed exit once, now (or every failed exit with all=true). Exits
|
|
446
|
+
// that failed three times in a row are never probed again on their own.
|
|
447
|
+
reconnectExit: (ip) => dkPost("/api/exits/reconnect", { ip }),
|
|
448
|
+
reconnectAllExits: () => dkPost("/api/exits/reconnect", { all: true }),
|
|
445
449
|
// Used by the browser's welcome flow, which also picks an avatar and marks
|
|
446
450
|
// first run done. Harmless where there is no welcome flow: nothing calls it.
|
|
447
451
|
setProfileFull: (p) => dkPost("/api/set-profile", p),
|
|
@@ -529,7 +533,10 @@ window.__DK_UI_VERSION="0.1.126";
|
|
|
529
533
|
// Brief 30: a validated component form, already whitelisted host-side.
|
|
530
534
|
// Passed through untouched — the renderer treats every string as text,
|
|
531
535
|
// never markup.
|
|
532
|
-
components: m.components
|
|
536
|
+
components: m.components,
|
|
537
|
+
// 'friend-request': the accepted request's greeting, merged in by the
|
|
538
|
+
// host (beagle's friend-greetings.ts); chat.jsx draws its own bubble.
|
|
539
|
+
kind: m.kind
|
|
533
540
|
};
|
|
534
541
|
}
|
|
535
542
|
var DK_TAIL_MIN = 30;
|
|
@@ -574,22 +581,27 @@ window.__DK_UI_VERSION="0.1.126";
|
|
|
574
581
|
}, [peers, isProtocol, enabled]);
|
|
575
582
|
return fixes;
|
|
576
583
|
}
|
|
577
|
-
function useDaemonData() {
|
|
584
|
+
function useDaemonData(opts) {
|
|
585
|
+
const wantExits = !!(opts && opts.exits);
|
|
586
|
+
const wantExitsRef = React.useRef(wantExits);
|
|
587
|
+
wantExitsRef.current = wantExits;
|
|
578
588
|
const [snap, setSnap] = React.useState({
|
|
579
589
|
me: DK_ME_FALLBACK,
|
|
580
590
|
peers: [],
|
|
581
591
|
requests: [],
|
|
582
592
|
exits: [],
|
|
583
593
|
activeExit: null,
|
|
594
|
+
exitFeature: null,
|
|
595
|
+
routeFallback: null,
|
|
584
596
|
loaded: false,
|
|
585
597
|
locked: false
|
|
586
598
|
});
|
|
587
599
|
const [threads, setThreads] = React.useState({});
|
|
588
600
|
const refreshRef = React.useRef(null);
|
|
589
601
|
const refresh = React.useCallback(async () => {
|
|
590
|
-
var _a;
|
|
602
|
+
var _a, _b, _c;
|
|
591
603
|
try {
|
|
592
|
-
const d = await dkGet("/api/desktop");
|
|
604
|
+
const d = await dkGet(wantExitsRef.current ? "/api/desktop?exits=1" : "/api/desktop");
|
|
593
605
|
if (d && d.locked) {
|
|
594
606
|
setSnap((s) => ({ ...s, locked: true, loaded: true }));
|
|
595
607
|
return;
|
|
@@ -607,6 +619,8 @@ window.__DK_UI_VERSION="0.1.126";
|
|
|
607
619
|
requests: d.requests || [],
|
|
608
620
|
exits: d.exits || [],
|
|
609
621
|
activeExit: (_a = d.activeExit) != null ? _a : null,
|
|
622
|
+
exitFeature: (_b = d.exitFeature) != null ? _b : null,
|
|
623
|
+
routeFallback: (_c = d.routeFallback) != null ? _c : null,
|
|
610
624
|
loaded: true
|
|
611
625
|
});
|
|
612
626
|
} catch (e) {
|
|
@@ -618,6 +632,10 @@ window.__DK_UI_VERSION="0.1.126";
|
|
|
618
632
|
const t = setInterval(refresh, 2500);
|
|
619
633
|
return () => clearInterval(t);
|
|
620
634
|
}, [refresh]);
|
|
635
|
+
React.useEffect(() => {
|
|
636
|
+
if (wantExits)
|
|
637
|
+
refresh();
|
|
638
|
+
}, [wantExits, refresh]);
|
|
621
639
|
const inFlight = React.useRef(/* @__PURE__ */ new Set());
|
|
622
640
|
const cache = React.useRef({});
|
|
623
641
|
const [earlier, setEarlier] = React.useState({});
|
|
@@ -626,15 +644,23 @@ window.__DK_UI_VERSION="0.1.126";
|
|
|
626
644
|
if (!c)
|
|
627
645
|
return;
|
|
628
646
|
const arr = [...c.byId.values()].sort((a, b) => a.ts - b.ts);
|
|
629
|
-
const
|
|
630
|
-
|
|
647
|
+
const withDay = [];
|
|
648
|
+
let lastDay = null;
|
|
649
|
+
for (const m of arr) {
|
|
650
|
+
const day = dkDayLabel(m.ts);
|
|
651
|
+
if (day !== lastDay) {
|
|
652
|
+
withDay.push({ day });
|
|
653
|
+
lastDay = day;
|
|
654
|
+
}
|
|
655
|
+
withDay.push(dkThreadMsg(m));
|
|
656
|
+
}
|
|
631
657
|
setThreads((t) => Object.assign({}, t, { [peerId]: withDay }));
|
|
632
658
|
setEarlier((e) => e[peerId] === c.more ? e : Object.assign({}, e, { [peerId]: c.more }));
|
|
633
659
|
}, []);
|
|
634
|
-
const loadThread = React.useCallback(async (peerId,
|
|
660
|
+
const loadThread = React.useCallback(async (peerId, opts2) => {
|
|
635
661
|
if (!peerId)
|
|
636
662
|
return;
|
|
637
|
-
if (
|
|
663
|
+
if (opts2 && opts2.reset)
|
|
638
664
|
delete cache.current[peerId];
|
|
639
665
|
if (inFlight.current.has(peerId))
|
|
640
666
|
return;
|
|
@@ -1728,6 +1754,25 @@ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
|
|
|
1728
1754
|
return { kind: "punk", id };
|
|
1729
1755
|
return { kind: "ens" };
|
|
1730
1756
|
}
|
|
1757
|
+
var REQUEST_TRANSPORTS = ["lan", "carrier", "direct", "relay", "express", "webrtc", "online", "offline", "tcp", "udp", "p2p"];
|
|
1758
|
+
function unquote(s) {
|
|
1759
|
+
const m = /^"([\s\S]*)"$/.exec(s);
|
|
1760
|
+
return m ? m[1] : s;
|
|
1761
|
+
}
|
|
1762
|
+
function requestGreeting(r) {
|
|
1763
|
+
const hello = r && typeof r.hello === "string" ? r.hello.trim() : "";
|
|
1764
|
+
const via = r && typeof r.via === "string" ? r.via.trim() : "";
|
|
1765
|
+
const isTransport = !!via && REQUEST_TRANSPORTS.includes(via.toLowerCase());
|
|
1766
|
+
const isName = !!via && !!r && typeof r.name === "string" && r.name.trim() === via;
|
|
1767
|
+
return {
|
|
1768
|
+
greeting: hello || (via && !isTransport && !isName ? unquote(via).trim() : ""),
|
|
1769
|
+
transport: isTransport ? via : ""
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
var GREETING_KIND = "friend-request";
|
|
1773
|
+
function isGreetingItem(m) {
|
|
1774
|
+
return !!m && m.kind === GREETING_KIND;
|
|
1775
|
+
}
|
|
1731
1776
|
var DK_CMD_HIST_KEY = "beagle.assistant.history";
|
|
1732
1777
|
var DK_CMD_HIST_MAX = 50;
|
|
1733
1778
|
function RequestsBlock({ T, requests, onAct }) {
|
|
@@ -1779,7 +1824,7 @@ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
|
|
|
1779
1824
|
overflow: "hidden",
|
|
1780
1825
|
textOverflow: "ellipsis",
|
|
1781
1826
|
whiteSpace: "nowrap"
|
|
1782
|
-
} }, r.name) : /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)", fontStyle: "italic" } }, T.noName || "no name given"), /* @__PURE__ */ React.createElement(Mono, { size: 11, copy: r.carrier, title: r.carrier }, shortKey(r.carrier, 8, 6)), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)", marginTop: 2 } }, "via "
|
|
1827
|
+
} }, r.name) : /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)", fontStyle: "italic" } }, T.noName || "no name given"), /* @__PURE__ */ React.createElement(Mono, { size: 11, copy: r.carrier, title: r.carrier }, shortKey(r.carrier, 8, 6)), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)", marginTop: 2 } }, requestGreeting(r).transport ? "via " + requestGreeting(r).transport + " \xB7 " : "", r.time))), /* @__PURE__ */ React.createElement(DkRequestGreeting, { T, text: requestGreeting(r).greeting }), r.descr && /* @__PURE__ */ React.createElement("div", { style: {
|
|
1783
1828
|
fontFamily: "var(--ui)",
|
|
1784
1829
|
fontSize: 12,
|
|
1785
1830
|
color: "var(--dim, var(--faint))",
|
|
@@ -1788,6 +1833,76 @@ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
|
|
|
1788
1833
|
paddingLeft: 8
|
|
1789
1834
|
} }, r.descr), failed[r.id] && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11, color: "var(--danger)" } }, failed[r.id]), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(Btn, { tone: "ok", icon: "check", size: "sm", onClick: () => act(r, "accept"), style: { flex: 1 } }, T.accept), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", icon: "x", size: "sm", onClick: () => act(r, "reject"), style: { flex: 1 } }, T.reject))))));
|
|
1790
1835
|
}
|
|
1836
|
+
function DkRequestGreeting({ T, text }) {
|
|
1837
|
+
const [open, setOpen] = React.useState(false);
|
|
1838
|
+
const [clipped, setClipped] = React.useState(false);
|
|
1839
|
+
const ref = React.useRef(null);
|
|
1840
|
+
React.useLayoutEffect(() => {
|
|
1841
|
+
const el = ref.current;
|
|
1842
|
+
if (el && !open)
|
|
1843
|
+
setClipped(el.scrollHeight > el.clientHeight + 1);
|
|
1844
|
+
}, [text, open]);
|
|
1845
|
+
if (!text)
|
|
1846
|
+
return null;
|
|
1847
|
+
const toggle = () => {
|
|
1848
|
+
try {
|
|
1849
|
+
const sel = window.getSelection && window.getSelection();
|
|
1850
|
+
if (sel && String(sel).length)
|
|
1851
|
+
return;
|
|
1852
|
+
} catch (e) {
|
|
1853
|
+
}
|
|
1854
|
+
if (clipped || open)
|
|
1855
|
+
setOpen((o) => !o);
|
|
1856
|
+
};
|
|
1857
|
+
return /* @__PURE__ */ React.createElement(
|
|
1858
|
+
"div",
|
|
1859
|
+
{
|
|
1860
|
+
onClick: toggle,
|
|
1861
|
+
title: !open && clipped ? T.showAll || "show all" : void 0,
|
|
1862
|
+
style: { cursor: clipped || open ? "pointer" : "text" }
|
|
1863
|
+
},
|
|
1864
|
+
/* @__PURE__ */ React.createElement("div", { style: { background: "var(--bub-them)", border: "1px solid var(--line)", borderRadius: 8, padding: "6px 9px" } }, /* @__PURE__ */ React.createElement("div", { ref, style: {
|
|
1865
|
+
fontFamily: "var(--ui)",
|
|
1866
|
+
fontSize: 12.5,
|
|
1867
|
+
color: "var(--text)",
|
|
1868
|
+
lineHeight: 1.45,
|
|
1869
|
+
whiteSpace: "pre-wrap",
|
|
1870
|
+
overflowWrap: "anywhere",
|
|
1871
|
+
userSelect: "text",
|
|
1872
|
+
WebkitUserSelect: "text",
|
|
1873
|
+
...open ? { maxHeight: 240, overflowY: "auto" } : { display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }
|
|
1874
|
+
} }, text)),
|
|
1875
|
+
(clipped || open) && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11, color: "var(--accent)", marginTop: 3 } }, open ? T.showLess || "show less" : T.showAll || "show all")
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
function DkGreetingMsg({ m, peer, T }) {
|
|
1879
|
+
return /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "flex-start", alignItems: "flex-end", gap: 8, margin: "3px 0 10px" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 24, radius: 6, dot: false }), /* @__PURE__ */ React.createElement("div", { style: {
|
|
1880
|
+
maxWidth: "75%",
|
|
1881
|
+
padding: "8px 12px",
|
|
1882
|
+
borderRadius: 12,
|
|
1883
|
+
borderBottomLeftRadius: 4,
|
|
1884
|
+
background: "var(--panel-2)",
|
|
1885
|
+
border: "1px dashed var(--accent)",
|
|
1886
|
+
color: "var(--text)"
|
|
1887
|
+
} }, /* @__PURE__ */ React.createElement("div", { style: {
|
|
1888
|
+
display: "flex",
|
|
1889
|
+
alignItems: "center",
|
|
1890
|
+
gap: 6,
|
|
1891
|
+
marginBottom: 4,
|
|
1892
|
+
fontFamily: "var(--mono)",
|
|
1893
|
+
fontSize: 10.5,
|
|
1894
|
+
color: "var(--accent)",
|
|
1895
|
+
fontWeight: 600
|
|
1896
|
+
} }, /* @__PURE__ */ React.createElement(Icon, { name: "userPlus", size: 12, stroke: 2.2, color: "var(--accent)" }), /* @__PURE__ */ React.createElement("span", null, T.friendRequestItem || "Friend request"), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--faint)", fontWeight: 400 } }, "\xB7 ", m.time)), /* @__PURE__ */ React.createElement("div", { style: {
|
|
1897
|
+
fontFamily: "var(--ui)",
|
|
1898
|
+
fontSize: 13.5,
|
|
1899
|
+
lineHeight: 1.5,
|
|
1900
|
+
whiteSpace: "pre-wrap",
|
|
1901
|
+
overflowWrap: "anywhere",
|
|
1902
|
+
userSelect: "text",
|
|
1903
|
+
WebkitUserSelect: "text"
|
|
1904
|
+
} }, m.text)));
|
|
1905
|
+
}
|
|
1791
1906
|
function DkRequestAvatar({ r, size, radius }) {
|
|
1792
1907
|
const [urlBroken, setUrlBroken] = React.useState(false);
|
|
1793
1908
|
const url = requestAvatarUrl(r);
|
|
@@ -2562,7 +2677,12 @@ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
|
|
|
2562
2677
|
return null;
|
|
2563
2678
|
}), err && /* @__PURE__ */ React.createElement("div", { style: { color: "var(--danger, #ff6b6b)", fontSize: 11.5, fontFamily: "var(--mono)" } }, err));
|
|
2564
2679
|
}
|
|
2565
|
-
function Msg(
|
|
2680
|
+
function Msg(props) {
|
|
2681
|
+
if (isGreetingItem(props.m))
|
|
2682
|
+
return /* @__PURE__ */ React.createElement(DkGreetingMsg, { m: props.m, peer: props.peer, T: props.T });
|
|
2683
|
+
return /* @__PURE__ */ React.createElement(DkMsgBody, { ...props });
|
|
2684
|
+
}
|
|
2685
|
+
function DkMsgBody({ m, peer, T, lang, onTheater, onDelete, onCancel, onRetry, onRetryMsg, onReveal, onOpenFile, onCall, onFormSubmit, onNameCard, onAssistantRemove, onAssistantAdopt, answered, selMode, selected, onToggleSel, busy, isGroup }) {
|
|
2566
2686
|
const mine = m.from === "me";
|
|
2567
2687
|
const nativeMedia = !m.file ? dkNativeMediaFromText(m.text) : null;
|
|
2568
2688
|
const grp = isGroup && m.dir !== "out" && !m.file && !nativeMedia ? dkGroupParse(m.text) : null;
|
|
@@ -6016,15 +6136,40 @@ ${peer.address}`
|
|
|
6016
6136
|
borderBottom: i < peers.length - 1 ? "1px solid var(--line)" : "none"
|
|
6017
6137
|
} }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, minWidth: 0 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: p, size: 26, radius: 6 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: p.alias ? "var(--ui)" : "var(--mono)", fontSize: 13, fontWeight: 600, color: "var(--text)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, p.alias || shortKey(p.userId, 8, 5)), p.agent && /* @__PURE__ */ React.createElement(Icon, { name: "bot", size: 12, color: "var(--faint)", stroke: 2 })), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, dim: true, copy: p.ip }, p.ip), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement(RouteTag, { peer: p })), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: p.wire === "64" ? "var(--warn)" : "var(--dim)" } }, p.wire === "64" ? "old\xB764" : "new\xB7163"), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "flex-end", gap: 6 } }, /* @__PURE__ */ React.createElement(Btn, { icon: "message", size: "sm", title: T.openChat, onClick: () => onOpenChat(p.id) }), /* @__PURE__ */ React.createElement(Btn, { icon: "signal", size: "sm", title: T.ping })))));
|
|
6018
6138
|
}
|
|
6019
|
-
function
|
|
6139
|
+
function ReconnectBtn({ T, ip, onReconnect, label, icon = "refresh" }) {
|
|
6140
|
+
const [busy, setBusy] = React.useState(false);
|
|
6141
|
+
return /* @__PURE__ */ React.createElement(
|
|
6142
|
+
Btn,
|
|
6143
|
+
{
|
|
6144
|
+
size: "sm",
|
|
6145
|
+
icon,
|
|
6146
|
+
disabled: busy,
|
|
6147
|
+
onClick: async () => {
|
|
6148
|
+
if (busy || !onReconnect)
|
|
6149
|
+
return;
|
|
6150
|
+
setBusy(true);
|
|
6151
|
+
try {
|
|
6152
|
+
await onReconnect(ip);
|
|
6153
|
+
} finally {
|
|
6154
|
+
setBusy(false);
|
|
6155
|
+
}
|
|
6156
|
+
}
|
|
6157
|
+
},
|
|
6158
|
+
busy ? T.exitChecking || "checking\u2026" : label || T.exitReconnect || "Reconnect"
|
|
6159
|
+
);
|
|
6160
|
+
}
|
|
6161
|
+
function ExitCard({ T, region, activeExit, onSetExit, onReconnect, featureOn }) {
|
|
6020
6162
|
return /* @__PURE__ */ React.createElement("div", { style: { borderRadius: 11, border: "1px solid var(--line)", overflow: "hidden", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "11px 14px", borderBottom: "1px solid var(--line)", background: "var(--panel-2)" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, letterSpacing: 0.5, padding: "2px 7px", borderRadius: 5, background: "var(--chip)", color: "var(--dim)" } }, region.flag), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--ui)", fontSize: 13.5, fontWeight: 600, color: "var(--text)" } }, region.label), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), (() => {
|
|
6021
6163
|
const reach = region.nodes.filter((n) => n.reachable).length;
|
|
6022
|
-
const
|
|
6023
|
-
|
|
6164
|
+
const failed = region.nodes.filter((n) => n.status === "failed").length;
|
|
6165
|
+
const down = region.nodes.length - reach - failed;
|
|
6166
|
+
return /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: down + failed > 0 ? "var(--warn)" : "var(--faint)" } }, reach, "/", region.nodes.length, " ", T.up, failed > 0 ? ` \xB7 ${failed} ${T.exitFailed || "failed"}` : "", down > 0 ? ` \xB7 ${down} down` : "");
|
|
6024
6167
|
})()), /* @__PURE__ */ React.createElement("div", null, region.nodes.map((n, i) => {
|
|
6025
6168
|
const active = n.ip === activeExit;
|
|
6026
6169
|
const stuck = n.online && !n.reachable;
|
|
6027
|
-
|
|
6170
|
+
const failed = n.status === "failed";
|
|
6171
|
+
const usable = n.reachable && featureOn !== false;
|
|
6172
|
+
return /* @__PURE__ */ React.createElement("div", { key: n.ip, className: "dk-wrap", style: { display: "flex", alignItems: "center", gap: 12, rowGap: 6, 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") : failed ? /* @__PURE__ */ React.createElement(Tag, { tone: "danger", title: "Failed 3 checks in a row \u2014 no longer checked automatically" }, T.exitFailed || "failed") : n.status === "unknown" ? /* @__PURE__ */ React.createElement(Tag, { tone: "off" }, T.exitNotChecked || "not checked") : 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") : n.status === "retrying" ? /* @__PURE__ */ React.createElement(Tag, { tone: "warn", title: "No answer \u2014 will try again shortly" }, T.exitRetrying || "retrying", " \xB7 ", n.fails, "/3") : /* @__PURE__ */ React.createElement(Tag, { tone: "off" }, "offline"), failed ? /* @__PURE__ */ React.createElement(ReconnectBtn, { T, ip: n.ip, onReconnect }) : active ? /* @__PURE__ */ React.createElement(Btn, { tone: "ok", icon: "check", size: "sm", onClick: () => onSetExit(null) }, T.routing) : /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "route", disabled: !usable, onClick: () => usable && onSetExit(n.ip), style: { opacity: usable ? 1 : 0.4 } }, T.routeThru));
|
|
6028
6173
|
})));
|
|
6029
6174
|
}
|
|
6030
6175
|
function LanEnableCard({ backend, onArm, onCancel }) {
|
|
@@ -6128,12 +6273,14 @@ ${peer.address}`
|
|
|
6128
6273
|
"\u53D6\u6D88,\u6062\u590D\u6D88\u606F"
|
|
6129
6274
|
)));
|
|
6130
6275
|
}
|
|
6131
|
-
function NetworkTab({ T, me, peers, exits, activeExit, reqCount, onSetExit, onOpenChat, backend, onArmLan, onCancelLan }) {
|
|
6276
|
+
function NetworkTab({ T, me, peers, exits, activeExit, exitFeature, routeFallback, onReconnectExit, reqCount, onSetExit, onOpenChat, backend, onArmLan, onCancelLan }) {
|
|
6277
|
+
const featureOn = !exitFeature || exitFeature.available !== false;
|
|
6278
|
+
const anyFailed = (exits || []).some((r) => (r.nodes || []).some((n) => n.status === "failed"));
|
|
6132
6279
|
if (backend && backend.switchable && !backend.hasVirtualLan) {
|
|
6133
6280
|
return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { className: "dk-page", 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 })));
|
|
6134
6281
|
}
|
|
6135
6282
|
const lanless = !!backend && backend.hasVirtualLan === false;
|
|
6136
|
-
return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { className: "dk-page", 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 }), !peers.length && /* @__PURE__ */ React.createElement("div", { style: { padding: "14px 16px", borderRadius: 11, border: "1px dashed var(--line)", fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)" } }, T.noPeersYet || "No friends yet \u2014 add one from Chat and it shows up here with its route.")), lanless ? null : /* @__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", { className: "dk-grid1", style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 } }, exits.map((r) => /* @__PURE__ */ React.createElement(ExitCard, { key: r.region, T, region: r, activeExit, onSetExit }))))));
|
|
6283
|
+
return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { className: "dk-page", 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 }), !peers.length && /* @__PURE__ */ React.createElement("div", { style: { padding: "14px 16px", borderRadius: 11, border: "1px dashed var(--line)", fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)" } }, T.noPeersYet || "No friends yet \u2014 add one from Chat and it shows up here with its route.")), lanless ? null : /* @__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 } }, anyFailed && onReconnectExit && /* @__PURE__ */ React.createElement(ReconnectBtn, { T, ip: null, onReconnect: onReconnectExit, label: T.exitReconnectAll || "Reconnect all" }), /* @__PURE__ */ React.createElement(Btn, { icon: "plus", size: "sm" }, T.addExit)) }), !featureOn && /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 4, padding: "11px 14px", borderRadius: 10, background: "var(--panel-2)", border: "1px dashed var(--line)" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 700, color: "var(--text)" } }, T.noExitAvailable || "No exit available \u2014 routing and proxy are off."), anyFailed && /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)" } }, T.noExitHint || "Every exit failed 3 times and is no longer checked automatically. Press Reconnect to try one again.")), routeFallback && routeFallback.from && /* @__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.routeFellBack || "Routing fell back to direct: every exit it used failed. Reconnect one to route through it again.")), 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", { className: "dk-grid1", style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 } }, exits.map((r) => /* @__PURE__ */ React.createElement(ExitCard, { key: r.region, T, region: r, activeExit, onSetExit, onReconnect: onReconnectExit, featureOn }))))));
|
|
6137
6284
|
}
|
|
6138
6285
|
Object.assign(window, { NetworkTab });
|
|
6139
6286
|
var dkAudioCtx = null;
|
|
@@ -6468,6 +6615,9 @@ ${peer.address}`
|
|
|
6468
6615
|
dirSearch: "search name / userid\u2026",
|
|
6469
6616
|
dirLoading: "loading directory\u2026",
|
|
6470
6617
|
noName: "no name given",
|
|
6618
|
+
friendRequestItem: "Friend request",
|
|
6619
|
+
showAll: "show all",
|
|
6620
|
+
showLess: "show less",
|
|
6471
6621
|
dirEmpty: "nobody matches",
|
|
6472
6622
|
dirError: "Could not load the directory:",
|
|
6473
6623
|
bridgeEmpty: "Nobody else is listed on this bridge yet. You are the first.",
|
|
@@ -6542,6 +6692,15 @@ ${peer.address}`
|
|
|
6542
6692
|
routeThru: "route",
|
|
6543
6693
|
egressVia: "all egress routed via",
|
|
6544
6694
|
stopRouting: "stop",
|
|
6695
|
+
exitFailed: "failed",
|
|
6696
|
+
exitReconnect: "Reconnect",
|
|
6697
|
+
exitReconnectAll: "Reconnect all",
|
|
6698
|
+
exitChecking: "checking\u2026",
|
|
6699
|
+
exitNotChecked: "not checked",
|
|
6700
|
+
exitRetrying: "retrying",
|
|
6701
|
+
noExitAvailable: "No exit available \u2014 routing and proxy are off.",
|
|
6702
|
+
noExitHint: "Every exit failed 3 times and is no longer checked automatically. Press Reconnect to try one again.",
|
|
6703
|
+
routeFellBack: "Routing fell back to direct: every exit it used failed. Reconnect one to route through it again.",
|
|
6545
6704
|
identity: "Identity",
|
|
6546
6705
|
userId: "user id",
|
|
6547
6706
|
carrierAddr: "carrier address",
|
|
@@ -6667,6 +6826,9 @@ ${peer.address}`
|
|
|
6667
6826
|
dirSearch: "\u641C\u7D22\u540D\u5B57 / userid\u2026",
|
|
6668
6827
|
dirLoading: "\u6B63\u5728\u52A0\u8F7D\u540D\u5355\u2026",
|
|
6669
6828
|
noName: "\u5BF9\u65B9\u6CA1\u6709\u586B\u540D\u5B57",
|
|
6829
|
+
friendRequestItem: "\u597D\u53CB\u8BF7\u6C42",
|
|
6830
|
+
showAll: "\u5C55\u5F00\u5168\u6587",
|
|
6831
|
+
showLess: "\u6536\u8D77",
|
|
6670
6832
|
dirEmpty: "\u6CA1\u6709\u5339\u914D\u7684\u4EBA",
|
|
6671
6833
|
dirError: "\u540D\u5355\u52A0\u8F7D\u5931\u8D25\uFF1A",
|
|
6672
6834
|
bridgeEmpty: "\u8FD9\u4E2A bridge \u4E0A\u8FD8\u6CA1\u6709\u522B\u4EBA\uFF0C\u4F60\u662F\u7B2C\u4E00\u4E2A\u3002",
|
|
@@ -6741,6 +6903,15 @@ ${peer.address}`
|
|
|
6741
6903
|
routeThru: "\u8DEF\u7531",
|
|
6742
6904
|
egressVia: "\u5168\u90E8\u51FA\u53E3\u6D41\u91CF\u7ECF\u7531",
|
|
6743
6905
|
stopRouting: "\u505C\u6B62",
|
|
6906
|
+
exitFailed: "\u5DF2\u5931\u8D25",
|
|
6907
|
+
exitReconnect: "\u91CD\u65B0\u8FDE\u63A5",
|
|
6908
|
+
exitReconnectAll: "\u5168\u90E8\u91CD\u8FDE",
|
|
6909
|
+
exitChecking: "\u68C0\u6D4B\u4E2D\u2026",
|
|
6910
|
+
exitNotChecked: "\u672A\u68C0\u6D4B",
|
|
6911
|
+
exitRetrying: "\u91CD\u8BD5\u4E2D",
|
|
6912
|
+
noExitAvailable: "\u6CA1\u6709\u53EF\u7528\u7684\u51FA\u53E3 \u2014\u2014 \u8DEF\u7531\u548C\u4EE3\u7406\u5DF2\u5173\u95ED\u3002",
|
|
6913
|
+
noExitHint: "\u6240\u6709\u51FA\u53E3\u90FD\u8FDE\u7EED\u5931\u8D25 3 \u6B21,\u5DF2\u4E0D\u518D\u81EA\u52A8\u68C0\u6D4B\u3002\u70B9\u300C\u91CD\u65B0\u8FDE\u63A5\u300D\u518D\u8BD5\u4E00\u6B21\u3002",
|
|
6914
|
+
routeFellBack: "\u8DEF\u7531\u5DF2\u6539\u56DE\u76F4\u8FDE:\u5B83\u4F7F\u7528\u7684\u51FA\u53E3\u5168\u90E8\u5931\u8D25\u3002\u91CD\u65B0\u8FDE\u63A5\u5176\u4E2D\u4E00\u4E2A\u5373\u53EF\u6062\u590D\u3002",
|
|
6744
6915
|
identity: "\u8EAB\u4EFD",
|
|
6745
6916
|
userId: "\u7528\u6237 ID",
|
|
6746
6917
|
carrierAddr: "Carrier \u5730\u5740",
|
|
@@ -7035,7 +7206,7 @@ ${peer.address}`
|
|
|
7035
7206
|
return hasTab(tabs, want) || DK_PEOPLE_TABS.includes(want) && showPeople ? want : firstTab;
|
|
7036
7207
|
});
|
|
7037
7208
|
const [activeId, setActiveId] = React.useState(null);
|
|
7038
|
-
const data = useDaemonData();
|
|
7209
|
+
const data = useDaemonData({ exits: tab === "network" });
|
|
7039
7210
|
const me = data.me;
|
|
7040
7211
|
const [upd, setUpd] = React.useState(null);
|
|
7041
7212
|
React.useEffect(() => {
|
|
@@ -7398,6 +7569,10 @@ ${peer.address}`
|
|
|
7398
7569
|
const onEdit = (name, description) => dkApi.setProfile(name, description).then(data.refresh);
|
|
7399
7570
|
const onSetExit = () => {
|
|
7400
7571
|
};
|
|
7572
|
+
const onReconnectExit = (ip) => (ip ? dkApi.reconnectExit(ip) : dkApi.reconnectAllExits()).then((r) => {
|
|
7573
|
+
data.refresh();
|
|
7574
|
+
return r;
|
|
7575
|
+
});
|
|
7401
7576
|
const onOpenChat = (id) => {
|
|
7402
7577
|
setTab("chat");
|
|
7403
7578
|
openConversation(id);
|
|
@@ -7477,7 +7652,7 @@ ${peer.address}`
|
|
|
7477
7652
|
fontWeight: 600,
|
|
7478
7653
|
background: tab === n.id ? "var(--accent)" : "transparent",
|
|
7479
7654
|
color: tab === n.id ? "#fff" : "var(--dim)"
|
|
7480
|
-
} }, /* @__PURE__ */ React.createElement(Icon, { name: n.icon, size: 14, stroke: 2, color: tab === n.id ? "#fff" : "var(--dim)" }), n.label))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, phone, isProtocol, protocolFix, onBack: closeConversation, peers, requests, activeId, thread: activeId === ASSISTANT_ID ? assistant.thread : data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), hasEarlier: !!(activeId && activeId !== ASSISTANT_ID && data.earlier[activeId]), onAssistantRemove: activeId === ASSISTANT_ID ? assistant.removeFriends : null, onAssistantAdopt: activeId === ASSISTANT_ID ? assistant.adoptServicePeers : null, onLoadEarlier: () => activeId && data.loadEarlier(activeId), prefillAddr: pendingAddr, onPrefillConsumed: () => setPendingAddr(""), muted: mutedIds, onToggleMute }), 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, me, peers, phone, setTab }), tabs.map((x) => x.kind === "host" && tab === x.id ? /* @__PURE__ */ React.createElement(HostTabPane, { key: x.id, tab: x, props: { T, t, me, peers, phone, setTab }, lang: t.lang }) : null), tab === "profile" && /* @__PURE__ */ React.createElement(
|
|
7655
|
+
} }, /* @__PURE__ */ React.createElement(Icon, { name: n.icon, size: 14, stroke: 2, color: tab === n.id ? "#fff" : "var(--dim)" }), n.label))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, phone, isProtocol, protocolFix, onBack: closeConversation, peers, requests, activeId, thread: activeId === ASSISTANT_ID ? assistant.thread : data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), hasEarlier: !!(activeId && activeId !== ASSISTANT_ID && data.earlier[activeId]), onAssistantRemove: activeId === ASSISTANT_ID ? assistant.removeFriends : null, onAssistantAdopt: activeId === ASSISTANT_ID ? assistant.adoptServicePeers : null, onLoadEarlier: () => activeId && data.loadEarlier(activeId), prefillAddr: pendingAddr, onPrefillConsumed: () => setPendingAddr(""), muted: mutedIds, onToggleMute }), 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, exitFeature: data.exitFeature, routeFallback: data.routeFallback, onReconnectExit, reqCount: requests.length, onSetExit, onOpenChat, backend, onArmLan: armLan, onCancelLan: cancelLan }), tab === "apps" && /* @__PURE__ */ React.createElement(AppsTab, { T, t, me, peers, phone, setTab }), tabs.map((x) => x.kind === "host" && tab === x.id ? /* @__PURE__ */ React.createElement(HostTabPane, { key: x.id, tab: x, props: { T, t, me, peers, phone, setTab }, lang: t.lang }) : null), tab === "profile" && /* @__PURE__ */ React.createElement(
|
|
7481
7656
|
ProfileTab,
|
|
7482
7657
|
{
|
|
7483
7658
|
T,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export declare const EXIT_PROBE_PORT = 8888;
|
|
2
|
+
export declare const EXIT_PROBE_TIMEOUT_MS = 2000;
|
|
3
|
+
/** Consecutive failed probes after which an exit is `failed`. */
|
|
4
|
+
export declare const EXIT_MAX_FAILS = 3;
|
|
5
|
+
/** Wait after the 1st and 2nd failure before the next automatic try. */
|
|
6
|
+
export declare const EXIT_RETRY_BACKOFF_MS: number[];
|
|
7
|
+
/** How long a successful probe is trusted before the next one. */
|
|
8
|
+
export declare const EXIT_OK_CACHE_MS = 60000;
|
|
9
|
+
export type ExitStatus = "unknown" | "ok" | "retrying" | "failed";
|
|
10
|
+
export interface ExitRecord {
|
|
11
|
+
/** Consecutive failed probes (reset by a success). */
|
|
12
|
+
fails: number;
|
|
13
|
+
failed: boolean;
|
|
14
|
+
reachable: boolean;
|
|
15
|
+
ping: number | null;
|
|
16
|
+
lastProbeAt: number;
|
|
17
|
+
/** No automatic probe before this time. */
|
|
18
|
+
nextProbeAt: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ExitView {
|
|
21
|
+
ip: string;
|
|
22
|
+
status: ExitStatus;
|
|
23
|
+
reachable: boolean;
|
|
24
|
+
ping: number | null;
|
|
25
|
+
fails: number;
|
|
26
|
+
checkedAt: number | null;
|
|
27
|
+
}
|
|
28
|
+
/** Routing that was switched to direct because its exits all failed. */
|
|
29
|
+
export interface RouteFallback {
|
|
30
|
+
from: string;
|
|
31
|
+
at: number;
|
|
32
|
+
}
|
|
33
|
+
export type ProbeFn = (ip: string) => Promise<{
|
|
34
|
+
ok: boolean;
|
|
35
|
+
ping: number | null;
|
|
36
|
+
}>;
|
|
37
|
+
/** One TCP handshake to the exit's CONNECT-proxy port. A completed handshake
|
|
38
|
+
* proves the mesh path and the exit process are both live. ECONNREFUSED means
|
|
39
|
+
* the host answered (L3 reachable) but nothing listens — still reachable for
|
|
40
|
+
* triage; a timeout / no route is the real "can't connect". */
|
|
41
|
+
export declare function tcpProbe(ip: string, port?: number, timeoutMs?: number): Promise<{
|
|
42
|
+
ok: boolean;
|
|
43
|
+
ping: number | null;
|
|
44
|
+
}>;
|
|
45
|
+
export declare class ExitProber {
|
|
46
|
+
#private;
|
|
47
|
+
constructor(opts?: {
|
|
48
|
+
path?: string;
|
|
49
|
+
probe?: ProbeFn;
|
|
50
|
+
now?: () => number;
|
|
51
|
+
});
|
|
52
|
+
view(ip: string): ExitView;
|
|
53
|
+
isFailed(ip: string): boolean;
|
|
54
|
+
/** True while at least one of `ips` is not failed — i.e. there is an exit
|
|
55
|
+
* the route/proxy feature could still use. None configured = false. */
|
|
56
|
+
usable(ips: string[]): boolean;
|
|
57
|
+
/** Is an automatic probe of `ip` due now? Failed exits never are. */
|
|
58
|
+
due(ip: string): boolean;
|
|
59
|
+
/** Probe every exit in `ips` that is due (never a failed one, never one
|
|
60
|
+
* already being probed). Resolves when those probes are done. */
|
|
61
|
+
refresh(ips: string[]): Promise<void>;
|
|
62
|
+
/** The Reconnect button: one probe now, whatever the state. Success makes
|
|
63
|
+
* the exit normal again; a failure leaves a failed exit failed. */
|
|
64
|
+
reconnect(ip: string): Promise<ExitView>;
|
|
65
|
+
get fallback(): RouteFallback | null;
|
|
66
|
+
setFallback(f: RouteFallback | null): void;
|
|
67
|
+
}
|
|
68
|
+
/** The exits a routes.yaml `default` sends traffic through: an exit ip names
|
|
69
|
+
* itself, a region name names its routes.yaml exits plus the shipped exits of
|
|
70
|
+
* that region. "direct" / empty → none. */
|
|
71
|
+
export declare function exitsForTarget(target: string | undefined | null, regions: Array<{
|
|
72
|
+
name: string;
|
|
73
|
+
exits?: string[];
|
|
74
|
+
}>, shipped: Array<{
|
|
75
|
+
virtual_ip?: string;
|
|
76
|
+
region?: string;
|
|
77
|
+
}>, knownIps: Iterable<string>): string[];
|
|
78
|
+
/** routes.yaml text with its top-level `default:` set to `value`, keeping
|
|
79
|
+
* every other line (and comment) as written. Returns null when there is no
|
|
80
|
+
* top-level `default:` line to change. */
|
|
81
|
+
export declare function setRoutesDefault(text: string, value: string): string | null;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Exit reachability, with memory — written from the 2026-11-03 MacBook Air
|
|
2
|
+
// battery drain.
|
|
3
|
+
//
|
|
4
|
+
// The network panel used to TCP-probe every exit's :8888 from scratch on every
|
|
5
|
+
// /api/desktop poll (one every 2.5s per open tab), with no record of what had
|
|
6
|
+
// already failed. On the Air every exit was dead, so the daemon sat in a loop
|
|
7
|
+
// of SYNs to seven hosts that never answered — whether or not routing used an
|
|
8
|
+
// exit and whether or not anyone was looking at the panel.
|
|
9
|
+
//
|
|
10
|
+
// The rules this file holds:
|
|
11
|
+
// - a probe result is cached (a good one for OK_CACHE_MS, a bad one until
|
|
12
|
+
// its backoff runs out);
|
|
13
|
+
// - an exit that fails MAX_FAILS probes in a row is `failed` and is never
|
|
14
|
+
// probed automatically again — only `reconnect()` (a button press) does;
|
|
15
|
+
// - the record lives on disk, so a restart does not start the loop over.
|
|
16
|
+
import net from "node:net";
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
18
|
+
export const EXIT_PROBE_PORT = 8888;
|
|
19
|
+
export const EXIT_PROBE_TIMEOUT_MS = 2000;
|
|
20
|
+
/** Consecutive failed probes after which an exit is `failed`. */
|
|
21
|
+
export const EXIT_MAX_FAILS = 3;
|
|
22
|
+
/** Wait after the 1st and 2nd failure before the next automatic try. */
|
|
23
|
+
export const EXIT_RETRY_BACKOFF_MS = [30_000, 120_000];
|
|
24
|
+
/** How long a successful probe is trusted before the next one. */
|
|
25
|
+
export const EXIT_OK_CACHE_MS = 60_000;
|
|
26
|
+
/** One TCP handshake to the exit's CONNECT-proxy port. A completed handshake
|
|
27
|
+
* proves the mesh path and the exit process are both live. ECONNREFUSED means
|
|
28
|
+
* the host answered (L3 reachable) but nothing listens — still reachable for
|
|
29
|
+
* triage; a timeout / no route is the real "can't connect". */
|
|
30
|
+
export function tcpProbe(ip, port = EXIT_PROBE_PORT, timeoutMs = EXIT_PROBE_TIMEOUT_MS) {
|
|
31
|
+
return new Promise((done) => {
|
|
32
|
+
const t0 = Date.now();
|
|
33
|
+
let settled = false;
|
|
34
|
+
let sock;
|
|
35
|
+
const finish = (ok) => {
|
|
36
|
+
if (settled)
|
|
37
|
+
return;
|
|
38
|
+
settled = true;
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
try {
|
|
41
|
+
sock?.destroy();
|
|
42
|
+
}
|
|
43
|
+
catch { /* already gone */ }
|
|
44
|
+
done({ ok, ping: ok ? Date.now() - t0 : null });
|
|
45
|
+
};
|
|
46
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
47
|
+
try {
|
|
48
|
+
sock = net.connect({ host: ip, port });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
finish(false);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
sock.once("connect", () => finish(true));
|
|
55
|
+
sock.once("error", (e) => finish(e.code === "ECONNREFUSED"));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export class ExitProber {
|
|
59
|
+
#path;
|
|
60
|
+
#probe;
|
|
61
|
+
#now;
|
|
62
|
+
#exits = new Map();
|
|
63
|
+
#fallback = null;
|
|
64
|
+
#inFlight = new Map();
|
|
65
|
+
constructor(opts = {}) {
|
|
66
|
+
this.#path = opts.path;
|
|
67
|
+
this.#probe = opts.probe ?? ((ip) => tcpProbe(ip));
|
|
68
|
+
this.#now = opts.now ?? Date.now;
|
|
69
|
+
this.#load();
|
|
70
|
+
}
|
|
71
|
+
#load() {
|
|
72
|
+
if (!this.#path || !existsSync(this.#path))
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
const f = JSON.parse(readFileSync(this.#path, "utf-8"));
|
|
76
|
+
for (const [ip, r] of Object.entries(f?.exits ?? {})) {
|
|
77
|
+
if (!r || typeof r !== "object")
|
|
78
|
+
continue;
|
|
79
|
+
this.#exits.set(ip, {
|
|
80
|
+
fails: Number(r.fails) || 0,
|
|
81
|
+
failed: !!r.failed,
|
|
82
|
+
reachable: !!r.reachable,
|
|
83
|
+
ping: typeof r.ping === "number" ? r.ping : null,
|
|
84
|
+
lastProbeAt: Number(r.lastProbeAt) || 0,
|
|
85
|
+
nextProbeAt: Number(r.nextProbeAt) || 0,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
this.#fallback = f?.fallback && typeof f.fallback.from === "string" ? f.fallback : null;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// A broken state file costs at most three probes per exit, never the app.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
#save() {
|
|
95
|
+
if (!this.#path)
|
|
96
|
+
return;
|
|
97
|
+
const body = { version: 1, exits: Object.fromEntries(this.#exits), fallback: this.#fallback };
|
|
98
|
+
try {
|
|
99
|
+
const tmp = `${this.#path}.tmp`;
|
|
100
|
+
writeFileSync(tmp, JSON.stringify(body, null, 2) + "\n");
|
|
101
|
+
renameSync(tmp, this.#path);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Not persisting only means a restart re-learns; keep serving.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
#record(ip) {
|
|
108
|
+
let r = this.#exits.get(ip);
|
|
109
|
+
if (!r) {
|
|
110
|
+
r = { fails: 0, failed: false, reachable: false, ping: null, lastProbeAt: 0, nextProbeAt: 0 };
|
|
111
|
+
this.#exits.set(ip, r);
|
|
112
|
+
}
|
|
113
|
+
return r;
|
|
114
|
+
}
|
|
115
|
+
view(ip) {
|
|
116
|
+
const r = this.#exits.get(ip);
|
|
117
|
+
if (!r || !r.lastProbeAt) {
|
|
118
|
+
return { ip, status: r?.failed ? "failed" : "unknown", reachable: false, ping: null, fails: r?.fails ?? 0, checkedAt: null };
|
|
119
|
+
}
|
|
120
|
+
const status = r.failed ? "failed" : r.reachable ? "ok" : r.fails > 0 ? "retrying" : "unknown";
|
|
121
|
+
return { ip, status, reachable: !r.failed && r.reachable, ping: r.failed ? null : r.ping, fails: r.fails, checkedAt: r.lastProbeAt };
|
|
122
|
+
}
|
|
123
|
+
isFailed(ip) {
|
|
124
|
+
return !!this.#exits.get(ip)?.failed;
|
|
125
|
+
}
|
|
126
|
+
/** True while at least one of `ips` is not failed — i.e. there is an exit
|
|
127
|
+
* the route/proxy feature could still use. None configured = false. */
|
|
128
|
+
usable(ips) {
|
|
129
|
+
return ips.some((ip) => !this.isFailed(ip));
|
|
130
|
+
}
|
|
131
|
+
/** Is an automatic probe of `ip` due now? Failed exits never are. */
|
|
132
|
+
due(ip) {
|
|
133
|
+
const r = this.#exits.get(ip);
|
|
134
|
+
if (!r)
|
|
135
|
+
return true;
|
|
136
|
+
if (r.failed)
|
|
137
|
+
return false;
|
|
138
|
+
return this.#now() >= r.nextProbeAt;
|
|
139
|
+
}
|
|
140
|
+
#apply(ip, ok, ping, manual) {
|
|
141
|
+
const r = this.#record(ip);
|
|
142
|
+
const now = this.#now();
|
|
143
|
+
r.lastProbeAt = now;
|
|
144
|
+
if (ok) {
|
|
145
|
+
r.fails = 0;
|
|
146
|
+
r.failed = false;
|
|
147
|
+
r.reachable = true;
|
|
148
|
+
r.ping = ping;
|
|
149
|
+
r.nextProbeAt = now + EXIT_OK_CACHE_MS;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
r.reachable = false;
|
|
153
|
+
r.ping = null;
|
|
154
|
+
if (manual && r.failed) {
|
|
155
|
+
// A failed Reconnect leaves it failed; it does not start a new cycle.
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
r.fails += 1;
|
|
159
|
+
if (r.fails >= EXIT_MAX_FAILS)
|
|
160
|
+
r.failed = true;
|
|
161
|
+
}
|
|
162
|
+
r.nextProbeAt = r.failed ? Number.MAX_SAFE_INTEGER : now + EXIT_RETRY_BACKOFF_MS[Math.min(r.fails - 1, EXIT_RETRY_BACKOFF_MS.length - 1)];
|
|
163
|
+
}
|
|
164
|
+
this.#save();
|
|
165
|
+
}
|
|
166
|
+
#run(ip, manual) {
|
|
167
|
+
const running = this.#inFlight.get(ip);
|
|
168
|
+
if (running)
|
|
169
|
+
return running;
|
|
170
|
+
const p = this.#probe(ip)
|
|
171
|
+
.catch(() => ({ ok: false, ping: null }))
|
|
172
|
+
.then(({ ok, ping }) => this.#apply(ip, ok, ping, manual))
|
|
173
|
+
.finally(() => this.#inFlight.delete(ip));
|
|
174
|
+
this.#inFlight.set(ip, p);
|
|
175
|
+
return p;
|
|
176
|
+
}
|
|
177
|
+
/** Probe every exit in `ips` that is due (never a failed one, never one
|
|
178
|
+
* already being probed). Resolves when those probes are done. */
|
|
179
|
+
async refresh(ips) {
|
|
180
|
+
const due = [...new Set(ips)].filter((ip) => !this.#inFlight.has(ip) && this.due(ip));
|
|
181
|
+
await Promise.all(due.map((ip) => this.#run(ip, false)));
|
|
182
|
+
}
|
|
183
|
+
/** The Reconnect button: one probe now, whatever the state. Success makes
|
|
184
|
+
* the exit normal again; a failure leaves a failed exit failed. */
|
|
185
|
+
async reconnect(ip) {
|
|
186
|
+
const running = this.#inFlight.get(ip);
|
|
187
|
+
if (running)
|
|
188
|
+
await running;
|
|
189
|
+
if (!this.view(ip).reachable)
|
|
190
|
+
await this.#run(ip, true);
|
|
191
|
+
return this.view(ip);
|
|
192
|
+
}
|
|
193
|
+
get fallback() {
|
|
194
|
+
return this.#fallback;
|
|
195
|
+
}
|
|
196
|
+
setFallback(f) {
|
|
197
|
+
this.#fallback = f;
|
|
198
|
+
this.#save();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** The exits a routes.yaml `default` sends traffic through: an exit ip names
|
|
202
|
+
* itself, a region name names its routes.yaml exits plus the shipped exits of
|
|
203
|
+
* that region. "direct" / empty → none. */
|
|
204
|
+
export function exitsForTarget(target, regions, shipped, knownIps) {
|
|
205
|
+
if (!target || target === "direct")
|
|
206
|
+
return [];
|
|
207
|
+
const out = new Set();
|
|
208
|
+
for (const ip of knownIps)
|
|
209
|
+
if (ip === target)
|
|
210
|
+
out.add(ip);
|
|
211
|
+
for (const r of regions)
|
|
212
|
+
if (r.name === target)
|
|
213
|
+
for (const ip of r.exits ?? [])
|
|
214
|
+
out.add(ip);
|
|
215
|
+
for (const e of shipped)
|
|
216
|
+
if (e.region === target && e.virtual_ip)
|
|
217
|
+
out.add(e.virtual_ip);
|
|
218
|
+
return [...out];
|
|
219
|
+
}
|
|
220
|
+
/** routes.yaml text with its top-level `default:` set to `value`, keeping
|
|
221
|
+
* every other line (and comment) as written. Returns null when there is no
|
|
222
|
+
* top-level `default:` line to change. */
|
|
223
|
+
export function setRoutesDefault(text, value) {
|
|
224
|
+
const re = /^default:[^\n]*$/m;
|
|
225
|
+
if (!re.test(text))
|
|
226
|
+
return null;
|
|
227
|
+
return text.replace(re, `default: ${value}`);
|
|
228
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export declare const GREETING_KIND = "friend-request";
|
|
2
|
+
/** One greeting as stored, per friend. */
|
|
3
|
+
export interface StoredGreeting {
|
|
4
|
+
text: string;
|
|
5
|
+
/** When the request arrived (ms). The item is dated here, not at accept. */
|
|
6
|
+
ts: number;
|
|
7
|
+
}
|
|
8
|
+
/** The shape merged into a /api/chat-history page — a raw store record with
|
|
9
|
+
* `kind` so beagle-ui draws the distinct bubble. */
|
|
10
|
+
export interface GreetingItem {
|
|
11
|
+
id: string;
|
|
12
|
+
dir: "in";
|
|
13
|
+
text: string;
|
|
14
|
+
ts: number;
|
|
15
|
+
kind: typeof GREETING_KIND;
|
|
16
|
+
}
|
|
17
|
+
/** A request's arrival time in ms. The embedded host stores a number, the lan
|
|
18
|
+
* daemon an ISO string. Undefined when neither parses. */
|
|
19
|
+
export declare function arrivalMs(v: unknown): number | undefined;
|
|
20
|
+
export declare class GreetingStore {
|
|
21
|
+
#private;
|
|
22
|
+
constructor(path: string);
|
|
23
|
+
/** Keep the greeting of an accepted request. Skips an empty / whitespace
|
|
24
|
+
* greeting, and a repeat request carrying the same text (one item per
|
|
25
|
+
* request, not one per click). Returns whether an item was added. */
|
|
26
|
+
record(peer: string, hello: unknown, arrivedAt: unknown, now?: number): boolean;
|
|
27
|
+
/** The items for one conversation, oldest first. */
|
|
28
|
+
items(peer: string): GreetingItem[];
|
|
29
|
+
/** Forget a friend's greetings (friend removed together with the chat). */
|
|
30
|
+
remove(peer: string): void;
|
|
31
|
+
}
|
|
32
|
+
/** Merge greeting items into one /api/chat-history page (oldest first).
|
|
33
|
+
*
|
|
34
|
+
* A greeting belongs on a page when it is older than `before` (if given)
|
|
35
|
+
* and, when the page is full (`limit` records — there are older ones the
|
|
36
|
+
* page left out), no older than the page's oldest record; otherwise it is
|
|
37
|
+
* shown when the user loads earlier and reaches it. Already-present ids are
|
|
38
|
+
* not added twice. Returns a new array. */
|
|
39
|
+
export declare function mergeGreetings<M extends {
|
|
40
|
+
ts?: number;
|
|
41
|
+
id?: string;
|
|
42
|
+
}>(page: readonly M[], greetings: readonly GreetingItem[], opts?: {
|
|
43
|
+
before?: number;
|
|
44
|
+
limit?: number;
|
|
45
|
+
}): Array<M | GreetingItem>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The greeting a requester typed into a friend request, kept after accept.
|
|
2
|
+
//
|
|
3
|
+
// Before this, the greeting lived only in the pending list: readable while the
|
|
4
|
+
// request was open (and drawn as "via <greeting>"), gone the moment it was
|
|
5
|
+
// accepted. On accept beagle now writes it into that friend's conversation as
|
|
6
|
+
// an incoming "friend request" item, dated at the request time.
|
|
7
|
+
//
|
|
8
|
+
// Local only — nothing is sent. Stored in beagle's own file, not in
|
|
9
|
+
// messages.json: with the daemon backend that file belongs to the agentnet
|
|
10
|
+
// daemon, which holds it in memory and would overwrite anything we wrote.
|
|
11
|
+
// /api/chat-history merges these items into the page it returns, so the
|
|
12
|
+
// daemon's unread counts and last-message preview never see them (spec:
|
|
13
|
+
// not unread, no notification, not the "last message" over newer ones).
|
|
14
|
+
// See decent-hq docs/tasks/TASK-2026-11-04-friend-request-greeting.md.
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname } from "node:path";
|
|
17
|
+
export const GREETING_KIND = "friend-request";
|
|
18
|
+
/** A request's arrival time in ms. The embedded host stores a number, the lan
|
|
19
|
+
* daemon an ISO string. Undefined when neither parses. */
|
|
20
|
+
export function arrivalMs(v) {
|
|
21
|
+
if (typeof v === "number" && Number.isFinite(v) && v > 0)
|
|
22
|
+
return v;
|
|
23
|
+
if (typeof v === "string" && v) {
|
|
24
|
+
const n = Date.parse(v);
|
|
25
|
+
if (Number.isFinite(n) && n > 0)
|
|
26
|
+
return n;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
export class GreetingStore {
|
|
31
|
+
#path;
|
|
32
|
+
#byPeer = {};
|
|
33
|
+
constructor(path) {
|
|
34
|
+
this.#path = path;
|
|
35
|
+
try {
|
|
36
|
+
if (existsSync(path)) {
|
|
37
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
38
|
+
for (const [peer, list] of Object.entries(raw?.byPeer ?? {})) {
|
|
39
|
+
if (!Array.isArray(list))
|
|
40
|
+
continue;
|
|
41
|
+
const ok = list.filter((g) => !!g && typeof g.text === "string" && typeof g.ts === "number");
|
|
42
|
+
if (ok.length)
|
|
43
|
+
this.#byPeer[peer] = ok;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// A corrupt file loses the greetings, never the chat: start empty.
|
|
49
|
+
this.#byPeer = {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Keep the greeting of an accepted request. Skips an empty / whitespace
|
|
53
|
+
* greeting, and a repeat request carrying the same text (one item per
|
|
54
|
+
* request, not one per click). Returns whether an item was added. */
|
|
55
|
+
record(peer, hello, arrivedAt, now = Date.now()) {
|
|
56
|
+
if (!peer || typeof hello !== "string")
|
|
57
|
+
return false;
|
|
58
|
+
const text = hello.trim();
|
|
59
|
+
if (!text)
|
|
60
|
+
return false;
|
|
61
|
+
const list = this.#byPeer[peer] ?? [];
|
|
62
|
+
if (list.some((g) => g.text.trim() === text))
|
|
63
|
+
return false;
|
|
64
|
+
list.push({ text, ts: arrivalMs(arrivedAt) ?? now });
|
|
65
|
+
list.sort((a, b) => a.ts - b.ts);
|
|
66
|
+
this.#byPeer[peer] = list;
|
|
67
|
+
this.#save();
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
/** The items for one conversation, oldest first. */
|
|
71
|
+
items(peer) {
|
|
72
|
+
return (this.#byPeer[peer] ?? []).map((g) => ({
|
|
73
|
+
id: `${GREETING_KIND}:${g.ts}`,
|
|
74
|
+
dir: "in",
|
|
75
|
+
text: g.text,
|
|
76
|
+
ts: g.ts,
|
|
77
|
+
kind: GREETING_KIND,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
/** Forget a friend's greetings (friend removed together with the chat). */
|
|
81
|
+
remove(peer) {
|
|
82
|
+
if (!this.#byPeer[peer])
|
|
83
|
+
return;
|
|
84
|
+
delete this.#byPeer[peer];
|
|
85
|
+
this.#save();
|
|
86
|
+
}
|
|
87
|
+
#save() {
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(dirname(this.#path), { recursive: true });
|
|
90
|
+
const tmp = `${this.#path}.${process.pid}.tmp`;
|
|
91
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, byPeer: this.#byPeer }, null, 2), "utf-8");
|
|
92
|
+
renameSync(tmp, this.#path);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Not fatal: the greeting stays for this run and the chat is unaffected.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Merge greeting items into one /api/chat-history page (oldest first).
|
|
100
|
+
*
|
|
101
|
+
* A greeting belongs on a page when it is older than `before` (if given)
|
|
102
|
+
* and, when the page is full (`limit` records — there are older ones the
|
|
103
|
+
* page left out), no older than the page's oldest record; otherwise it is
|
|
104
|
+
* shown when the user loads earlier and reaches it. Already-present ids are
|
|
105
|
+
* not added twice. Returns a new array. */
|
|
106
|
+
export function mergeGreetings(page, greetings, opts = {}) {
|
|
107
|
+
if (!greetings.length)
|
|
108
|
+
return page.slice();
|
|
109
|
+
const full = opts.limit !== undefined && page.length >= opts.limit;
|
|
110
|
+
const oldest = page.length ? Math.min(...page.map((m) => (typeof m.ts === "number" ? m.ts : Infinity))) : Infinity;
|
|
111
|
+
const have = new Set(page.map((m) => m.id).filter(Boolean));
|
|
112
|
+
const add = greetings.filter((g) => !have.has(g.id) &&
|
|
113
|
+
(opts.before === undefined || g.ts < opts.before) &&
|
|
114
|
+
(!full || g.ts >= oldest));
|
|
115
|
+
if (!add.length)
|
|
116
|
+
return page.slice();
|
|
117
|
+
// Stable: a greeting sorts before a message with the same timestamp.
|
|
118
|
+
return [...add, ...page].sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0));
|
|
119
|
+
}
|
|
@@ -26,3 +26,6 @@ export declare function refreshPendingAvatars<E extends {
|
|
|
26
26
|
* the same convention as `peers[]` — and the card then falls back to the
|
|
27
27
|
* beagles.eth lookup and a generated picture. */
|
|
28
28
|
export declare function desktopRequestRow(p: Record<string, unknown>, i: number, fmtTime: (ts?: number) => string): Record<string, unknown>;
|
|
29
|
+
/** The pending entry the UI's Accept refers to: the card's id is the userid,
|
|
30
|
+
* or the address when the request carried no userid. */
|
|
31
|
+
export declare function findPending(pending: ReadonlyArray<Record<string, unknown>>, id: unknown): Record<string, unknown> | undefined;
|
package/dist/pending-requests.js
CHANGED
|
@@ -60,10 +60,22 @@ export function desktopRequestRow(p, i, fmtTime) {
|
|
|
60
60
|
// was nothing to decide on.
|
|
61
61
|
name: p.name || "",
|
|
62
62
|
descr: p.descr || "",
|
|
63
|
-
|
|
63
|
+
// The greeting the requester typed. It used to ride in `via`, so the card
|
|
64
|
+
// drew it as "via <greeting>" — mislabeled, and gone after accept.
|
|
65
|
+
// beagle-ui >= 0.2.36 reads `hello`; for an older UI, `via` stays the
|
|
66
|
+
// transport word it always fell back to.
|
|
67
|
+
hello: typeof p.hello === "string" ? p.hello : "",
|
|
68
|
+
via: p.via || "lan",
|
|
64
69
|
time: fmtTime(p.arrivedAt),
|
|
65
70
|
// The requester's avatar, drawn before accept: URL first, then punk.
|
|
66
71
|
punkId: a.punkId ?? null,
|
|
67
72
|
avatarUrl: a.avatarUrl ?? null,
|
|
68
73
|
};
|
|
69
74
|
}
|
|
75
|
+
/** The pending entry the UI's Accept refers to: the card's id is the userid,
|
|
76
|
+
* or the address when the request carried no userid. */
|
|
77
|
+
export function findPending(pending, id) {
|
|
78
|
+
if (typeof id !== "string" || !id)
|
|
79
|
+
return undefined;
|
|
80
|
+
return pending.find((p) => p.userid === id || p.address === id || p.pubkey === id);
|
|
81
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type IpcRequest, type IpcResponse } from "./ipc.js";
|
|
2
|
+
import { type ProbeFn } from "./exit-probe.js";
|
|
2
3
|
/** How this copy of beagle got onto disk — decides what "update" can do.
|
|
3
4
|
* npx cache paths contain _npx AND node_modules, so test _npx first. */
|
|
4
5
|
export declare function installMode(): "global" | "npx" | "dev" | "unknown";
|
|
@@ -32,6 +33,16 @@ export interface BeagleServerOptions {
|
|
|
32
33
|
* service peers on top of the list shipped in the app. Written only by the
|
|
33
34
|
* Assistant's `/nodes refresh`, and only after the user ticks the rows. */
|
|
34
35
|
servicePeersPath?: string;
|
|
36
|
+
/** <configDir>/friend-greetings.json — the greetings of accepted friend
|
|
37
|
+
* requests, shown as the first item of that chat. Beagle's own file (see
|
|
38
|
+
* friend-greetings.ts). Undefined = greetings are not kept. */
|
|
39
|
+
greetingsPath?: string;
|
|
40
|
+
/** Where the exit probe state (failure counts, failed exits) is kept.
|
|
41
|
+
* Default: beagle-exit-state.json next to routes.yaml. */
|
|
42
|
+
exitStatePath?: string;
|
|
43
|
+
/** How an exit is probed. Default: a TCP handshake to its :8888. Tests
|
|
44
|
+
* inject a counter here. */
|
|
45
|
+
exitProbe?: ProbeFn;
|
|
35
46
|
/** The swappable backend, so the UI can show which one is live and drive the
|
|
36
47
|
* LAN handoff. Optional: a caller that pins one backend just omits it and
|
|
37
48
|
* the network panel falls back to "no virtual LAN here". */
|
package/dist/server.js
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
// empty because the daemon accepts immediately.
|
|
14
14
|
//
|
|
15
15
|
import http from "node:http";
|
|
16
|
-
import net from "node:net";
|
|
17
16
|
import { existsSync, readFileSync, writeFileSync, statSync, createReadStream, realpathSync } from "node:fs";
|
|
18
17
|
import { fileURLToPath } from "node:url";
|
|
19
18
|
import { dirname, join } from "node:path";
|
|
@@ -21,12 +20,14 @@ import { INSTALL_COMMAND as LAN_INSTALL_COMMAND } from "./lan-handoff.js";
|
|
|
21
20
|
import yaml from "js-yaml";
|
|
22
21
|
import { probeDaemon } from "./ipc.js";
|
|
23
22
|
import { DEFAULT_EXITS } from "./exits.js";
|
|
23
|
+
import { ExitProber, exitsForTarget, setRoutesDefault } from "./exit-probe.js";
|
|
24
24
|
import * as peerAddr from "@decentnetwork/peer";
|
|
25
25
|
import { bridgeRoster } from "./bridge-roster.js";
|
|
26
26
|
import { isSupervised } from "./relaunch.js";
|
|
27
27
|
import { historyStats } from "./assistant-stats.js";
|
|
28
28
|
import { readFriendStore, pickPunk } from "./friend-store.js";
|
|
29
|
-
import { desktopRequestRow } from "./pending-requests.js";
|
|
29
|
+
import { desktopRequestRow, findPending } from "./pending-requests.js";
|
|
30
|
+
import { GreetingStore, mergeGreetings } from "./friend-greetings.js";
|
|
30
31
|
import { createDiscoverFetch } from "./discover-cache.js";
|
|
31
32
|
import { applyWalletBind } from "@decentnetwork/beagle-ui/wallet-proof";
|
|
32
33
|
import { settleUpload } from "./upload-settle.js";
|
|
@@ -34,11 +35,22 @@ import { globalPrefixOf } from "./global-prefix.js";
|
|
|
34
35
|
// Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
|
|
35
36
|
// scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
|
|
36
37
|
const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
/** Set routes.yaml's top-level `default:` to `value`, keeping the rest of the
|
|
39
|
+
* file as written. False when there is no file or no such line to change. */
|
|
40
|
+
function writeRoutesDefault(path, value) {
|
|
41
|
+
try {
|
|
42
|
+
if (!existsSync(path))
|
|
43
|
+
return false;
|
|
44
|
+
const next = setRoutesDefault(readFileSync(path, "utf-8"), value);
|
|
45
|
+
if (next === null)
|
|
46
|
+
return false;
|
|
47
|
+
writeFileSync(path, next, "utf-8");
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
42
54
|
/** Server-side fetch for the discovery directories (leaderboard / ENS names).
|
|
43
55
|
* Successes are cached, FAILURES ARE TOO, and concurrent callers share one
|
|
44
56
|
* request — see discover-cache.ts for the outage this comes from. */
|
|
@@ -947,6 +959,13 @@ export function startBeagleServer(opts) {
|
|
|
947
959
|
const host = opts.listenHost ?? "127.0.0.1";
|
|
948
960
|
const port = opts.listenPort ?? 8765;
|
|
949
961
|
const log = opts.log ?? ((s) => console.log(s));
|
|
962
|
+
const greetings = opts.greetingsPath ? new GreetingStore(opts.greetingsPath) : null;
|
|
963
|
+
// Exit reachability with memory (see exit-probe.ts). One per server, state
|
|
964
|
+
// kept next to routes.yaml so a restart does not re-probe dead exits.
|
|
965
|
+
const exitProber = new ExitProber({
|
|
966
|
+
path: opts.exitStatePath ?? join(dirname(opts.routesPath), "beagle-exit-state.json"),
|
|
967
|
+
probe: opts.exitProbe,
|
|
968
|
+
});
|
|
950
969
|
const sendJson = (res, code, body) => {
|
|
951
970
|
res.writeHead(code, { "content-type": "application/json" });
|
|
952
971
|
res.end(JSON.stringify(body));
|
|
@@ -2238,31 +2257,46 @@ export function startBeagleServer(opts) {
|
|
|
2238
2257
|
// REACHABILITY (data plane): presence "online" only means the peer is
|
|
2239
2258
|
// announcing on the DHT — it does NOT mean IP packets get through (a
|
|
2240
2259
|
// desynced/NAT-blocked exit shows "online" while `ping 10.86.x` times out,
|
|
2241
|
-
// e.g. GFAX).
|
|
2242
|
-
//
|
|
2243
|
-
//
|
|
2260
|
+
// e.g. GFAX). The proof is a TCP handshake to the exit's CONNECT-proxy
|
|
2261
|
+
// port — but see exit-probe.ts: probes are cached and backed off, a dead
|
|
2262
|
+
// exit goes `failed` after three tries and is never probed again on its
|
|
2263
|
+
// own, and nothing is probed at all unless the network panel is open
|
|
2264
|
+
// (?exits=1) or routing actually goes through an exit.
|
|
2244
2265
|
const allNodes = [...byRegion.values()].flat();
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
+
const allIps = allNodes.map((n) => n.ip);
|
|
2267
|
+
const qs = new URL(req.url || "", "http://localhost").searchParams;
|
|
2268
|
+
const panelOpen = qs.get("exits") === "1";
|
|
2269
|
+
const routeTarget = routes.default && routes.default !== "direct" ? routes.default : null;
|
|
2270
|
+
if (panelOpen || routeTarget)
|
|
2271
|
+
await exitProber.refresh(allIps);
|
|
2272
|
+
const views = new Map(allIps.map((ip) => [ip, exitProber.view(ip)]));
|
|
2273
|
+
for (const n of allNodes) {
|
|
2274
|
+
const v = views.get(n.ip);
|
|
2275
|
+
Object.assign(n, { reachable: v.reachable, ping: v.ping, status: v.status, fails: v.fails, checkedAt: v.checkedAt });
|
|
2276
|
+
}
|
|
2277
|
+
// routes.default on an exit whose every node failed would black-hole
|
|
2278
|
+
// the traffic it sends there: switch the default to direct (and say so)
|
|
2279
|
+
// until a Reconnect brings one of them back.
|
|
2280
|
+
let activeExit = routeTarget;
|
|
2281
|
+
if (routeTarget) {
|
|
2282
|
+
const targetIps = exitsForTarget(routeTarget, routes.regions ?? [], DEFAULT_EXITS, allIps);
|
|
2283
|
+
if (targetIps.length && targetIps.every((ip) => exitProber.isFailed(ip))) {
|
|
2284
|
+
if (writeRoutesDefault(opts.routesPath, "direct")) {
|
|
2285
|
+
exitProber.setFallback({ from: routeTarget, at: Date.now() });
|
|
2286
|
+
log(`[exits] every exit of routes.default "${routeTarget}" failed — default set to direct`);
|
|
2287
|
+
activeExit = null;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
// The user (or a Reconnect) put routing back on an exit: the old
|
|
2292
|
+
// "fell back to direct" notice no longer describes anything.
|
|
2293
|
+
if (activeExit && exitProber.fallback)
|
|
2294
|
+
exitProber.setFallback(null);
|
|
2295
|
+
const usable = exitProber.usable(allIps);
|
|
2296
|
+
const exitFeature = {
|
|
2297
|
+
available: usable,
|
|
2298
|
+
reason: usable ? null : allIps.length ? "No exit available — every exit failed. Reconnect one to use routing." : "No exit available",
|
|
2299
|
+
};
|
|
2266
2300
|
const exits = [...byRegion.entries()].map(([region, nodes]) => ({
|
|
2267
2301
|
region,
|
|
2268
2302
|
flag: regionMeta[region]?.flag ?? region.slice(0, 2).toUpperCase(),
|
|
@@ -2270,9 +2304,9 @@ export function startBeagleServer(opts) {
|
|
|
2270
2304
|
nodes,
|
|
2271
2305
|
reachableCount: nodes.filter((n) => n.reachable).length,
|
|
2272
2306
|
unreachableCount: nodes.filter((n) => !n.reachable).length,
|
|
2307
|
+
failedCount: nodes.filter((n) => n.status === "failed").length,
|
|
2273
2308
|
}));
|
|
2274
|
-
|
|
2275
|
-
sendJson(res, 200, { me, peers, requests, exits, activeExit });
|
|
2309
|
+
sendJson(res, 200, { me, peers, requests, exits, activeExit, exitFeature, routeFallback: exitProber.fallback });
|
|
2276
2310
|
return;
|
|
2277
2311
|
}
|
|
2278
2312
|
// The locally adopted service peers: the app ships a list, and this is
|
|
@@ -2436,7 +2470,18 @@ export function startBeagleServer(opts) {
|
|
|
2436
2470
|
const bef = Number(q.get("before"));
|
|
2437
2471
|
const before = Number.isFinite(bef) && bef > 0 ? bef : undefined;
|
|
2438
2472
|
const r = await opts.call({ op: "chat-history", userid: peer, peer, before, limit });
|
|
2439
|
-
|
|
2473
|
+
const data = (r.ok ? (r.data ?? { chats: {} }) : { chats: {} });
|
|
2474
|
+
// The greeting of the accepted friend request, as the chat's first
|
|
2475
|
+
// item (friend-greetings.ts). Merged here, never written to the
|
|
2476
|
+
// daemon's store, so it is not unread and not the roster preview.
|
|
2477
|
+
if (greetings && peer) {
|
|
2478
|
+
const items = greetings.items(peer);
|
|
2479
|
+
if (items.length) {
|
|
2480
|
+
data.chats = data.chats ?? {};
|
|
2481
|
+
data.chats[peer] = mergeGreetings(data.chats[peer] ?? [], items, { before, limit });
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
sendJson(res, 200, data);
|
|
2440
2485
|
return;
|
|
2441
2486
|
}
|
|
2442
2487
|
// Brief 30: submit a rendered form. The values are the user's own input;
|
|
@@ -2556,6 +2601,8 @@ export function startBeagleServer(opts) {
|
|
|
2556
2601
|
if (req.method === "POST" && url === "/api/friend-remove") {
|
|
2557
2602
|
const { userid } = await readBody(req);
|
|
2558
2603
|
const r = await opts.call({ op: "friend-remove", userid });
|
|
2604
|
+
if (r.ok)
|
|
2605
|
+
greetings?.remove(userid);
|
|
2559
2606
|
sendJson(res, r.ok ? 200 : 400, r);
|
|
2560
2607
|
return;
|
|
2561
2608
|
}
|
|
@@ -2598,6 +2645,42 @@ export function startBeagleServer(opts) {
|
|
|
2598
2645
|
}
|
|
2599
2646
|
return;
|
|
2600
2647
|
}
|
|
2648
|
+
// The network panel's Reconnect: probe a failed exit once, now. Only the
|
|
2649
|
+
// exits this node knows about — the page does not get to make the daemon
|
|
2650
|
+
// dial arbitrary hosts. {all:true} = every failed exit.
|
|
2651
|
+
if (req.method === "POST" && url === "/api/exits/reconnect") {
|
|
2652
|
+
const body = (await readBody(req));
|
|
2653
|
+
let routes = { regions: [], default: "direct" };
|
|
2654
|
+
if (existsSync(opts.routesPath)) {
|
|
2655
|
+
try {
|
|
2656
|
+
routes = yaml.load(readFileSync(opts.routesPath, "utf-8")) ?? routes;
|
|
2657
|
+
}
|
|
2658
|
+
catch { /* keep direct */ }
|
|
2659
|
+
}
|
|
2660
|
+
const known = new Set(DEFAULT_EXITS.flatMap((e) => (e.virtual_ip ? [e.virtual_ip] : [])));
|
|
2661
|
+
for (const r of routes.regions ?? [])
|
|
2662
|
+
for (const ip of r.exits ?? [])
|
|
2663
|
+
known.add(ip);
|
|
2664
|
+
const targets = body.all
|
|
2665
|
+
? [...known].filter((ip) => exitProber.isFailed(ip))
|
|
2666
|
+
: typeof body.ip === "string" && known.has(body.ip) ? [body.ip] : [];
|
|
2667
|
+
if (!body.all && !targets.length)
|
|
2668
|
+
return sendJson(res, 400, { ok: false, error: "unknown exit" });
|
|
2669
|
+
const results = await Promise.all(targets.map((ip) => exitProber.reconnect(ip)));
|
|
2670
|
+
// Routing that fell back to direct goes back once one of its exits works.
|
|
2671
|
+
const fb = exitProber.fallback;
|
|
2672
|
+
let restored = null;
|
|
2673
|
+
if (fb) {
|
|
2674
|
+
const fbIps = exitsForTarget(fb.from, routes.regions ?? [], DEFAULT_EXITS, known);
|
|
2675
|
+
if (fbIps.some((ip) => exitProber.view(ip).reachable)) {
|
|
2676
|
+
if ((routes.default ?? "direct") === "direct" && writeRoutesDefault(opts.routesPath, fb.from))
|
|
2677
|
+
restored = fb.from;
|
|
2678
|
+
exitProber.setFallback(null);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
sendJson(res, 200, { ok: true, exits: results, restored, available: exitProber.usable([...known]) });
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2601
2684
|
if (req.method === "GET" && url === "/api/routes") {
|
|
2602
2685
|
let routes = { regions: [], default: "direct" };
|
|
2603
2686
|
if (existsSync(opts.routesPath)) {
|
|
@@ -2637,7 +2720,21 @@ export function startBeagleServer(opts) {
|
|
|
2637
2720
|
}
|
|
2638
2721
|
if (req.method === "POST" && url === "/api/accept") {
|
|
2639
2722
|
const { userid } = await readBody(req);
|
|
2723
|
+
// Read the request BEFORE accepting: accept drops it from pending, and
|
|
2724
|
+
// with it the greeting the requester typed.
|
|
2725
|
+
let entry;
|
|
2726
|
+
if (greetings) {
|
|
2727
|
+
try {
|
|
2728
|
+
const pend = await opts.call({ op: "friends-pending" });
|
|
2729
|
+
if (pend.ok)
|
|
2730
|
+
entry = findPending(pend.data?.pending ?? [], userid);
|
|
2731
|
+
}
|
|
2732
|
+
catch { /* no greeting then; the accept still goes through */ }
|
|
2733
|
+
}
|
|
2640
2734
|
const r = await opts.call({ op: "friends-accept", userid });
|
|
2735
|
+
if (r.ok && greetings && entry) {
|
|
2736
|
+
greetings.record(entry.userid || userid, entry.hello, entry.arrivedAt);
|
|
2737
|
+
}
|
|
2641
2738
|
sendJson(res, r.ok ? 200 : 400, r);
|
|
2642
2739
|
return;
|
|
2643
2740
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/beagle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.128",
|
|
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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"start": "node dist/cli.js"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@decentnetwork/beagle-ui": "^0.2.
|
|
29
|
+
"@decentnetwork/beagle-ui": "^0.2.37",
|
|
30
30
|
"@decentnetwork/chat-components": "^0.1.3",
|
|
31
31
|
"@decentnetwork/lan": "^0.1.313",
|
|
32
32
|
"@decentnetwork/peer": "^0.1.170",
|