@decentnetwork/lan 0.1.233 → 0.1.234

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.
@@ -381,31 +381,50 @@ export async function cmdStatus(args) {
381
381
  return;
382
382
  }
383
383
  const config = await ConfigLoader.load(configPath);
384
- let liveTun = null;
384
+ let diag = null;
385
385
  try {
386
386
  const res = await ipcCall(config, { op: "diag" }, 5_000);
387
387
  if (res.ok)
388
- liveTun = (res.data?.tun) ?? null;
388
+ diag = res.data ?? null;
389
389
  }
390
390
  catch {
391
391
  // daemon not running — fall back to config below
392
392
  }
393
- const running = !!liveTun;
394
393
  console.log(`Decent AgentNet`);
395
394
  console.log(`Node: ${config.node.name}`);
396
395
  console.log(`Namespace: ${config.node.namespace}`);
397
- console.log(`Daemon: ${running ? "running" : "not running (values below are configured, not live)"}`);
398
- console.log(`Subnet: ${liveTun?.subnet ?? config.network.subnet}`);
399
- console.log(`Interface: ${liveTun?.name ?? config.network.interface}${running ? "" : " (configured)"}`);
400
- console.log(`Virtual IP: ${liveTun?.ip ?? config.network.ip}${running ? "" : " (configured)"}`);
401
- if (running && liveTun?.ip && liveTun.ip !== config.network.ip) {
396
+ if (!diag) {
397
+ console.log(`Daemon: not running — everything below is CONFIGURED, not live`);
398
+ console.log(`Subnet: ${config.network.subnet}`);
399
+ console.log(`Interface: ${config.network.interface}`);
400
+ console.log(`Virtual IP: ${config.network.ip}`);
401
+ console.log(`Bootstrap: ${config.carrier.bootstrapNodes.length} nodes`);
402
+ const ipamOffline = await Ipam.loadOrCreate(config.paths.ipamFile, config.node.namespace);
403
+ console.log(`Peers (on disk): ${ipamOffline.getPeers().length}`);
404
+ const policyOffline = await Policy.loadOrCreate(config.paths.policyFile);
405
+ console.log(`ACL rules: ${policyOffline.getRules().length}`);
406
+ console.log(`\nStart it with 'agentnet up --real-tun' (or 'agentnet service install').`);
407
+ return;
408
+ }
409
+ const friends = diag.friends ?? [];
410
+ const online = friends.filter((f) => f.status === "online" || f.status === "connected").length;
411
+ const s = diag.stats ?? {};
412
+ console.log(`Daemon: running`);
413
+ if (diag.identity?.userid)
414
+ console.log(`Userid: ${diag.identity.userid}`);
415
+ if (diag.identity?.address)
416
+ console.log(`Address: ${diag.identity.address}`);
417
+ console.log(`Subnet: ${diag.tun?.subnet ?? config.network.subnet}`);
418
+ console.log(`Interface: ${diag.tun?.name ?? config.network.interface}`);
419
+ console.log(`Virtual IP: ${diag.tun?.ip ?? "(no TUN)"}`);
420
+ if (diag.tun?.ip && diag.tun.ip !== config.network.ip) {
402
421
  console.log(` note: config.yaml still says ${config.network.ip} — the daemon's address wins`);
403
422
  }
404
423
  console.log(`Bootstrap: ${config.carrier.bootstrapNodes.length} nodes`);
405
- // Show IPAM peers count
406
- const ipam = await Ipam.loadOrCreate(config.paths.ipamFile, config.node.namespace);
407
- console.log(`Peers configured: ${ipam.getPeers().length}`);
408
- // Show ACL rules count
424
+ console.log(`Peers (roster): ${diag.ipam?.length ?? 0}`);
425
+ console.log(`Friends: ${friends.length} (${online} online)`);
426
+ console.log(`Sessions: ${s.activeSessions ?? 0} active`);
427
+ console.log(`Traffic: ${s.packetsForwarded ?? 0} sent / ${s.packetsReceived ?? 0} received / ${s.packetsDropped ?? 0} dropped`);
409
428
  const policy = await Policy.loadOrCreate(config.paths.policyFile);
410
429
  console.log(`ACL rules: ${policy.getRules().length}`);
411
430
  }
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.233";
1
+ window.__DK_UI_VERSION="0.1.234";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
@@ -41,6 +41,8 @@ const ICON_PATHS = {
41
41
  // ---- account ----
42
42
  shield: '<path d="M12 21.5s7.5-3.8 7.5-9.5V5.5L12 2.8 4.5 5.5V12c0 5.7 7.5 9.5 7.5 9.5Z"/>',
43
43
  download: '<path d="M12 3.5v11M7.5 10l4.5 4.5 4.5-4.5"/><path d="M5 20.5h14"/>',
44
+ // Folder with a magnifier corner — "reveal / show in file manager".
45
+ folderOpen: '<path d="M3 7.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><circle cx="12" cy="13" r="2.4"/><path d="M13.9 14.9 15.5 16.5"/>',
44
46
  migrate: '<path d="M8 4 4 8l4 4"/><path d="M4 8h13a3 3 0 0 1 3 3v1"/><path d="m16 20 4-4-4-4"/><path d="M20 16H7a3 3 0 0 1-3-3v-1"/>',
45
47
  starCoin: '<circle cx="12" cy="12" r="9"/><path d="m12 7.2 1.4 2.9 3.2.4-2.3 2.2.6 3.1-2.9-1.5-2.9 1.5.6-3.1L8.4 10.5l3.2-.4L12 7.2Z" fill="currentColor" stroke="none"/>',
46
48
  link: '<path d="M10.5 13.5a4 4 0 0 0 5.7 0l2.3-2.3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M13.5 10.5a4 4 0 0 0-5.7 0l-2.3 2.3a4 4 0 0 0 5.7 5.7l1-1"/>',
@@ -232,6 +234,8 @@ const dkApi = {
232
234
  alias: (userid, alias) => dkPost("/api/friend-alias", { userid, alias }),
233
235
  markRead: (userid) => dkPost("/api/chat-mark-read", { userid }),
234
236
  delFiles: (userid, ids) => dkPost("/api/file-delete", { userid, ids }),
237
+ // Reveal a received file in the OS file manager (localhost-only on the daemon side).
238
+ revealFile: (name) => dkPost("/api/file-reveal", { name }),
235
239
  cancelSend: (userid, ids) => dkPost("/api/file-cancel", { userid, ids }),
236
240
  retrySend: (userid, ids) => dkPost("/api/file-retry", { userid, ids }),
237
241
  setProfile: (name, description) => dkPost("/api/set-profile", { name, description }),
@@ -1140,10 +1144,17 @@ function PeerRow({ peer, T, active, onClick }) {
1140
1144
  minWidth: 0
1141
1145
  } }, peer.lastMsg))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4, flexShrink: 0 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)" } }, peer.lastTime), peer.unread ? /* @__PURE__ */ React.createElement(Unread, { n: peer.unread }) : /* @__PURE__ */ React.createElement(StatusDot, { online: peer.online })));
1142
1146
  }
1143
- function PeerSidebar({ T, peers, requests, activeId, onSelect, onAct, onAdd }) {
1147
+ function PeerSidebar({ T, peers, requests, activeId, onSelect, onAct, onAdd, prefillAddr, onPrefillConsumed }) {
1144
1148
  const [q, setQ] = React.useState("");
1145
1149
  const [addr, setAddr] = React.useState("");
1146
1150
  const [addState, setAddState] = React.useState(null);
1151
+ const [fromDeepLink, setFromDeepLink] = React.useState(false);
1152
+ React.useEffect(() => {
1153
+ if (prefillAddr) {
1154
+ setAddr(prefillAddr);
1155
+ setFromDeepLink(true);
1156
+ }
1157
+ }, [prefillAddr]);
1147
1158
  const submitAddr = () => {
1148
1159
  const v = addr.trim();
1149
1160
  if (!v || addState && addState.kind === "sending") return;
@@ -1151,6 +1162,8 @@ function PeerSidebar({ T, peers, requests, activeId, onSelect, onAct, onAdd }) {
1151
1162
  Promise.resolve(onAdd(v)).then((r) => {
1152
1163
  if (r && r.ok) {
1153
1164
  setAddr("");
1165
+ setFromDeepLink(false);
1166
+ onPrefillConsumed && onPrefillConsumed();
1154
1167
  setAddState({ kind: "ok", msg: T.addSent });
1155
1168
  } else {
1156
1169
  setAddState({ kind: "err", msg: r && r.error ? `${T.addFailed}: ${r.error}` : T.addFailed });
@@ -1167,9 +1180,30 @@ function PeerSidebar({ T, peers, requests, activeId, onSelect, onAct, onAdd }) {
1167
1180
  return s.toLowerCase().includes(q.toLowerCase());
1168
1181
  });
1169
1182
  const online = peers.filter((p) => p.online).length;
1170
- return /* @__PURE__ */ React.createElement("div", { style: { width: 320, flexShrink: 0, borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement("div", { style: { padding: "12px 12px 10px", borderBottom: "1px solid var(--line)", display: "flex", flexDirection: "column", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 7 } }, /* @__PURE__ */ React.createElement("input", { value: addr, onChange: (e) => setAddr(e.target.value), onKeyDown: (e) => {
1171
- if (e.key === "Enter") submitAddr();
1172
- }, placeholder: T.addPlaceholder, style: inputStyle }), /* @__PURE__ */ React.createElement(Btn, { tone: "solid", icon: "userPlus", onClick: submitAddr }, T.add)), addState && /* @__PURE__ */ React.createElement("div", { style: {
1183
+ return /* @__PURE__ */ React.createElement("div", { style: { width: 320, flexShrink: 0, borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement("div", { style: { padding: "12px 12px 10px", borderBottom: "1px solid var(--line)", display: "flex", flexDirection: "column", gap: 8 } }, fromDeepLink && /* @__PURE__ */ React.createElement("div", { style: {
1184
+ fontFamily: "var(--ui)",
1185
+ fontSize: 12,
1186
+ lineHeight: 1.4,
1187
+ color: "var(--text)",
1188
+ background: "color-mix(in oklab, var(--accent), transparent 88%)",
1189
+ border: "1px solid var(--accent)",
1190
+ borderRadius: 8,
1191
+ padding: "7px 10px"
1192
+ } }, T.deepLinkConfirm || "\u901A\u8FC7\u94FE\u63A5\u6DFB\u52A0\u8FD9\u4F4D\u597D\u53CB? \u70B9\u300C\u6DFB\u52A0\u300D\u53D1\u9001\u597D\u53CB\u8BF7\u6C42 / Add this contact from the link? Tap Add to send a friend request."), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 7 } }, /* @__PURE__ */ React.createElement(
1193
+ "input",
1194
+ {
1195
+ value: addr,
1196
+ onChange: (e) => {
1197
+ setAddr(e.target.value);
1198
+ if (fromDeepLink) setFromDeepLink(false);
1199
+ },
1200
+ onKeyDown: (e) => {
1201
+ if (e.key === "Enter") submitAddr();
1202
+ },
1203
+ placeholder: T.addPlaceholder,
1204
+ style: { ...inputStyle, ...fromDeepLink ? { borderColor: "var(--accent)" } : null }
1205
+ }
1206
+ ), /* @__PURE__ */ React.createElement(Btn, { tone: "solid", icon: "userPlus", onClick: submitAddr }, T.add)), addState && /* @__PURE__ */ React.createElement("div", { style: {
1173
1207
  fontFamily: "var(--mono)",
1174
1208
  fontSize: 11.5,
1175
1209
  lineHeight: 1.35,
@@ -1306,7 +1340,7 @@ function dkMarkdownHtml(src) {
1306
1340
  function MarkdownText({ text }) {
1307
1341
  return /* @__PURE__ */ React.createElement("div", { className: "dk-md", dangerouslySetInnerHTML: { __html: dkMarkdownHtml(text) } });
1308
1342
  }
1309
- function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onCall, selMode, selected, onToggleSel, busy }) {
1343
+ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onReveal, onCall, selMode, selected, onToggleSel, busy }) {
1310
1344
  const mine = m.from === "me";
1311
1345
  const rtcFile = !mine && !m.file ? dkRtcFileFromText(peer.userId || peer.id, m.text) : null;
1312
1346
  const callRec = !m.file ? dkCallFromText(m.text) : null;
@@ -1444,7 +1478,18 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onCall, selMo
1444
1478
  style: mediaBtnStyle
1445
1479
  },
1446
1480
  /* @__PURE__ */ React.createElement(Icon, { name: "maximize", size: 16, stroke: 2, color: "#fff" })
1447
- ))), m.file.media === "audio" && /* @__PURE__ */ React.createElement("audio", { src: dkFileUrl(m.file.name), controls: true, style: { width: "100%" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "0 2px" } }, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", flexShrink: 0 } }, m.file.size), /* @__PURE__ */ React.createElement("a", { href: dkFileDownloadUrl(m.file.name), download: m.file.name, title: "download", style: { display: "inline-flex", flexShrink: 0 } }, /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 15, stroke: 2, color: "var(--accent)" }))))
1481
+ ))), m.file.media === "audio" && /* @__PURE__ */ React.createElement("audio", { src: dkFileUrl(m.file.name), controls: true, style: { width: "100%" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "0 2px" } }, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", flexShrink: 0 } }, m.file.size), onReveal && /* @__PURE__ */ React.createElement(
1482
+ "button",
1483
+ {
1484
+ title: T.revealInFinder || "\u5728\u6587\u4EF6\u7BA1\u7406\u5668\u4E2D\u663E\u793A / Show in file manager",
1485
+ onClick: (e) => {
1486
+ e.stopPropagation();
1487
+ onReveal(m.file.name);
1488
+ },
1489
+ style: { background: "none", border: "none", cursor: "pointer", padding: 0, display: "inline-flex", flexShrink: 0 }
1490
+ },
1491
+ /* @__PURE__ */ React.createElement(Icon, { name: "folderOpen", size: 15, stroke: 2, color: "var(--accent)" })
1492
+ ), /* @__PURE__ */ React.createElement("a", { href: dkFileDownloadUrl(m.file.name), download: m.file.name, title: "download", style: { display: "inline-flex", flexShrink: 0 } }, /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 15, stroke: 2, color: "var(--accent)" }))))
1448
1493
  ) : (() => {
1449
1494
  const cardStyle = {
1450
1495
  display: "flex",
@@ -1497,6 +1542,18 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onCall, selMo
1497
1542
  },
1498
1543
  icon,
1499
1544
  meta,
1545
+ onReveal && /* @__PURE__ */ React.createElement(
1546
+ "button",
1547
+ {
1548
+ title: T.revealInFinder || "\u5728\u6587\u4EF6\u7BA1\u7406\u5668\u4E2D\u663E\u793A / Show in file manager",
1549
+ onClick: (e) => {
1550
+ e.stopPropagation();
1551
+ onReveal(m.file.name);
1552
+ },
1553
+ style: { background: "none", border: "none", cursor: "pointer", padding: 0, display: "inline-flex", flexShrink: 0 }
1554
+ },
1555
+ /* @__PURE__ */ React.createElement(Icon, { name: "folderOpen", size: 16, stroke: 2, color: "var(--accent)" })
1556
+ ),
1500
1557
  /* @__PURE__ */ React.createElement(
1501
1558
  "a",
1502
1559
  {
@@ -1621,6 +1678,14 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1621
1678
  delete n[id];
1622
1679
  return n;
1623
1680
  });
1681
+ const doReveal = (name) => {
1682
+ if (!name) return;
1683
+ dkApi.revealFile(name).then((r) => {
1684
+ if (!r || r.ok === false) {
1685
+ showFlash("err", (lang === "zh" ? "\u65E0\u6CD5\u5728\u6587\u4EF6\u7BA1\u7406\u5668\u4E2D\u663E\u793A: " : "Could not reveal file: ") + (r && r.error || ""));
1686
+ }
1687
+ });
1688
+ };
1624
1689
  const doCancel = (id) => {
1625
1690
  if (!id || fileBusy[id]) return;
1626
1691
  setFileBusy((b) => ({ ...b, [id]: "cancel" }));
@@ -1807,6 +1872,7 @@ ${peer.address}`
1807
1872
  onDelete: (id) => doDelete([id]),
1808
1873
  onCancel: doCancel,
1809
1874
  onRetry: doRetry,
1875
+ onReveal: doReveal,
1810
1876
  onCall,
1811
1877
  busy: m.id ? fileBusy[m.id] : void 0,
1812
1878
  selMode,
@@ -1930,9 +1996,9 @@ function MenuItem({ icon, label, onClick, danger }) {
1930
1996
  function ChatEmpty({ T }) {
1931
1997
  return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, color: "var(--faint)", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "message", size: 40, stroke: 1.4, color: "var(--line)" }), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13 } }, T.pickPeer));
1932
1998
  }
1933
- function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread }) {
1999
+ function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, prefillAddr, onPrefillConsumed }) {
1934
2000
  const peer = peers.find((p) => p.id === activeId);
1935
- return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", minWidth: 0, minHeight: 0 } }, /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd }), peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, thread, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread }) : /* @__PURE__ */ React.createElement(ChatEmpty, { T }));
2001
+ return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", minWidth: 0, minHeight: 0 } }, /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd, prefillAddr, onPrefillConsumed }), peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, thread, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread }) : /* @__PURE__ */ React.createElement(ChatEmpty, { T }));
1936
2002
  }
1937
2003
  Object.assign(window, { ChatTab });
1938
2004
  function StatTile({ label, value, sub, tone }) {
@@ -2645,6 +2711,20 @@ function CallOverlay({ T, ctl, peers }) {
2645
2711
  })() : /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 22 } }, /* @__PURE__ */ React.createElement("div", { style: { position: "relative", display: "flex", alignItems: "center", justifyContent: "center" } }, !connected && /* @__PURE__ */ React.createElement("span", { style: { position: "absolute", width: 150, height: 150, borderRadius: 999, border: "2px solid var(--accent)", animation: "dkpulse 2s ease-out infinite" } }), /* @__PURE__ */ React.createElement(DkIdenticon, { seed: call.peerId, size: 108, radius: 26 })), /* @__PURE__ */ React.createElement("div", { style: { textAlign: "center" } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 22, fontWeight: 700, color: "var(--text)" } }, name), /* @__PURE__ */ React.createElement("div", { style: { marginTop: 8, fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--faint)" } }, stateLabel)), /* @__PURE__ */ React.createElement("div", { style: { marginTop: 18 } }, controls)));
2646
2712
  }
2647
2713
  Object.assign(window, { useCallController, CallOverlay, IncomingCallModal });
2714
+ function dkParseDeepLinkAddress() {
2715
+ try {
2716
+ const h = window.location.hash || "";
2717
+ const qi = h.indexOf("?");
2718
+ if (qi >= 0) {
2719
+ const a = new URLSearchParams(h.slice(qi + 1)).get("address");
2720
+ if (a && a.trim()) return a.trim();
2721
+ }
2722
+ const a2 = new URLSearchParams(window.location.search || "").get("address");
2723
+ if (a2 && a2.trim()) return a2.trim();
2724
+ } catch (e) {
2725
+ }
2726
+ return "";
2727
+ }
2648
2728
  const DK_DEFAULTS = (
2649
2729
  /*EDITMODE-BEGIN*/
2650
2730
  {
@@ -2922,6 +3002,34 @@ function DkApp() {
2922
3002
  sessionStorage.setItem(key, "1");
2923
3003
  window.location.reload();
2924
3004
  }, [me && me.lanVer]);
3005
+ const [pendingAddr, setPendingAddr] = React.useState(() => dkParseDeepLinkAddress());
3006
+ React.useEffect(() => {
3007
+ const onHash = () => {
3008
+ const a = dkParseDeepLinkAddress();
3009
+ if (a) setPendingAddr(a);
3010
+ };
3011
+ window.addEventListener("hashchange", onHash);
3012
+ return () => window.removeEventListener("hashchange", onHash);
3013
+ }, []);
3014
+ React.useEffect(() => {
3015
+ if (!pendingAddr) return;
3016
+ setTab("chat");
3017
+ if (!peers || !peers.length) return;
3018
+ const hit = peers.find((p) => p.address && p.address === pendingAddr);
3019
+ if (hit) {
3020
+ setActiveId(hit.id);
3021
+ setPendingAddr("");
3022
+ try {
3023
+ window.history.replaceState(null, "", window.location.pathname);
3024
+ } catch (e) {
3025
+ }
3026
+ } else {
3027
+ try {
3028
+ window.history.replaceState(null, "", window.location.pathname);
3029
+ } catch (e) {
3030
+ }
3031
+ }
3032
+ }, [pendingAddr, peers]);
2925
3033
  const peers = data.peers;
2926
3034
  const requests = data.requests;
2927
3035
  const exits = data.exits;
@@ -3020,7 +3128,7 @@ function DkApp() {
3020
3128
  { id: "network", icon: "network", label: T.network },
3021
3129
  { id: "profile", icon: "userRound", label: T.profile }
3022
3130
  ];
3023
- return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("svg", { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "var(--accent)", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", style: { display: "block", flexShrink: 0 } }, /* @__PURE__ */ React.createElement("path", { d: "m4.5 17 6-6-6-6" }), /* @__PURE__ */ React.createElement("path", { d: "M12 18.5h7.5" })), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "beagle"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", nav.find((n) => n.id === tab).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId) }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit })), /* @__PURE__ */ React.createElement(TweaksPanel, null, /* @__PURE__ */ React.createElement(TweakSection, { label: t.lang === "zh" ? "\u5916\u89C2" : "Appearance" }), /* @__PURE__ */ React.createElement(
3131
+ return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("svg", { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "var(--accent)", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", style: { display: "block", flexShrink: 0 } }, /* @__PURE__ */ React.createElement("path", { d: "m4.5 17 6-6-6-6" }), /* @__PURE__ */ React.createElement("path", { d: "M12 18.5h7.5" })), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "beagle"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", nav.find((n) => n.id === tab).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), prefillAddr: pendingAddr, onPrefillConsumed: () => setPendingAddr("") }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit })), /* @__PURE__ */ React.createElement(TweaksPanel, null, /* @__PURE__ */ React.createElement(TweakSection, { label: t.lang === "zh" ? "\u5916\u89C2" : "Appearance" }), /* @__PURE__ */ React.createElement(
3024
3132
  TweakRadio,
3025
3133
  {
3026
3134
  label: t.lang === "zh" ? "\u4E3B\u9898" : "Theme",
package/dist/ui/server.js CHANGED
@@ -35,6 +35,74 @@ function isLocalRequest(req) {
35
35
  const a = req.socket.remoteAddress ?? "";
36
36
  return a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
37
37
  }
38
+ /**
39
+ * Reveal a file in the OS file manager (Finder / Explorer / Nautilus), so the
40
+ * user can play, share, AirDrop, copy or delete it with native tools instead
41
+ * of re-downloading — the file is already on disk under downloadsDir.
42
+ *
43
+ * Two things make this fiddly:
44
+ * - The daemon usually runs as ROOT (started via sudo). A GUI command run as
45
+ * root either opens in root's own (invisible) session or is refused. On
46
+ * macOS the fix is `launchctl asuser <uid>` to hop into the logged-in
47
+ * user's Aqua session; elsewhere we drop to `SUDO_USER` with `sudo -u`.
48
+ * - WSL has no Linux file manager but can drive Windows Explorer via the
49
+ * translated \\wsl$ path.
50
+ *
51
+ * Best-effort: returns {ok:false, error} rather than throwing so the caller
52
+ * can surface a message. `filePath` MUST already be validated as inside
53
+ * downloadsDir by the caller.
54
+ */
55
+ async function revealInFileManager(filePath) {
56
+ const { spawn } = await import("node:child_process");
57
+ const run = (cmd, args) => new Promise((resolve) => {
58
+ let child;
59
+ try {
60
+ child = spawn(cmd, args, { stdio: "ignore", detached: true });
61
+ }
62
+ catch (e) {
63
+ resolve({ ok: false, error: `${cmd}: ${e.message}` });
64
+ return;
65
+ }
66
+ child.on("error", (e) => resolve({ ok: false, error: `${cmd}: ${e.message}` }));
67
+ // Windows Explorer returns exit code 1 even on success, so treat spawn
68
+ // without an 'error' event as success rather than gating on the code.
69
+ child.unref();
70
+ setTimeout(() => resolve({ ok: true }), 150);
71
+ });
72
+ const asRoot = typeof process.getuid === "function" && process.getuid() === 0;
73
+ const sudoUser = process.env.SUDO_USER;
74
+ const sudoUid = process.env.SUDO_UID;
75
+ if (process.platform === "darwin") {
76
+ // Run `open -R` inside the login user's GUI session when we're root.
77
+ if (asRoot && sudoUid) {
78
+ return run("launchctl", ["asuser", sudoUid, "open", "-R", filePath]);
79
+ }
80
+ return run("open", ["-R", filePath]);
81
+ }
82
+ if (process.platform === "win32") {
83
+ return run("explorer.exe", [`/select,${filePath}`]);
84
+ }
85
+ // Linux (incl. WSL). WSL: drive Windows Explorer with the \\wsl$ UNC path.
86
+ const isWsl = existsSync("/proc/version") &&
87
+ (await import("node:fs/promises")).readFile("/proc/version", "utf-8").then((v) => v.toLowerCase().includes("microsoft"), () => false);
88
+ if (await isWsl) {
89
+ let winPath = filePath;
90
+ try {
91
+ const { execFileSync } = await import("node:child_process");
92
+ winPath = execFileSync("wslpath", ["-w", filePath], { encoding: "utf-8" }).trim() || filePath;
93
+ }
94
+ catch {
95
+ // wslpath missing — fall through with the raw path
96
+ }
97
+ return run("explorer.exe", [`/select,${winPath}`]);
98
+ }
99
+ // A headless server has no file manager; xdg-open on the parent folder is the
100
+ // best we can do where a desktop exists. Drop privileges to the login user.
101
+ const dir = filePath.slice(0, filePath.lastIndexOf("/")) || "/";
102
+ if (asRoot && sudoUser)
103
+ return run("sudo", ["-u", sudoUser, "xdg-open", dir]);
104
+ return run("xdg-open", [dir]);
105
+ }
38
106
  /** Validate and normalize a website origin (scheme://host[:port], no path).
39
107
  * Returns the canonical origin string or null if malformed. Only http/https
40
108
  * are accepted. */
@@ -400,6 +468,31 @@ export function startFriendUi(opts) {
400
468
  // download (?dl=1 → attachment) and inline preview/playback (no dl → the
401
469
  // right media content-type + inline disposition + HTTP Range so <video>/
402
470
  // <audio> can seek without buffering the whole file).
471
+ // Reveal a received file in the OS file manager. LOCALHOST-ONLY: this
472
+ // opens Finder/Explorer on the machine the DAEMON runs on, so it's
473
+ // meaningful only for someone sitting at that machine — never honour it
474
+ // for a remotely-opened UI (which could otherwise pop a file manager on
475
+ // the host at a stranger's request).
476
+ if (req.method === "POST" && url === "/api/file-reveal") {
477
+ if (!opts.downloadsDir) {
478
+ sendJson(res, 404, { ok: false, error: "downloads disabled" });
479
+ return;
480
+ }
481
+ if (!isLocalRequest(req)) {
482
+ sendJson(res, 403, { ok: false, error: "reveal is available only on this machine" });
483
+ return;
484
+ }
485
+ const body = await readBody(req);
486
+ const name = (typeof body.name === "string" ? body.name : "").replace(/[/\\]/g, "");
487
+ const file = join(opts.downloadsDir, name);
488
+ if (!name || !file.startsWith(opts.downloadsDir) || !existsSync(file)) {
489
+ sendJson(res, 404, { ok: false, error: "not found" });
490
+ return;
491
+ }
492
+ const r = await revealInFileManager(file);
493
+ sendJson(res, r.ok ? 200 : 500, r);
494
+ return;
495
+ }
403
496
  if (req.method === "GET" && url === "/api/file-download") {
404
497
  if (!opts.downloadsDir) {
405
498
  res.writeHead(404);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.233",
3
+ "version": "0.1.234",
4
4
  "description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",