@decentnetwork/beagle 0.1.129 → 0.1.130

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.
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.129";
1
+ window.__DK_UI_VERSION="0.1.130";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -446,6 +446,8 @@ window.__DK_UI_VERSION="0.1.129";
446
446
  // that failed three times in a row are never probed again on their own.
447
447
  reconnectExit: (ip) => dkPost("/api/exits/reconnect", { ip }),
448
448
  reconnectAllExits: () => dkPost("/api/exits/reconnect", { all: true }),
449
+ // Every exit once, now, failed ones included.
450
+ refreshExits: () => dkPost("/api/exits/refresh", {}),
449
451
  // Used by the browser's welcome flow, which also picks an avatar and marks
450
452
  // first run done. Harmless where there is no welcome flow: nothing calls it.
451
453
  setProfileFull: (p) => dkPost("/api/set-profile", p),
@@ -585,6 +587,7 @@ window.__DK_UI_VERSION="0.1.129";
585
587
  const wantExits = !!(opts && opts.exits);
586
588
  const wantExitsRef = React.useRef(wantExits);
587
589
  wantExitsRef.current = wantExits;
590
+ const exitsOpenedRef = React.useRef(false);
588
591
  const [snap, setSnap] = React.useState({
589
592
  me: DK_ME_FALLBACK,
590
593
  peers: [],
@@ -601,7 +604,12 @@ window.__DK_UI_VERSION="0.1.129";
601
604
  const refresh = React.useCallback(async () => {
602
605
  var _a, _b, _c;
603
606
  try {
604
- const d = await dkGet(wantExitsRef.current ? "/api/desktop?exits=1" : "/api/desktop");
607
+ let path = "/api/desktop";
608
+ if (wantExitsRef.current) {
609
+ path += exitsOpenedRef.current ? "?exits=1&open=1" : "?exits=1";
610
+ exitsOpenedRef.current = false;
611
+ }
612
+ const d = await dkGet(path);
605
613
  if (d && d.locked) {
606
614
  setSnap((s) => ({ ...s, locked: true, loaded: true }));
607
615
  return;
@@ -633,8 +641,10 @@ window.__DK_UI_VERSION="0.1.129";
633
641
  return () => clearInterval(t);
634
642
  }, [refresh]);
635
643
  React.useEffect(() => {
636
- if (wantExits)
644
+ if (wantExits) {
645
+ exitsOpenedRef.current = true;
637
646
  refresh();
647
+ }
638
648
  }, [wantExits, refresh]);
639
649
  const inFlight = React.useRef(/* @__PURE__ */ new Set());
640
650
  const cache = React.useRef({});
@@ -6169,7 +6179,7 @@ ${peer.address}`
6169
6179
  const stuck = n.online && !n.reachable;
6170
6180
  const failed = n.status === "failed";
6171
6181
  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));
6182
+ 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; Reconnect to try again" }, 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 === "down" ? /* @__PURE__ */ React.createElement(Tag, { tone: "warn", title: "No answer \u2014 checked again only when you open this panel or press Refresh; 3 in a row and it is no longer checked" }, T.exitDown || "down", " \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));
6173
6183
  })));
6174
6184
  }
6175
6185
  function LanEnableCard({ backend, onArm, onCancel }) {
@@ -6273,14 +6283,14 @@ ${peer.address}`
6273
6283
  "\u53D6\u6D88,\u6062\u590D\u6D88\u606F"
6274
6284
  )));
6275
6285
  }
6276
- function NetworkTab({ T, me, peers, exits, activeExit, exitFeature, routeFallback, onReconnectExit, reqCount, onSetExit, onOpenChat, backend, onArmLan, onCancelLan }) {
6286
+ function NetworkTab({ T, me, peers, exits, activeExit, exitFeature, routeFallback, onReconnectExit, onRefreshExits, reqCount, onSetExit, onOpenChat, backend, onArmLan, onCancelLan }) {
6277
6287
  const featureOn = !exitFeature || exitFeature.available !== false;
6278
6288
  const anyFailed = (exits || []).some((r) => (r.nodes || []).some((n) => n.status === "failed"));
6279
6289
  if (backend && backend.switchable && !backend.hasVirtualLan) {
6280
6290
  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 })));
6281
6291
  }
6282
6292
  const lanless = !!backend && backend.hasVirtualLan === false;
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 }))))));
6293
+ 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 } }, onRefreshExits && (exits || []).length > 0 && /* @__PURE__ */ React.createElement(ReconnectBtn, { T, ip: null, onReconnect: () => onRefreshExits(), label: T.exitRefresh || "Refresh" }), 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 }))))));
6284
6294
  }
6285
6295
  Object.assign(window, { NetworkTab });
6286
6296
  var dkAudioCtx = null;
@@ -6697,9 +6707,10 @@ ${peer.address}`
6697
6707
  exitReconnectAll: "Reconnect all",
6698
6708
  exitChecking: "checking\u2026",
6699
6709
  exitNotChecked: "not checked",
6700
- exitRetrying: "retrying",
6710
+ exitDown: "down",
6711
+ exitRefresh: "Refresh",
6701
6712
  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.",
6713
+ noExitHint: "Every exit failed 3 checks in a row and is no longer checked. Press Reconnect to try one again.",
6703
6714
  routeFellBack: "Routing fell back to direct: every exit it used failed. Reconnect one to route through it again.",
6704
6715
  identity: "Identity",
6705
6716
  userId: "user id",
@@ -6908,9 +6919,10 @@ ${peer.address}`
6908
6919
  exitReconnectAll: "\u5168\u90E8\u91CD\u8FDE",
6909
6920
  exitChecking: "\u68C0\u6D4B\u4E2D\u2026",
6910
6921
  exitNotChecked: "\u672A\u68C0\u6D4B",
6911
- exitRetrying: "\u91CD\u8BD5\u4E2D",
6922
+ exitDown: "\u4E0D\u901A",
6923
+ exitRefresh: "\u5237\u65B0",
6912
6924
  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",
6925
+ noExitHint: "\u6240\u6709\u51FA\u53E3\u90FD\u8FDE\u7EED 3 \u6B21\u68C0\u6D4B\u5931\u8D25,\u5DF2\u4E0D\u518D\u68C0\u6D4B\u3002\u70B9\u300C\u91CD\u65B0\u8FDE\u63A5\u300D\u518D\u8BD5\u4E00\u6B21\u3002",
6914
6926
  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",
6915
6927
  identity: "\u8EAB\u4EFD",
6916
6928
  userId: "\u7528\u6237 ID",
@@ -7573,6 +7585,10 @@ ${peer.address}`
7573
7585
  data.refresh();
7574
7586
  return r;
7575
7587
  });
7588
+ const onRefreshExits = () => dkApi.refreshExits().then((r) => {
7589
+ data.refresh();
7590
+ return r;
7591
+ });
7576
7592
  const onOpenChat = (id) => {
7577
7593
  setTab("chat");
7578
7594
  openConversation(id);
@@ -7652,7 +7668,7 @@ ${peer.address}`
7652
7668
  fontWeight: 600,
7653
7669
  background: tab === n.id ? "var(--accent)" : "transparent",
7654
7670
  color: tab === n.id ? "#fff" : "var(--dim)"
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(
7671
+ } }, /* @__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, onRefreshExits, 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(
7656
7672
  ProfileTab,
7657
7673
  {
7658
7674
  T,
@@ -2,11 +2,12 @@ export declare const EXIT_PROBE_PORT = 8888;
2
2
  export declare const EXIT_PROBE_TIMEOUT_MS = 2000;
3
3
  /** Consecutive failed probes after which an exit is `failed`. */
4
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";
5
+ /** Panel polls (?exits=1) closer together than this are one panel visit, so
6
+ * they cost one probe round, not one per poll. Long, because a background
7
+ * browser tab is throttled to a poll a minute and must not count as
8
+ * re-opening the panel each time. Re-opening the tab says so explicitly. */
9
+ export declare const EXIT_PANEL_SESSION_GAP_MS: number;
10
+ export type ExitStatus = "unknown" | "ok" | "down" | "failed";
10
11
  export interface ExitRecord {
11
12
  /** Consecutive failed probes (reset by a success). */
12
13
  fails: number;
@@ -14,8 +15,6 @@ export interface ExitRecord {
14
15
  reachable: boolean;
15
16
  ping: number | null;
16
17
  lastProbeAt: number;
17
- /** No automatic probe before this time. */
18
- nextProbeAt: number;
19
18
  }
20
19
  export interface ExitView {
21
20
  ip: string;
@@ -54,14 +53,20 @@ export declare class ExitProber {
54
53
  /** True while at least one of `ips` is not failed — i.e. there is an exit
55
54
  * the route/proxy feature could still use. None configured = false. */
56
55
  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. */
56
+ /** A network-panel poll arrived (?exits=1). True when it is a new visit —
57
+ * `opened` (the UI's first poll after the tab opens) or the first poll
58
+ * after a long gap — and so earns one probe round. Every other poll of
59
+ * the same visit is false: an open panel costs nothing. */
60
+ panelPoll(opened: boolean): boolean;
61
+ /** One access: probe every exit in `ips` once — never a failed one, never
62
+ * one already being probed. Resolves when those probes are done. */
61
63
  refresh(ips: string[]): Promise<void>;
62
64
  /** The Reconnect button: one probe now, whatever the state. Success makes
63
65
  * the exit normal again; a failure leaves a failed exit failed. */
64
66
  reconnect(ip: string): Promise<ExitView>;
67
+ /** The Refresh button: every exit once, failed ones included (with
68
+ * Reconnect's rule: a failed one that fails again stays failed). */
69
+ refreshAll(ips: string[]): Promise<ExitView[]>;
65
70
  get fallback(): RouteFallback | null;
66
71
  setFallback(f: RouteFallback | null): void;
67
72
  }
@@ -7,22 +7,25 @@
7
7
  // of SYNs to seven hosts that never answered — whether or not routing used an
8
8
  // exit and whether or not anyone was looking at the panel.
9
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.
10
+ // The rules this file holds (Weili 2026-11-05: "有访问的时候再查,联系失败后停止查询"
11
+ // — check when something is accessed, stop checking once contact fails):
12
+ // - nothing here runs on a timer. An exit is probed only on an access: the
13
+ // network panel being OPENED (not while it stays open — see panelPoll),
14
+ // a Refresh / Reconnect press, or routing being set to go through it;
15
+ // - each failed access is one failure; MAX_FAILS in a row and the exit is
16
+ // `failed` and only Reconnect / Refresh probes it again. A success resets;
17
+ // - the record lives on disk, so a restart does not start over.
16
18
  import net from "node:net";
17
19
  import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
18
20
  export const EXIT_PROBE_PORT = 8888;
19
21
  export const EXIT_PROBE_TIMEOUT_MS = 2000;
20
22
  /** Consecutive failed probes after which an exit is `failed`. */
21
23
  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;
24
+ /** Panel polls (?exits=1) closer together than this are one panel visit, so
25
+ * they cost one probe round, not one per poll. Long, because a background
26
+ * browser tab is throttled to a poll a minute and must not count as
27
+ * re-opening the panel each time. Re-opening the tab says so explicitly. */
28
+ export const EXIT_PANEL_SESSION_GAP_MS = 5 * 60_000;
26
29
  /** One TCP handshake to the exit's CONNECT-proxy port. A completed handshake
27
30
  * proves the mesh path and the exit process are both live. ECONNREFUSED means
28
31
  * the host answered (L3 reachable) but nothing listens — still reachable for
@@ -62,6 +65,7 @@ export class ExitProber {
62
65
  #exits = new Map();
63
66
  #fallback = null;
64
67
  #inFlight = new Map();
68
+ #lastPanelPoll = -Infinity;
65
69
  constructor(opts = {}) {
66
70
  this.#path = opts.path;
67
71
  this.#probe = opts.probe ?? ((ip) => tcpProbe(ip));
@@ -82,7 +86,6 @@ export class ExitProber {
82
86
  reachable: !!r.reachable,
83
87
  ping: typeof r.ping === "number" ? r.ping : null,
84
88
  lastProbeAt: Number(r.lastProbeAt) || 0,
85
- nextProbeAt: Number(r.nextProbeAt) || 0,
86
89
  });
87
90
  }
88
91
  this.#fallback = f?.fallback && typeof f.fallback.from === "string" ? f.fallback : null;
@@ -107,7 +110,7 @@ export class ExitProber {
107
110
  #record(ip) {
108
111
  let r = this.#exits.get(ip);
109
112
  if (!r) {
110
- r = { fails: 0, failed: false, reachable: false, ping: null, lastProbeAt: 0, nextProbeAt: 0 };
113
+ r = { fails: 0, failed: false, reachable: false, ping: null, lastProbeAt: 0 };
111
114
  this.#exits.set(ip, r);
112
115
  }
113
116
  return r;
@@ -117,7 +120,7 @@ export class ExitProber {
117
120
  if (!r || !r.lastProbeAt) {
118
121
  return { ip, status: r?.failed ? "failed" : "unknown", reachable: false, ping: null, fails: r?.fails ?? 0, checkedAt: null };
119
122
  }
120
- const status = r.failed ? "failed" : r.reachable ? "ok" : r.fails > 0 ? "retrying" : "unknown";
123
+ const status = r.failed ? "failed" : r.reachable ? "ok" : r.fails > 0 ? "down" : "unknown";
121
124
  return { ip, status, reachable: !r.failed && r.reachable, ping: r.failed ? null : r.ping, fails: r.fails, checkedAt: r.lastProbeAt };
122
125
  }
123
126
  isFailed(ip) {
@@ -128,14 +131,15 @@ export class ExitProber {
128
131
  usable(ips) {
129
132
  return ips.some((ip) => !this.isFailed(ip));
130
133
  }
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;
134
+ /** A network-panel poll arrived (?exits=1). True when it is a new visit —
135
+ * `opened` (the UI's first poll after the tab opens) or the first poll
136
+ * after a long gap — and so earns one probe round. Every other poll of
137
+ * the same visit is false: an open panel costs nothing. */
138
+ panelPoll(opened) {
139
+ const now = this.#now();
140
+ const fresh = opened || now - this.#lastPanelPoll > EXIT_PANEL_SESSION_GAP_MS;
141
+ this.#lastPanelPoll = now;
142
+ return fresh;
139
143
  }
140
144
  #apply(ip, ok, ping, manual) {
141
145
  const r = this.#record(ip);
@@ -146,7 +150,6 @@ export class ExitProber {
146
150
  r.failed = false;
147
151
  r.reachable = true;
148
152
  r.ping = ping;
149
- r.nextProbeAt = now + EXIT_OK_CACHE_MS;
150
153
  }
151
154
  else {
152
155
  r.reachable = false;
@@ -159,7 +162,6 @@ export class ExitProber {
159
162
  if (r.fails >= EXIT_MAX_FAILS)
160
163
  r.failed = true;
161
164
  }
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
165
  }
164
166
  this.#save();
165
167
  }
@@ -174,11 +176,11 @@ export class ExitProber {
174
176
  this.#inFlight.set(ip, p);
175
177
  return p;
176
178
  }
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
+ /** One access: probe every exit in `ips` once — never a failed one, never
180
+ * one already being probed. Resolves when those probes are done. */
179
181
  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
+ const todo = [...new Set(ips)].filter((ip) => !this.#inFlight.has(ip) && !this.isFailed(ip));
183
+ await Promise.all(todo.map((ip) => this.#run(ip, false)));
182
184
  }
183
185
  /** The Reconnect button: one probe now, whatever the state. Success makes
184
186
  * the exit normal again; a failure leaves a failed exit failed. */
@@ -186,10 +188,14 @@ export class ExitProber {
186
188
  const running = this.#inFlight.get(ip);
187
189
  if (running)
188
190
  await running;
189
- if (!this.view(ip).reachable)
190
- await this.#run(ip, true);
191
+ await this.#run(ip, true);
191
192
  return this.view(ip);
192
193
  }
194
+ /** The Refresh button: every exit once, failed ones included (with
195
+ * Reconnect's rule: a failed one that fails again stays failed). */
196
+ async refreshAll(ips) {
197
+ return Promise.all([...new Set(ips)].map((ip) => this.reconnect(ip)));
198
+ }
193
199
  get fallback() {
194
200
  return this.#fallback;
195
201
  }
package/dist/server.d.ts CHANGED
@@ -43,6 +43,8 @@ export interface BeagleServerOptions {
43
43
  /** How an exit is probed. Default: a TCP handshake to its :8888. Tests
44
44
  * inject a counter here. */
45
45
  exitProbe?: ProbeFn;
46
+ /** Clock for the exit prober (tests). Default Date.now. */
47
+ exitNow?: () => number;
46
48
  /** The swappable backend, so the UI can show which one is live and drive the
47
49
  * LAN handoff. Optional: a caller that pins one backend just omits it and
48
50
  * the network panel falls back to "no virtual LAN here". */
package/dist/server.js CHANGED
@@ -974,7 +974,48 @@ export function startBeagleServer(opts) {
974
974
  const exitProber = new ExitProber({
975
975
  path: opts.exitStatePath ?? join(dirname(opts.routesPath), "beagle-exit-state.json"),
976
976
  probe: opts.exitProbe,
977
+ now: opts.exitNow,
977
978
  });
979
+ const readRoutes = () => {
980
+ const none = { regions: [], default: "direct" };
981
+ if (!existsSync(opts.routesPath))
982
+ return none;
983
+ try {
984
+ return yaml.load(readFileSync(opts.routesPath, "utf-8")) ?? none;
985
+ }
986
+ catch {
987
+ return none;
988
+ }
989
+ };
990
+ const knownExitIps = (routes) => {
991
+ const known = new Set(DEFAULT_EXITS.flatMap((e) => (e.virtual_ip ? [e.virtual_ip] : [])));
992
+ for (const r of routes.regions ?? [])
993
+ for (const ip of r.exits ?? [])
994
+ known.add(ip);
995
+ return known;
996
+ };
997
+ const routeTargetOf = (routes) => routes.default && routes.default !== "direct" ? routes.default : null;
998
+ /** Routing goes through an exit: probe that route's exits once (an access),
999
+ * never again on a timer. */
1000
+ const probeRoute = async (routes) => {
1001
+ const target = routeTargetOf(routes);
1002
+ if (!target)
1003
+ return;
1004
+ await exitProber.refresh(exitsForTarget(target, routes.regions ?? [], DEFAULT_EXITS, knownExitIps(routes)));
1005
+ };
1006
+ /** A fallback to direct whose exits work again goes back to its route. */
1007
+ const restoreFallback = (routes) => {
1008
+ const fb = exitProber.fallback;
1009
+ if (!fb)
1010
+ return null;
1011
+ const fbIps = exitsForTarget(fb.from, routes.regions ?? [], DEFAULT_EXITS, knownExitIps(routes));
1012
+ if (!fbIps.some((ip) => exitProber.view(ip).reachable))
1013
+ return null;
1014
+ exitProber.setFallback(null);
1015
+ return (routes.default ?? "direct") === "direct" && writeRoutesDefault(opts.routesPath, fb.from) ? fb.from : null;
1016
+ };
1017
+ // Daemon start with routing through an exit = one access.
1018
+ void probeRoute(readRoutes()).catch(() => undefined);
978
1019
  const sendJson = (res, code, body) => {
979
1020
  res.writeHead(code, { "content-type": "application/json" });
980
1021
  res.end(JSON.stringify(body));
@@ -2263,16 +2304,16 @@ export function startBeagleServer(opts) {
2263
2304
  // announcing on the DHT — it does NOT mean IP packets get through (a
2264
2305
  // desynced/NAT-blocked exit shows "online" while `ping 10.86.x` times out,
2265
2306
  // e.g. GFAX). The proof is a TCP handshake to the exit's CONNECT-proxy
2266
- // port — but see exit-probe.ts: probes are cached and backed off, a dead
2267
- // exit goes `failed` after three tries and is never probed again on its
2268
- // own, and nothing is probed at all unless the network panel is open
2269
- // (?exits=1) or routing actually goes through an exit.
2307
+ // port — but see exit-probe.ts: nothing is probed on a timer. Opening
2308
+ // the network panel is one probe round (?exits=1&open=1, or the first
2309
+ // ?exits=1 after a long gap); the panel staying open costs nothing; a
2310
+ // dead exit goes `failed` after three failed accesses and only
2311
+ // Reconnect / Refresh touch it again.
2270
2312
  const allNodes = [...byRegion.values()].flat();
2271
2313
  const allIps = allNodes.map((n) => n.ip);
2272
2314
  const qs = new URL(req.url || "", "http://localhost").searchParams;
2273
- const panelOpen = qs.get("exits") === "1";
2274
- const routeTarget = routes.default && routes.default !== "direct" ? routes.default : null;
2275
- if (panelOpen || routeTarget)
2315
+ const routeTarget = routeTargetOf(routes);
2316
+ if (qs.get("exits") === "1" && exitProber.panelPoll(qs.get("open") === "1"))
2276
2317
  await exitProber.refresh(allIps);
2277
2318
  const views = new Map(allIps.map((ip) => [ip, exitProber.view(ip)]));
2278
2319
  for (const n of allNodes) {
@@ -2655,34 +2696,24 @@ export function startBeagleServer(opts) {
2655
2696
  // dial arbitrary hosts. {all:true} = every failed exit.
2656
2697
  if (req.method === "POST" && url === "/api/exits/reconnect") {
2657
2698
  const body = (await readBody(req));
2658
- let routes = { regions: [], default: "direct" };
2659
- if (existsSync(opts.routesPath)) {
2660
- try {
2661
- routes = yaml.load(readFileSync(opts.routesPath, "utf-8")) ?? routes;
2662
- }
2663
- catch { /* keep direct */ }
2664
- }
2665
- const known = new Set(DEFAULT_EXITS.flatMap((e) => (e.virtual_ip ? [e.virtual_ip] : [])));
2666
- for (const r of routes.regions ?? [])
2667
- for (const ip of r.exits ?? [])
2668
- known.add(ip);
2699
+ const routes = readRoutes();
2700
+ const known = knownExitIps(routes);
2669
2701
  const targets = body.all
2670
2702
  ? [...known].filter((ip) => exitProber.isFailed(ip))
2671
2703
  : typeof body.ip === "string" && known.has(body.ip) ? [body.ip] : [];
2672
2704
  if (!body.all && !targets.length)
2673
2705
  return sendJson(res, 400, { ok: false, error: "unknown exit" });
2674
2706
  const results = await Promise.all(targets.map((ip) => exitProber.reconnect(ip)));
2675
- // Routing that fell back to direct goes back once one of its exits works.
2676
- const fb = exitProber.fallback;
2677
- let restored = null;
2678
- if (fb) {
2679
- const fbIps = exitsForTarget(fb.from, routes.regions ?? [], DEFAULT_EXITS, known);
2680
- if (fbIps.some((ip) => exitProber.view(ip).reachable)) {
2681
- if ((routes.default ?? "direct") === "direct" && writeRoutesDefault(opts.routesPath, fb.from))
2682
- restored = fb.from;
2683
- exitProber.setFallback(null);
2684
- }
2685
- }
2707
+ const restored = restoreFallback(routes);
2708
+ sendJson(res, 200, { ok: true, exits: results, restored, available: exitProber.usable([...known]) });
2709
+ return;
2710
+ }
2711
+ // The panel's Refresh: every known exit once, now — failed ones too.
2712
+ if (req.method === "POST" && url === "/api/exits/refresh") {
2713
+ const routes = readRoutes();
2714
+ const known = knownExitIps(routes);
2715
+ const results = await exitProber.refreshAll([...known]);
2716
+ const restored = restoreFallback(routes);
2686
2717
  sendJson(res, 200, { ok: true, exits: results, restored, available: exitProber.usable([...known]) });
2687
2718
  return;
2688
2719
  }
@@ -2709,7 +2740,11 @@ export function startBeagleServer(opts) {
2709
2740
  const body = await readBody(req);
2710
2741
  try {
2711
2742
  const routes = JSON.parse(body.routes ?? "{}");
2743
+ const before = routeTargetOf(readRoutes());
2712
2744
  writeFileSync(opts.routesPath, yaml.dump(routes, { lineWidth: -1 }), "utf-8");
2745
+ // Routing just set to go through an exit = one access of its exits.
2746
+ if (routeTargetOf(routes) && routeTargetOf(routes) !== before)
2747
+ void probeRoute(routes).catch(() => undefined);
2713
2748
  sendJson(res, 200, { ok: true });
2714
2749
  }
2715
2750
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.129",
3
+ "version": "0.1.130",
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.37",
29
+ "@decentnetwork/beagle-ui": "^0.2.38",
30
30
  "@decentnetwork/chat-components": "^0.1.3",
31
31
  "@decentnetwork/lan": "^0.1.313",
32
32
  "@decentnetwork/peer": "^0.1.170",