@decentnetwork/lan 0.1.219 → 0.1.221

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.
@@ -90,6 +90,8 @@ export declare class PeerManager extends EventEmitter {
90
90
  isNativeFriend(userid: string): boolean;
91
91
  /** Accept an incoming file offer. */
92
92
  acceptFile(userid: string, fileNumber: number): void;
93
+ /** Reject/cancel an incoming file offer before accepting it. */
94
+ cancelFile(userid: string, fileNumber: number): void;
93
95
  /** Cancel an in-flight send by content fileId. Returns true if it was found. */
94
96
  cancelSend(userid: string, fileId: string): boolean;
95
97
  /** Cancel in-flight sends matching size/name when activeSends lost the fileId. */
@@ -205,6 +205,10 @@ export class PeerManager extends EventEmitter {
205
205
  acceptFile(userid, fileNumber) {
206
206
  this.peer?.acceptFile(userid, fileNumber);
207
207
  }
208
+ /** Reject/cancel an incoming file offer before accepting it. */
209
+ cancelFile(userid, fileNumber) {
210
+ this.peer?.cancelFile(userid, fileNumber, false);
211
+ }
208
212
  /** Cancel an in-flight send by content fileId. Returns true if it was found. */
209
213
  cancelSend(userid, fileId) {
210
214
  return this.peer?.cancelFileById(userid, fileId, true) ?? false;
@@ -215,6 +215,15 @@ export declare function cmdProxyWhitelist(args: {
215
215
  userid?: string;
216
216
  configDir?: string;
217
217
  }): Promise<void>;
218
+ /**
219
+ * Exit bandwidth guard for Messenger file receiving. Default is OFF: friends can
220
+ * send files normally. When ON, this node only accepts incoming file offers from
221
+ * peers present in the existing proxy whitelist. Applies live to the daemon.
222
+ */
223
+ export declare function cmdProxyFileWhitelist(args: {
224
+ mode: "on" | "off" | "status";
225
+ configDir?: string;
226
+ }): Promise<void>;
218
227
  /**
219
228
  * Set (or clear, with 0) the exit's busy threshold in Mbps. Above this smoothed
220
229
  * egress rate the exit serves only whitelisted peers; 0 = free for all.
@@ -1004,6 +1004,9 @@ export async function cmdProxyWhitelist(args) {
1004
1004
  console.log(busy > 0
1005
1005
  ? `Busy threshold: ${busy}Mbps (above this, only whitelisted peers get new tunnels).`
1006
1006
  : `Busy threshold: OFF — exit is free for all. Set one with 'agentnet proxy busy-mbps <N>'.`);
1007
+ console.log(proxy.fileWhitelistOnly === true
1008
+ ? `File receive guard: ON — incoming files are accepted only from this whitelist.`
1009
+ : `File receive guard: OFF — friend file receiving is open. Enable with 'agentnet proxy file-whitelist on'.`);
1007
1010
  return;
1008
1011
  }
1009
1012
  if (!args.userid) {
@@ -1025,6 +1028,35 @@ export async function cmdProxyWhitelist(args) {
1025
1028
  await ConfigLoader.save(config, configPath);
1026
1029
  await applyProxyReloadIfRunning(config);
1027
1030
  }
1031
+ /**
1032
+ * Exit bandwidth guard for Messenger file receiving. Default is OFF: friends can
1033
+ * send files normally. When ON, this node only accepts incoming file offers from
1034
+ * peers present in the existing proxy whitelist. Applies live to the daemon.
1035
+ */
1036
+ export async function cmdProxyFileWhitelist(args) {
1037
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
1038
+ const configPath = resolve(dir, "config.yaml");
1039
+ const config = await ConfigLoader.load(configPath);
1040
+ const proxy = config.proxy ?? { enabled: false, port: 8888 };
1041
+ const whitelist = proxy.whitelist ?? [];
1042
+ const enabled = proxy.fileWhitelistOnly === true;
1043
+ if (args.mode === "status") {
1044
+ console.log(enabled
1045
+ ? `File receive guard: ON — incoming files are accepted only from proxy.whitelist (${whitelist.length} entr${whitelist.length === 1 ? "y" : "ies"}).`
1046
+ : `File receive guard: OFF — friend file receiving is open.`);
1047
+ return;
1048
+ }
1049
+ const next = args.mode === "on";
1050
+ config.proxy = { ...proxy, fileWhitelistOnly: next };
1051
+ await ConfigLoader.save(config, configPath);
1052
+ console.log(next
1053
+ ? `File receive guard ON — incoming files from friends outside proxy.whitelist will be rejected.`
1054
+ : `File receive guard OFF — friends can send files normally.`);
1055
+ if (next && whitelist.length === 0) {
1056
+ console.log(`Warning: proxy.whitelist is empty, so all incoming file offers will be rejected until you add peers.`);
1057
+ }
1058
+ await applyProxyReloadIfRunning(config);
1059
+ }
1028
1060
  /**
1029
1061
  * Set (or clear, with 0) the exit's busy threshold in Mbps. Above this smoothed
1030
1062
  * egress rate the exit serves only whitelisted peers; 0 = free for all.
package/dist/cli/index.js CHANGED
@@ -10,7 +10,7 @@ import { hideBin } from "yargs/helpers";
10
10
  // Belt-and-braces — also raise it here in case the CLI is run directly
11
11
  // (e.g. `node dist/cli/index.js` rather than via dist/index.js).
12
12
  EventEmitter.defaultMaxListeners = 100;
13
- import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsAutoAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyWho, cmdProxyAccess, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyWhitelist, cmdProxyBusyMbps, cmdProxyUse, cmdProxyRouter, cmdProxyTrustCa, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdConsole, cmdFileSend, cmdChatSend, cmdChatHistory, cmdFriendRemove, cmdFriendAlias, } from "./commands.js";
13
+ import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsAutoAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyWho, cmdProxyAccess, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyWhitelist, cmdProxyFileWhitelist, cmdProxyBusyMbps, cmdProxyUse, cmdProxyRouter, cmdProxyTrustCa, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdConsole, cmdFileSend, cmdChatSend, cmdChatHistory, cmdFriendRemove, cmdFriendAlias, } from "./commands.js";
14
14
  async function main() {
15
15
  await yargs(hideBin(process.argv))
16
16
  .scriptName("agentnet")
@@ -327,6 +327,15 @@ async function main() {
327
327
  return;
328
328
  }
329
329
  await cmdProxyWhitelist({ action: argv.action, userid: argv.userid, configDir: argv["config-dir"] });
330
+ })
331
+ .command("file-whitelist <mode>", "Incoming files: accept only from proxy whitelist when on (on|off|status)", (yy) => yy
332
+ .positional("mode", { type: "string", demandOption: true })
333
+ .option("config-dir", { type: "string" }), async (argv) => {
334
+ if (argv.mode !== "on" && argv.mode !== "off" && argv.mode !== "status") {
335
+ console.log("Usage: agentnet proxy file-whitelist <on|off|status>");
336
+ return;
337
+ }
338
+ await cmdProxyFileWhitelist({ mode: argv.mode, configDir: argv["config-dir"] });
330
339
  })
331
340
  .command("busy-mbps <mbps>", "Set the exit's busy threshold (Mbps); above it, only whitelisted peers get new tunnels. 0 = free for all", (yy) => yy
332
341
  .positional("mbps", { type: "number", demandOption: true })
@@ -82,6 +82,7 @@ export declare class DaemonServer {
82
82
  * so a helper that keeps dying immediately doesn't tight-loop. */
83
83
  private attachTunRecovery;
84
84
  private rebuildTun;
85
+ private isFileSenderWhitelisted;
85
86
  start(): Promise<void>;
86
87
  stop(): Promise<void>;
87
88
  /**
@@ -207,6 +207,20 @@ export class DaemonServer {
207
207
  }
208
208
  this.tunRebuildInProgress = false;
209
209
  }
210
+ isFileSenderWhitelisted(userid) {
211
+ const proxy = this.config.proxy;
212
+ if (!proxy?.fileWhitelistOnly)
213
+ return true;
214
+ const whitelist = new Set((proxy.whitelist ?? []).map((s) => String(s).trim()).filter(Boolean));
215
+ if (whitelist.size === 0)
216
+ return false;
217
+ if (whitelist.has(userid))
218
+ return true;
219
+ const rec = this.ipam?.resolveCarrierId(userid);
220
+ if (!rec)
221
+ return false;
222
+ return whitelist.has(rec.virtualIp) || whitelist.has(rec.name) || whitelist.has(rec.carrierId);
223
+ }
210
224
  async start() {
211
225
  if (this.isRunning) {
212
226
  throw new Error("Daemon already running");
@@ -704,6 +718,7 @@ export class DaemonServer {
704
718
  // and `proxy busy-mbps` apply live without a restart.
705
719
  const whitelist = fresh.proxy?.whitelist ?? [];
706
720
  const busyMbps = fresh.proxy?.busyMbps;
721
+ const fileWhitelistOnly = fresh.proxy?.fileWhitelistOnly === true;
707
722
  this.connectProxy.updateAdmission({ whitelist, busyMbps });
708
723
  // Keep the in-memory config in sync so a later diag reflects it.
709
724
  if (this.config.proxy) {
@@ -712,8 +727,9 @@ export class DaemonServer {
712
727
  this.config.proxy.allowConnectPorts = allowConnectPorts;
713
728
  this.config.proxy.whitelist = whitelist;
714
729
  this.config.proxy.busyMbps = busyMbps;
730
+ this.config.proxy.fileWhitelistOnly = fileWhitelistOnly;
715
731
  }
716
- return { applied: true, allowHosts, whitelist, busyMbps };
732
+ return { applied: true, allowHosts, whitelist, busyMbps, fileWhitelistOnly };
717
733
  },
718
734
  proxyAccess: async (userid, allow) => {
719
735
  // Live block/allow a peer's access to THIS node's exit proxy port.
@@ -1014,6 +1030,13 @@ export class DaemonServer {
1014
1030
  // File transfer (toxcore-standard): auto-accept offers from friends and
1015
1031
  // save completed files under <configDir>/downloads/.
1016
1032
  this.peerManager.on("file-offer", (o) => {
1033
+ if (!this.isFileSenderWhitelisted(o.friendId)) {
1034
+ const rec = this.ipam?.resolveCarrierId(o.friendId);
1035
+ const who = rec ? `${rec.name}/${rec.virtualIp}` : o.friendId.slice(0, 8);
1036
+ this.logger.warn(`Incoming file "${o.name}" (${o.size}B) from ${who} rejected — proxy.fileWhitelistOnly=true and sender is not in proxy.whitelist`);
1037
+ this.peerManager?.cancelFile(o.friendId, o.fileNumber);
1038
+ return;
1039
+ }
1017
1040
  this.logger.info(`Incoming file "${o.name}" (${o.size}B) from ${o.friendId.slice(0, 8)} — accepting`);
1018
1041
  this.peerManager?.acceptFile(o.friendId, o.fileNumber);
1019
1042
  });
package/dist/types.d.ts CHANGED
@@ -141,6 +141,7 @@ export interface ProxyConfig {
141
141
  egress?: string;
142
142
  busyMbps?: number;
143
143
  whitelist?: string[];
144
+ fileWhitelistOnly?: boolean;
144
145
  }
145
146
  export interface FriendsConfig {
146
147
  /** When true (default), the running daemon auto-accepts incoming
@@ -1365,10 +1365,11 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, selMode, sele
1365
1365
  ), mine && m.status === "queued" ? /* @__PURE__ */ React.createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 2 }, title: "waiting for peer \u2014 will send when they're online" }, /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 11, stroke: 2.2, color: "var(--faint)" }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, T.queued || "queued")) : mine && m.status && /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 12, stroke: 2.2, color: m.status === "read" ? "var(--accent)" : "var(--faint)" })))
1366
1366
  );
1367
1367
  }
1368
- function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread }) {
1368
+ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread }) {
1369
1369
  const scrollRef = React.useRef(null);
1370
1370
  const followScrollRef = React.useRef(true);
1371
1371
  const fileRef = React.useRef(null);
1372
+ const rtcFileRef = React.useRef(null);
1372
1373
  const [menu, setMenu] = React.useState(false);
1373
1374
  const [selMode, setSelMode] = React.useState(false);
1374
1375
  const [sel, setSel] = React.useState(() => /* @__PURE__ */ new Set());
@@ -1541,12 +1542,13 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1541
1542
  requestAnimationFrame(scrollToBottom);
1542
1543
  }
1543
1544
  };
1544
- const sendFiles = (files) => {
1545
- if (!files || !files.length || !onSendFile) return;
1545
+ const sendFiles = (files, mode) => {
1546
+ const sender = mode === "webrtc" ? onSendRtcFile : onSendFile;
1547
+ if (!files || !files.length || !sender) return;
1546
1548
  const file = files[0];
1547
1549
  followScrollRef.current = true;
1548
- setSending({ name: file.name });
1549
- Promise.resolve(onSendFile(file)).then((r) => {
1550
+ setSending({ name: file.name, via: mode });
1551
+ Promise.resolve(sender(file)).then((r) => {
1550
1552
  if (r && r.ok === false) window.alert((lang === "zh" ? "\u53D1\u9001\u5931\u8D25: " : "Send failed: ") + (r.error || ""));
1551
1553
  }).finally(() => {
1552
1554
  setSending(null);
@@ -1572,7 +1574,18 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1572
1574
  onDrop
1573
1575
  },
1574
1576
  dragOver && /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", inset: 0, zIndex: 30, background: "rgba(91,140,255,0.10)", border: "2px dashed var(--accent)", borderRadius: 12, display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none", fontFamily: "var(--mono)", fontSize: 14, color: "var(--accent)" } }, lang === "zh" ? "\u677E\u5F00\u53D1\u9001\u6587\u4EF6" : "Drop to send file"),
1575
- /* @__PURE__ */ React.createElement("div", { style: { height: 60, flexShrink: 0, borderBottom: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 34, radius: 8 }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: peer.alias ? "var(--ui)" : "var(--mono)", fontSize: 15, fontWeight: 700, color: "var(--text)" } }, peer.alias || shortKey(peer.userId, 10, 6)), peer.agent && /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, "agent"), /* @__PURE__ */ React.createElement(RouteTag, { peer })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 2 } }, /* @__PURE__ */ React.createElement(Mono, { size: 11.5, dim: true, copy: peer.userId, title: peer.userId }, shortKey(peer.userId, 10, 6)), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)" } }, "\xB7"), /* @__PURE__ */ React.createElement("button", { onClick: () => onOpenNet(peer), style: { background: "none", border: "none", cursor: "pointer", padding: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--accent)", display: "inline-flex", alignItems: "center", gap: 4 } }, /* @__PURE__ */ React.createElement(Icon, { name: "network", size: 12, stroke: 2 }), " ", peer.ip))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), onCall && /* @__PURE__ */ React.createElement(Btn, { icon: "phone", title: T && T.audioCall || "Audio call", onClick: () => onCall(peer.userId, false) }), onCall && /* @__PURE__ */ React.createElement(Btn, { icon: "video", title: T && T.videoCall || "Video call", onClick: () => onCall(peer.userId, true) }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(Btn, { icon: "more", onClick: () => setMenu((v) => !v) }), menu && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { onClick: () => setMenu(false), style: { position: "fixed", inset: 0, zIndex: 40 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", right: 0, top: 36, zIndex: 50, width: 180, background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 9, padding: 6, boxShadow: "0 14px 40px rgba(0,0,0,0.4)" } }, /* @__PURE__ */ React.createElement(MenuItem, { icon: "hash", label: T.alias, onClick: () => {
1577
+ /* @__PURE__ */ React.createElement("div", { style: { height: 60, flexShrink: 0, borderBottom: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 34, radius: 8 }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: peer.alias ? "var(--ui)" : "var(--mono)", fontSize: 15, fontWeight: 700, color: "var(--text)" } }, peer.alias || shortKey(peer.userId, 10, 6)), peer.agent && /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, "agent"), /* @__PURE__ */ React.createElement(RouteTag, { peer })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 2 } }, /* @__PURE__ */ React.createElement(Mono, { size: 11.5, dim: true, copy: peer.userId, title: peer.userId }, shortKey(peer.userId, 10, 6)), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)" } }, "\xB7"), /* @__PURE__ */ React.createElement("button", { onClick: () => onOpenNet(peer), style: { background: "none", border: "none", cursor: "pointer", padding: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--accent)", display: "inline-flex", alignItems: "center", gap: 4 } }, /* @__PURE__ */ React.createElement(Icon, { name: "network", size: 12, stroke: 2 }), " ", peer.ip))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), onCall && /* @__PURE__ */ React.createElement(Btn, { icon: "phone", title: T && T.audioCall || "Audio call", onClick: () => onCall(peer.userId, false) }), onCall && /* @__PURE__ */ React.createElement(Btn, { icon: "video", title: T && T.videoCall || "Video call", onClick: () => onCall(peer.userId, true) }), onSendRtcFile && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
1578
+ "input",
1579
+ {
1580
+ ref: rtcFileRef,
1581
+ type: "file",
1582
+ style: { display: "none" },
1583
+ onChange: (e) => {
1584
+ sendFiles(e.target.files, "webrtc");
1585
+ e.target.value = "";
1586
+ }
1587
+ }
1588
+ ), /* @__PURE__ */ React.createElement(Btn, { icon: "file", title: T && T.sendWebrtcFile || "Send file via WebRTC", onClick: () => rtcFileRef.current && rtcFileRef.current.click() })), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(Btn, { icon: "more", onClick: () => setMenu((v) => !v) }), menu && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { onClick: () => setMenu(false), style: { position: "fixed", inset: 0, zIndex: 40 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", right: 0, top: 36, zIndex: 50, width: 180, background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 9, padding: 6, boxShadow: "0 14px 40px rgba(0,0,0,0.4)" } }, /* @__PURE__ */ React.createElement(MenuItem, { icon: "hash", label: T.alias, onClick: () => {
1576
1589
  setMenu(false);
1577
1590
  onAlias(peer);
1578
1591
  } }), /* @__PURE__ */ React.createElement(MenuItem, { icon: "check", label: T.selectDelete || "Select & delete", onClick: () => {
@@ -1620,7 +1633,7 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1620
1633
  stroke: 2.2,
1621
1634
  color: flash.kind === "err" ? "var(--danger)" : "var(--accent)"
1622
1635
  }
1623
- ), flash.msg), sending && /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto 8px", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--faint)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "paperclip", size: 13, stroke: 2, color: "var(--accent)" }), (lang === "zh" ? "\u53D1\u9001\u4E2D: " : "Sending: ") + sending.name), /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement(
1636
+ ), flash.msg), sending && /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto 8px", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--faint)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "paperclip", size: 13, stroke: 2, color: "var(--accent)" }), (lang === "zh" ? "\u53D1\u9001\u4E2D: " : "Sending: ") + (sending.via === "webrtc" ? "WebRTC \xB7 " : "") + sending.name), /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement(
1624
1637
  "input",
1625
1638
  {
1626
1639
  ref: fileRef,
@@ -1715,9 +1728,9 @@ function MenuItem({ icon, label, onClick, danger }) {
1715
1728
  function ChatEmpty({ T }) {
1716
1729
  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));
1717
1730
  }
1718
- function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onSendFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread }) {
1731
+ function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread }) {
1719
1732
  const peer = peers.find((p) => p.id === activeId);
1720
- 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, onAlias, onRemove, onOpenNet, onCall, onReloadThread }) : /* @__PURE__ */ React.createElement(ChatEmpty, { T }));
1733
+ 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 }));
1721
1734
  }
1722
1735
  Object.assign(window, { ChatTab });
1723
1736
  function StatTile({ label, value, sub, tone }) {
@@ -2415,6 +2428,7 @@ const STR = {
2415
2428
  call: "Voice call",
2416
2429
  alias: "Set alias",
2417
2430
  sendFile: "Send file",
2431
+ sendWebrtcFile: "Send file via WebRTC",
2418
2432
  block: "Block peer",
2419
2433
  remove: "Remove friend",
2420
2434
  receivedFile: "Received file",
@@ -2506,6 +2520,7 @@ const STR = {
2506
2520
  call: "\u8BED\u97F3\u901A\u8BDD",
2507
2521
  alias: "\u8BBE\u7F6E\u5907\u6CE8",
2508
2522
  sendFile: "\u53D1\u9001\u6587\u4EF6",
2523
+ sendWebrtcFile: "\u901A\u8FC7 WebRTC \u53D1\u9001\u6587\u4EF6",
2509
2524
  block: "\u62C9\u9ED1",
2510
2525
  remove: "\u5220\u9664\u597D\u53CB",
2511
2526
  receivedFile: "\u6536\u5230\u6587\u4EF6",
@@ -2661,6 +2676,19 @@ function DkApp() {
2661
2676
  return r;
2662
2677
  });
2663
2678
  };
2679
+ const onSendRtcFile = (file) => {
2680
+ if (!activeId || !file) return Promise.resolve({ ok: false, error: "no active peer or file" });
2681
+ return rtcFileCtl.sendFile(activeId, file).then((r) => {
2682
+ if (r && r.ok && r.via === "webrtc") {
2683
+ return dkApi.send(activeId, (t.lang === "zh" ? "\u5DF2\u901A\u8FC7 WebRTC \u53D1\u9001\u6587\u4EF6: " : "sent file via WebRTC: ") + file.name).then(() => ({ ok: true, via: "webrtc" }));
2684
+ }
2685
+ return { ok: false, error: r && r.error || "WebRTC file send failed" };
2686
+ }).then((r) => {
2687
+ data.loadThread(activeId);
2688
+ data.refresh();
2689
+ return r;
2690
+ });
2691
+ };
2664
2692
  const onAlias = (peer) => {
2665
2693
  const a = window.prompt("Set alias for this peer (empty to clear):", peer.alias || "");
2666
2694
  if (a !== null) dkApi.alias(peer.id, a).then(data.refresh);
@@ -2678,7 +2706,7 @@ function DkApp() {
2678
2706
  { id: "network", icon: "network", label: T.network },
2679
2707
  { id: "profile", icon: "userRound", label: T.profile }
2680
2708
  ];
2681
- 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("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, 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(
2709
+ 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("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(
2682
2710
  TweakRadio,
2683
2711
  {
2684
2712
  label: t.lang === "zh" ? "\u4E3B\u9898" : "Theme",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.219",
3
+ "version": "0.1.221",
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",