@decentnetwork/beagle 0.1.128 → 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.
@@ -26,10 +26,12 @@
26
26
  exits:
27
27
  # --- China (China-only / GFW-blocked-from-outside sites) ---
28
28
  - name: cn
29
+ label: ea-1
29
30
  userid: 5Aj6uQMd1cNRb9cGT4AwHCN4RdVZjfaGLtgKGhdP1LzN
30
31
  virtual_ip: 10.86.1.15
31
32
  region: china
32
33
  - name: sh
34
+ label: ea-2
33
35
  userid: 6D1PLSVqbSpcxnDMAd8ZXLWycYveeP2hU4g3igDQEqA7
34
36
  virtual_ip: 10.86.1.16
35
37
  region: china
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.128";
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.128";
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.128";
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.128";
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.128";
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/exits.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export interface DefaultExit {
2
2
  name: string;
3
+ /** What the panel shows for this exit, when the name is not for users. */
4
+ label?: string;
3
5
  userid: string;
4
6
  virtual_ip?: string;
5
7
  region?: string;
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
@@ -35,6 +35,15 @@ import { globalPrefixOf } from "./global-prefix.js";
35
35
  // Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
36
36
  // scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
37
37
  const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
38
+ /** How an exit region is shown to users. The keys are routing keys (routes.yaml
39
+ * region names, the router's built-in domain lists) and stay as they are; the
40
+ * flag and label are display only. App strings never carry PRC place names
41
+ * (CLAUDE.md), so the "china" region is shown as East Asia. */
42
+ const REGION_META = {
43
+ china: { flag: "EA", label: "East Asia" },
44
+ japan: { flag: "JP", label: "Japan" },
45
+ us: { flag: "US", label: "United States" },
46
+ };
38
47
  /** Set routes.yaml's top-level `default:` to `value`, keeping the rest of the
39
48
  * file as written. False when there is no file or no such line to change. */
40
49
  function writeRoutesDefault(path, value) {
@@ -965,7 +974,48 @@ export function startBeagleServer(opts) {
965
974
  const exitProber = new ExitProber({
966
975
  path: opts.exitStatePath ?? join(dirname(opts.routesPath), "beagle-exit-state.json"),
967
976
  probe: opts.exitProbe,
977
+ now: opts.exitNow,
968
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);
969
1019
  const sendJson = (res, code, body) => {
970
1020
  res.writeHead(code, { "content-type": "application/json" });
971
1021
  res.end(JSON.stringify(body));
@@ -2116,7 +2166,8 @@ export function startBeagleServer(opts) {
2116
2166
  channel: opts.meExtra?.channel ?? "@next",
2117
2167
  wire: opts.meExtra?.wire ?? "163",
2118
2168
  isExit: !!meExit,
2119
- exitRegion: meExit?.region ?? null,
2169
+ // The display tag, not the routing key (see REGION_META).
2170
+ exitRegion: meExit?.region ? REGION_META[meExit.region]?.flag ?? meExit.region : null,
2120
2171
  // null = backend didn't report it (daemon); the UI then hides the
2121
2172
  // toggle rather than showing a state it cannot know.
2122
2173
  autoAccept: typeof node.autoAccept === "boolean" ? node.autoAccept : null,
@@ -2222,11 +2273,6 @@ export function startBeagleServer(opts) {
2222
2273
  onlineIps.add(ip);
2223
2274
  }
2224
2275
  }
2225
- const regionMeta = {
2226
- china: { flag: "CN", label: "China" },
2227
- japan: { flag: "JP", label: "Japan" },
2228
- us: { flag: "US", label: "United States" },
2229
- };
2230
2276
  const byRegion = new Map();
2231
2277
  for (const e of DEFAULT_EXITS) {
2232
2278
  if (!e.virtual_ip)
@@ -2236,7 +2282,7 @@ export function startBeagleServer(opts) {
2236
2282
  byRegion.set(region, []);
2237
2283
  byRegion.get(region).push({
2238
2284
  ip: e.virtual_ip,
2239
- host: e.name,
2285
+ host: e.label ?? e.name,
2240
2286
  online: onlineUserids.has(e.userid) || onlineIps.has(e.virtual_ip),
2241
2287
  reachable: false,
2242
2288
  ping: null,
@@ -2258,16 +2304,16 @@ export function startBeagleServer(opts) {
2258
2304
  // announcing on the DHT — it does NOT mean IP packets get through (a
2259
2305
  // desynced/NAT-blocked exit shows "online" while `ping 10.86.x` times out,
2260
2306
  // 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.
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.
2265
2312
  const allNodes = [...byRegion.values()].flat();
2266
2313
  const allIps = allNodes.map((n) => n.ip);
2267
2314
  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)
2315
+ const routeTarget = routeTargetOf(routes);
2316
+ if (qs.get("exits") === "1" && exitProber.panelPoll(qs.get("open") === "1"))
2271
2317
  await exitProber.refresh(allIps);
2272
2318
  const views = new Map(allIps.map((ip) => [ip, exitProber.view(ip)]));
2273
2319
  for (const n of allNodes) {
@@ -2299,8 +2345,8 @@ export function startBeagleServer(opts) {
2299
2345
  };
2300
2346
  const exits = [...byRegion.entries()].map(([region, nodes]) => ({
2301
2347
  region,
2302
- flag: regionMeta[region]?.flag ?? region.slice(0, 2).toUpperCase(),
2303
- label: regionMeta[region]?.label ?? region,
2348
+ flag: REGION_META[region]?.flag ?? region.slice(0, 2).toUpperCase(),
2349
+ label: REGION_META[region]?.label ?? region,
2304
2350
  nodes,
2305
2351
  reachableCount: nodes.filter((n) => n.reachable).length,
2306
2352
  unreachableCount: nodes.filter((n) => !n.reachable).length,
@@ -2650,34 +2696,24 @@ export function startBeagleServer(opts) {
2650
2696
  // dial arbitrary hosts. {all:true} = every failed exit.
2651
2697
  if (req.method === "POST" && url === "/api/exits/reconnect") {
2652
2698
  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);
2699
+ const routes = readRoutes();
2700
+ const known = knownExitIps(routes);
2664
2701
  const targets = body.all
2665
2702
  ? [...known].filter((ip) => exitProber.isFailed(ip))
2666
2703
  : typeof body.ip === "string" && known.has(body.ip) ? [body.ip] : [];
2667
2704
  if (!body.all && !targets.length)
2668
2705
  return sendJson(res, 400, { ok: false, error: "unknown exit" });
2669
2706
  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
- }
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);
2681
2717
  sendJson(res, 200, { ok: true, exits: results, restored, available: exitProber.usable([...known]) });
2682
2718
  return;
2683
2719
  }
@@ -2704,7 +2740,11 @@ export function startBeagleServer(opts) {
2704
2740
  const body = await readBody(req);
2705
2741
  try {
2706
2742
  const routes = JSON.parse(body.routes ?? "{}");
2743
+ const before = routeTargetOf(readRoutes());
2707
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);
2708
2748
  sendJson(res, 200, { ok: true });
2709
2749
  }
2710
2750
  catch (err) {
@@ -2905,8 +2945,8 @@ const PAGE = `<!doctype html>
2905
2945
  <div id="exits"></div>
2906
2946
  <div class="add">
2907
2947
  <input id="exitIp" placeholder="exit virtual IP (e.g. 10.86.1.15)" autocomplete="off" style="flex:2"/>
2908
- <input id="exitRegion" placeholder="region (china/japan/us)" autocomplete="off" list="regionList" style="flex:1"/>
2909
- <datalist id="regionList"><option>china</option><option>japan</option><option>us</option></datalist>
2948
+ <input id="exitRegion" placeholder="region (e.g. japan, us)" autocomplete="off" list="regionList" style="flex:1"/>
2949
+ <datalist id="regionList"><option>japan</option><option>us</option></datalist>
2910
2950
  <button onclick="addExit()">Add exit</button>
2911
2951
  </div>
2912
2952
 
@@ -3023,6 +3063,8 @@ async function sendChat(){
3023
3063
  document.getElementById('chatInput').addEventListener('keydown', e=>{ if(e.key==='Enter') sendChat(); });
3024
3064
 
3025
3065
  // ---- Exit nodes (routes.yaml) ----
3066
+ // Display names for routing keys; the key itself stays in routes.yaml.
3067
+ const REGION_LABEL = { china: 'East Asia' };
3026
3068
  let routesObj = { regions: [], default: 'direct' };
3027
3069
  async function refreshExits(){
3028
3070
  let r; try { r = await api('/api/routes'); } catch(e){ return; }
@@ -3033,7 +3075,7 @@ async function refreshExits(){
3033
3075
  document.getElementById('ecount').textContent = total ? '('+total+')' : '';
3034
3076
  document.getElementById('exits').innerHTML = regions.length ? regions.map((rg)=> \`
3035
3077
  <div class="row" style="flex-direction:column; align-items:stretch; gap:.3rem">
3036
- <div class="name">\${esc(rg.name)} \${(rg.exits||[]).length? '':'<span class="sub">(empty → direct)</span>'}</div>
3078
+ <div class="name">\${esc(REGION_LABEL[rg.name]||rg.name)} \${(rg.exits||[]).length? '':'<span class="sub">(empty → direct)</span>'}</div>
3037
3079
  \${(rg.exits||[]).map(ip=>\`<div class="sub" style="display:flex; gap:.5rem; align-items:center"><span style="flex:1">\${esc(ip)}</span><button class="reject" onclick="removeExit('\${esc(rg.name)}','\${esc(ip)}')">×</button></div>\`).join('')}
3038
3080
  </div>\`).join('') : '<div class="empty">No routes.yaml regions — using auto-discovery.</div>';
3039
3081
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.128",
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",