@decentnetwork/lan 0.1.233 → 0.1.235
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands.js +54 -12
- package/dist/ui/desktop/app.js +118 -10
- package/dist/ui/server.js +93 -0
- package/dist/utils/log-throttle.d.ts +35 -0
- package/dist/utils/log-throttle.js +105 -0
- package/package.json +1 -1
package/dist/cli/commands.js
CHANGED
|
@@ -17,6 +17,7 @@ import yaml from "js-yaml";
|
|
|
17
17
|
import { startMultiExitRouter } from "../proxy/multi-exit-router.js";
|
|
18
18
|
import { resolveEgressBindIp } from "../proxy/egress.js";
|
|
19
19
|
import { startFriendUi } from "../ui/server.js";
|
|
20
|
+
import { installLogThrottle } from "../utils/log-throttle.js";
|
|
20
21
|
/**
|
|
21
22
|
* Refuse to open a second Carrier peer with this identity if the
|
|
22
23
|
* daemon is already running with the same keypair. Two peers sharing
|
|
@@ -381,31 +382,50 @@ export async function cmdStatus(args) {
|
|
|
381
382
|
return;
|
|
382
383
|
}
|
|
383
384
|
const config = await ConfigLoader.load(configPath);
|
|
384
|
-
let
|
|
385
|
+
let diag = null;
|
|
385
386
|
try {
|
|
386
387
|
const res = await ipcCall(config, { op: "diag" }, 5_000);
|
|
387
388
|
if (res.ok)
|
|
388
|
-
|
|
389
|
+
diag = res.data ?? null;
|
|
389
390
|
}
|
|
390
391
|
catch {
|
|
391
392
|
// daemon not running — fall back to config below
|
|
392
393
|
}
|
|
393
|
-
const running = !!liveTun;
|
|
394
394
|
console.log(`Decent AgentNet`);
|
|
395
395
|
console.log(`Node: ${config.node.name}`);
|
|
396
396
|
console.log(`Namespace: ${config.node.namespace}`);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
397
|
+
if (!diag) {
|
|
398
|
+
console.log(`Daemon: not running — everything below is CONFIGURED, not live`);
|
|
399
|
+
console.log(`Subnet: ${config.network.subnet}`);
|
|
400
|
+
console.log(`Interface: ${config.network.interface}`);
|
|
401
|
+
console.log(`Virtual IP: ${config.network.ip}`);
|
|
402
|
+
console.log(`Bootstrap: ${config.carrier.bootstrapNodes.length} nodes`);
|
|
403
|
+
const ipamOffline = await Ipam.loadOrCreate(config.paths.ipamFile, config.node.namespace);
|
|
404
|
+
console.log(`Peers (on disk): ${ipamOffline.getPeers().length}`);
|
|
405
|
+
const policyOffline = await Policy.loadOrCreate(config.paths.policyFile);
|
|
406
|
+
console.log(`ACL rules: ${policyOffline.getRules().length}`);
|
|
407
|
+
console.log(`\nStart it with 'agentnet up --real-tun' (or 'agentnet service install').`);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const friends = diag.friends ?? [];
|
|
411
|
+
const online = friends.filter((f) => f.status === "online" || f.status === "connected").length;
|
|
412
|
+
const s = diag.stats ?? {};
|
|
413
|
+
console.log(`Daemon: running`);
|
|
414
|
+
if (diag.identity?.userid)
|
|
415
|
+
console.log(`Userid: ${diag.identity.userid}`);
|
|
416
|
+
if (diag.identity?.address)
|
|
417
|
+
console.log(`Address: ${diag.identity.address}`);
|
|
418
|
+
console.log(`Subnet: ${diag.tun?.subnet ?? config.network.subnet}`);
|
|
419
|
+
console.log(`Interface: ${diag.tun?.name ?? config.network.interface}`);
|
|
420
|
+
console.log(`Virtual IP: ${diag.tun?.ip ?? "(no TUN)"}`);
|
|
421
|
+
if (diag.tun?.ip && diag.tun.ip !== config.network.ip) {
|
|
402
422
|
console.log(` note: config.yaml still says ${config.network.ip} — the daemon's address wins`);
|
|
403
423
|
}
|
|
404
424
|
console.log(`Bootstrap: ${config.carrier.bootstrapNodes.length} nodes`);
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
console.log(`
|
|
408
|
-
|
|
425
|
+
console.log(`Peers (roster): ${diag.ipam?.length ?? 0}`);
|
|
426
|
+
console.log(`Friends: ${friends.length} (${online} online)`);
|
|
427
|
+
console.log(`Sessions: ${s.activeSessions ?? 0} active`);
|
|
428
|
+
console.log(`Traffic: ${s.packetsForwarded ?? 0} sent / ${s.packetsReceived ?? 0} received / ${s.packetsDropped ?? 0} dropped`);
|
|
409
429
|
const policy = await Policy.loadOrCreate(config.paths.policyFile);
|
|
410
430
|
console.log(`ACL rules: ${policy.getRules().length}`);
|
|
411
431
|
}
|
|
@@ -423,6 +443,12 @@ export async function cmdUp(args) {
|
|
|
423
443
|
if (args.name) {
|
|
424
444
|
config.node.name = args.name;
|
|
425
445
|
}
|
|
446
|
+
// Collapse repeated log lines before they reach stdout. A daemon that runs
|
|
447
|
+
// for weeks repeats the same message shape endlessly (a peer re-keying, an
|
|
448
|
+
// onion path failing); unthrottled that produced a 12 GB log file, and a
|
|
449
|
+
// full disk stops the daemon outright. Covers the SDK's [peer-debug] output
|
|
450
|
+
// too, since this wraps the process console rather than our Logger.
|
|
451
|
+
installLogThrottle();
|
|
426
452
|
const daemon = new DaemonServer({
|
|
427
453
|
config,
|
|
428
454
|
configDir: dir,
|
|
@@ -2371,6 +2397,22 @@ WantedBy=multi-user.target
|
|
|
2371
2397
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2372
2398
|
throw new Error(`Could not write ${plistPath} (need root? try 'sudo'): ${msg}`);
|
|
2373
2399
|
}
|
|
2400
|
+
// launchd never rotates StandardOutPath — the file grows until the disk is
|
|
2401
|
+
// full and the daemon dies with it (a 12 GB agentnet log is how we found
|
|
2402
|
+
// this). systemd users get journald rotation for free; on macOS the native
|
|
2403
|
+
// equivalent is a newsyslog rule, so install one alongside the plist:
|
|
2404
|
+
// rotate at 10 MB, keep 5 compressed generations.
|
|
2405
|
+
try {
|
|
2406
|
+
const fs = await import("fs/promises");
|
|
2407
|
+
await fs.mkdir("/etc/newsyslog.d", { recursive: true });
|
|
2408
|
+
await fs.writeFile("/etc/newsyslog.d/com.decentlan.agentnet.conf", "# logfilename [owner:group] mode count size(KB) when flags\n" +
|
|
2409
|
+
"/var/log/agentnet.log root:wheel 644 5 10240 * GZ\n", "utf-8");
|
|
2410
|
+
console.log("Log rotation: /var/log/agentnet.log rotates at 10 MB, 5 compressed generations kept.");
|
|
2411
|
+
}
|
|
2412
|
+
catch (err) {
|
|
2413
|
+
// Not fatal — the daemon's own log throttling already bounds the volume.
|
|
2414
|
+
console.log(`Note: could not install the newsyslog rotation rule (${err instanceof Error ? err.message : err}). The log is still throttled in-process.`);
|
|
2415
|
+
}
|
|
2374
2416
|
execSync(`launchctl load ${plistPath}`);
|
|
2375
2417
|
console.log(`Installed ${plistPath} and started com.decentlan.agentnet.`);
|
|
2376
2418
|
console.log(`Optional — watch China video (CCTV etc.): agentnet proxy trust-ca && agentnet proxy router --hls-accel (docs/CCTV-VIEWING.md)`);
|
package/dist/ui/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
1
|
+
window.__DK_UI_VERSION="0.1.235";
|
|
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: {
|
|
1171
|
-
|
|
1172
|
-
|
|
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),
|
|
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);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bound the daemon's log volume by collapsing repeated lines.
|
|
3
|
+
*
|
|
4
|
+
* A long-running daemon emits the same message shape over and over — a peer
|
|
5
|
+
* that keeps re-keying, an onion path that keeps failing, a node blacklisted
|
|
6
|
+
* on a loop. Left alone that filled a 12 GB log file. The value of the 30,000th
|
|
7
|
+
* copy of a line is zero, but the disk cost is not, and on a full disk the
|
|
8
|
+
* daemon stops working entirely.
|
|
9
|
+
*
|
|
10
|
+
* This wraps the process's console so EVERY writer is covered, including the
|
|
11
|
+
* `[peer-debug]` output from `@decentnetwork/peer` (which logs via console and
|
|
12
|
+
* so can't be reached by our own Logger class). Messages are bucketed by
|
|
13
|
+
* "shape" — ids, addresses and numbers stripped — so a line that differs only
|
|
14
|
+
* by which peer or port it names still collapses into one bucket.
|
|
15
|
+
*
|
|
16
|
+
* Behaviour per shape, per window: print the first BURST copies verbatim, then
|
|
17
|
+
* go quiet; when the window closes, print a single "(… ×N suppressed)" line so
|
|
18
|
+
* the volume is still visible. Nothing is dropped silently.
|
|
19
|
+
*/
|
|
20
|
+
export interface LogThrottleOptions {
|
|
21
|
+
/** Copies of one shape printed verbatim before suppression. */
|
|
22
|
+
burst?: number;
|
|
23
|
+
/** Window length; the counter and suppression reset after this. */
|
|
24
|
+
windowMs?: number;
|
|
25
|
+
/** Distinct shapes tracked at once (guards the map itself from a leak). */
|
|
26
|
+
maxShapes?: number;
|
|
27
|
+
}
|
|
28
|
+
/** Collapse a message to its shape: strip ids, IPs, numbers and durations so
|
|
29
|
+
* the same event about different peers shares one bucket. */
|
|
30
|
+
export declare function logShape(message: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Install the throttle on `console`. Returns a restore function (used by tests;
|
|
33
|
+
* the daemon never uninstalls).
|
|
34
|
+
*/
|
|
35
|
+
export declare function installLogThrottle(opts?: LogThrottleOptions): () => void;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bound the daemon's log volume by collapsing repeated lines.
|
|
3
|
+
*
|
|
4
|
+
* A long-running daemon emits the same message shape over and over — a peer
|
|
5
|
+
* that keeps re-keying, an onion path that keeps failing, a node blacklisted
|
|
6
|
+
* on a loop. Left alone that filled a 12 GB log file. The value of the 30,000th
|
|
7
|
+
* copy of a line is zero, but the disk cost is not, and on a full disk the
|
|
8
|
+
* daemon stops working entirely.
|
|
9
|
+
*
|
|
10
|
+
* This wraps the process's console so EVERY writer is covered, including the
|
|
11
|
+
* `[peer-debug]` output from `@decentnetwork/peer` (which logs via console and
|
|
12
|
+
* so can't be reached by our own Logger class). Messages are bucketed by
|
|
13
|
+
* "shape" — ids, addresses and numbers stripped — so a line that differs only
|
|
14
|
+
* by which peer or port it names still collapses into one bucket.
|
|
15
|
+
*
|
|
16
|
+
* Behaviour per shape, per window: print the first BURST copies verbatim, then
|
|
17
|
+
* go quiet; when the window closes, print a single "(… ×N suppressed)" line so
|
|
18
|
+
* the volume is still visible. Nothing is dropped silently.
|
|
19
|
+
*/
|
|
20
|
+
/** Collapse a message to its shape: strip ids, IPs, numbers and durations so
|
|
21
|
+
* the same event about different peers shares one bucket. */
|
|
22
|
+
export function logShape(message) {
|
|
23
|
+
return message
|
|
24
|
+
.replace(/\b[0-9a-fA-F]{16,}\b/g, "<hex>")
|
|
25
|
+
.replace(/\b[1-9A-HJ-NP-Za-km-z]{40,}\b/g, "<id>") // base58 userid/address
|
|
26
|
+
.replace(/\b\d+\.\d+\.\d+\.\d+\b/g, "<ip>")
|
|
27
|
+
// No word boundaries: counters are routinely glued to a unit ("60s",
|
|
28
|
+
// "250ms", "step2"). Requiring \b left those unnormalized, so every
|
|
29
|
+
// distinct duration became its own bucket and nothing collapsed.
|
|
30
|
+
.replace(/\d+(\.\d+)?/g, "<n>")
|
|
31
|
+
.slice(0, 200);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Install the throttle on `console`. Returns a restore function (used by tests;
|
|
35
|
+
* the daemon never uninstalls).
|
|
36
|
+
*/
|
|
37
|
+
export function installLogThrottle(opts = {}) {
|
|
38
|
+
const burst = opts.burst ?? 5;
|
|
39
|
+
const windowMs = opts.windowMs ?? 60_000;
|
|
40
|
+
const maxShapes = opts.maxShapes ?? 500;
|
|
41
|
+
const shapes = new Map();
|
|
42
|
+
const original = {
|
|
43
|
+
log: console.log.bind(console),
|
|
44
|
+
debug: console.debug.bind(console),
|
|
45
|
+
info: console.info.bind(console),
|
|
46
|
+
warn: console.warn.bind(console),
|
|
47
|
+
error: console.error.bind(console),
|
|
48
|
+
};
|
|
49
|
+
const wrap = (emit) => (message, ...args) => {
|
|
50
|
+
// Only throttle plain string messages; structured/objects pass through.
|
|
51
|
+
if (typeof message !== "string") {
|
|
52
|
+
emit(message, ...args);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const key = logShape(message);
|
|
57
|
+
let st = shapes.get(key);
|
|
58
|
+
if (!st) {
|
|
59
|
+
// Bound the tracking map. Evicting the oldest window is enough: a
|
|
60
|
+
// genuinely hot shape re-registers on its next line.
|
|
61
|
+
if (shapes.size >= maxShapes) {
|
|
62
|
+
let oldestKey;
|
|
63
|
+
let oldest = Infinity;
|
|
64
|
+
for (const [k, v] of shapes) {
|
|
65
|
+
if (v.windowStart < oldest) {
|
|
66
|
+
oldest = v.windowStart;
|
|
67
|
+
oldestKey = k;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (oldestKey)
|
|
71
|
+
shapes.delete(oldestKey);
|
|
72
|
+
}
|
|
73
|
+
st = { count: 0, windowStart: now, suppressed: 0, sample: message };
|
|
74
|
+
shapes.set(key, st);
|
|
75
|
+
}
|
|
76
|
+
if (now - st.windowStart >= windowMs) {
|
|
77
|
+
// Window closed — report what we swallowed, then start fresh.
|
|
78
|
+
if (st.suppressed > 0) {
|
|
79
|
+
emit(`… ${st.suppressed} more like this suppressed in the last ${Math.round(windowMs / 1000)}s: ${st.sample.slice(0, 160)}`);
|
|
80
|
+
}
|
|
81
|
+
st.count = 0;
|
|
82
|
+
st.suppressed = 0;
|
|
83
|
+
st.windowStart = now;
|
|
84
|
+
}
|
|
85
|
+
st.count++;
|
|
86
|
+
if (st.count <= burst) {
|
|
87
|
+
emit(message, ...args);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
st.suppressed++;
|
|
91
|
+
};
|
|
92
|
+
console.log = wrap(original.log);
|
|
93
|
+
console.debug = wrap(original.debug);
|
|
94
|
+
console.info = wrap(original.info);
|
|
95
|
+
console.warn = wrap(original.warn);
|
|
96
|
+
// Errors are rare and each one matters — never suppress them.
|
|
97
|
+
console.error = original.error;
|
|
98
|
+
return () => {
|
|
99
|
+
console.log = original.log;
|
|
100
|
+
console.debug = original.debug;
|
|
101
|
+
console.info = original.info;
|
|
102
|
+
console.warn = original.warn;
|
|
103
|
+
console.error = original.error;
|
|
104
|
+
};
|
|
105
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.235",
|
|
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",
|