@decentnetwork/lan 0.1.227 → 0.1.229

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.
@@ -44,6 +44,9 @@ export interface IpcHandlers {
44
44
  /** Append a local-only chat history entry. Used for browser-local events such
45
45
  * as WebRTC file receive; does not transmit anything to the peer. */
46
46
  chatLogLocal: (userid: string, dir: "in" | "out", text: string) => Promise<void>;
47
+ /** Append a local-only file history entry after the UI server has saved a
48
+ * browser-received WebRTC file into downloads/. */
49
+ fileLogLocal: (userid: string, dir: "in" | "out", name: string, size: number) => Promise<void>;
47
50
  /** Return persisted chat history. With `peer`, returns just that thread and
48
51
  * honours `before`/`limit` pagination; without, every thread (legacy shape). */
49
52
  chatHistory: (peer?: string, before?: number, limit?: number) => Promise<Record<string, unknown>>;
@@ -118,7 +121,7 @@ export interface IpcHandlers {
118
121
  selfRestart: () => Promise<Record<string, unknown>>;
119
122
  }
120
123
  export interface IpcRequest {
121
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-log-local" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "file-delete" | "file-cancel" | "file-retry" | "chat-mark-read" | "subscribe" | "sign" | "call-signal" | "call-poll" | "proxy-reload" | "proxy-access" | "self-restart";
124
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-log-local" | "file-log-local" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "file-delete" | "file-cancel" | "file-retry" | "chat-mark-read" | "subscribe" | "sign" | "call-signal" | "call-poll" | "proxy-reload" | "proxy-access" | "self-restart";
122
125
  address?: string;
123
126
  hello?: string;
124
127
  userid?: string;
@@ -127,6 +130,7 @@ export interface IpcRequest {
127
130
  alias?: string;
128
131
  name?: string;
129
132
  description?: string;
133
+ size?: number;
130
134
  path?: string;
131
135
  before?: number;
132
136
  limit?: number;
@@ -202,6 +202,14 @@ export class IpcServer {
202
202
  await this.handlers.chatLogLocal(req.userid, req.dir === "out" ? "out" : "in", req.text ?? "");
203
203
  return;
204
204
  }
205
+ case "file-log-local": {
206
+ if (!req.userid)
207
+ throw new Error("userid is required");
208
+ if (!req.name)
209
+ throw new Error("name is required");
210
+ await this.handlers.fileLogLocal(req.userid, req.dir === "out" ? "out" : "in", req.name, Math.max(0, req.size ?? 0));
211
+ return;
212
+ }
205
213
  case "chat-history":
206
214
  return await this.handlers.chatHistory(req.userid, req.before, req.limit);
207
215
  case "friends-list":
@@ -386,6 +386,13 @@ export class DaemonServer {
386
386
  return;
387
387
  this.logChat(userid, dir, text);
388
388
  },
389
+ fileLogLocal: async (userid, dir, name, size) => {
390
+ if (!name)
391
+ return;
392
+ this.messageStore?.appendFile(userid, dir, { name: sanitizeFileName(name), size });
393
+ this.friendMeta?.ensure(userid);
394
+ this.ipcEvents.emit("event", { type: "file", userid, dir, name });
395
+ },
389
396
  chatHistory: async (peer, before, limit) => {
390
397
  return { chats: this.messageStore?.history(peer, { before, limit }) ?? {} };
391
398
  },
@@ -231,6 +231,22 @@ const dkApi = {
231
231
  cancelSend: (userid, ids) => dkPost("/api/file-cancel", { userid, ids }),
232
232
  retrySend: (userid, ids) => dkPost("/api/file-retry", { userid, ids }),
233
233
  setProfile: (name, description) => dkPost("/api/set-profile", { name, description }),
234
+ saveWebrtcFile: async (userid, name, blob) => {
235
+ try {
236
+ const r = await fetch(
237
+ "/api/webrtc-file-save?userid=" + encodeURIComponent(userid) + "&name=" + encodeURIComponent(name),
238
+ { method: "POST", body: blob }
239
+ );
240
+ const txt = await r.text();
241
+ try {
242
+ return JSON.parse(txt);
243
+ } catch {
244
+ return { ok: false, error: (txt || "HTTP " + r.status).slice(0, 120) };
245
+ }
246
+ } catch (e) {
247
+ return { ok: false, error: String(e) };
248
+ }
249
+ },
234
250
  sendFile: async (userid, file) => {
235
251
  try {
236
252
  const r = await fetch(
@@ -1190,9 +1206,14 @@ function dkRtcFileFromText(peerId, text) {
1190
1206
  }
1191
1207
  return null;
1192
1208
  }
1193
- function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, selMode, selected, onToggleSel, busy }) {
1209
+ function dkCallFromText(text) {
1210
+ const m = /^WebRTC (audio|video) call: (incoming|outgoing)$/.exec(String(text || ""));
1211
+ return m ? { kind: m[1], direction: m[2] } : null;
1212
+ }
1213
+ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onCall, selMode, selected, onToggleSel, busy }) {
1194
1214
  const mine = m.from === "me";
1195
1215
  const rtcFile = !mine && !m.file ? dkRtcFileFromText(peer.userId || peer.id, m.text) : null;
1216
+ const callRec = !m.file ? dkCallFromText(m.text) : null;
1196
1217
  return /* @__PURE__ */ React.createElement(
1197
1218
  "div",
1198
1219
  {
@@ -1222,7 +1243,16 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, selMode, sele
1222
1243
  justifyContent: "center"
1223
1244
  } }, selected && /* @__PURE__ */ React.createElement(Icon, { name: "check", size: 12, stroke: 3, color: "#fff" })),
1224
1245
  !mine && /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 24, radius: 6, dot: false }),
1225
- /* @__PURE__ */ React.createElement("div", { style: { maxWidth: m.file && (m.file.media === "image" || m.file.media === "video") || rtcFile && (rtcFile.media === "image" || rtcFile.media === "video") ? "min(680px, 88%)" : "64%", display: "flex", flexDirection: "column", alignItems: mine ? "flex-end" : "flex-start" } }, rtcFile ? /* @__PURE__ */ React.createElement("div", { style: {
1246
+ /* @__PURE__ */ React.createElement("div", { style: { maxWidth: m.file && (m.file.media === "image" || m.file.media === "video") || rtcFile && (rtcFile.media === "image" || rtcFile.media === "video") ? "min(680px, 88%)" : "64%", display: "flex", flexDirection: "column", alignItems: mine ? "flex-end" : "flex-start" } }, callRec ? /* @__PURE__ */ React.createElement(
1247
+ "button",
1248
+ {
1249
+ onClick: () => onCall && onCall(peer.userId, callRec.kind === "video"),
1250
+ style: { display: "flex", alignItems: "center", gap: 10, padding: "9px 12px", borderRadius: 12, border: "1px solid var(--line)", background: mine ? "var(--bub-me)" : "var(--bub-them)", color: mine ? "#fff" : "var(--text)", cursor: "pointer", fontFamily: "var(--mono)", fontSize: 12.5 }
1251
+ },
1252
+ /* @__PURE__ */ React.createElement(Icon, { name: callRec.kind === "video" ? "video" : "phone", size: 17, stroke: 2, color: mine ? "#fff" : "var(--accent)" }),
1253
+ /* @__PURE__ */ React.createElement("span", null, callRec.direction === "incoming" ? "Incoming" : "Outgoing", " ", callRec.kind, " call"),
1254
+ /* @__PURE__ */ React.createElement("span", { style: { color: mine ? "rgba(255,255,255,0.75)" : "var(--faint)" } }, "\xB7 redial")
1255
+ ) : rtcFile ? /* @__PURE__ */ React.createElement("div", { style: {
1226
1256
  display: "flex",
1227
1257
  flexDirection: "column",
1228
1258
  gap: 6,
@@ -1603,8 +1633,14 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1603
1633
  if (!files || !files.length || !sender) return;
1604
1634
  const file = files[0];
1605
1635
  followScrollRef.current = true;
1606
- setSending({ name: file.name, via: mode, pct: 0 });
1607
- const onProgress = (p) => setSending((s) => s && s.name === file.name ? { ...s, pct: p && p.pct } : s);
1636
+ setSending({ name: file.name, via: mode, pct: 0, sent: 0, size: file.size });
1637
+ const onProgress = (p) => setSending((s) => s && s.name === file.name ? {
1638
+ ...s,
1639
+ pct: p && p.pct,
1640
+ sent: p && p.sent,
1641
+ size: p && p.size || s.size,
1642
+ kbps: p && p.kbps
1643
+ } : s);
1608
1644
  Promise.resolve(sender(file, onProgress)).then((r) => {
1609
1645
  if (r && r.ok === false) window.alert((lang === "zh" ? "\u53D1\u9001\u5931\u8D25: " : "Send failed: ") + (r.error || ""));
1610
1646
  else showFlash("ok", mode === "webrtc" ? lang === "zh" ? "WebRTC \u6587\u4EF6\u53D1\u9001\u5B8C\u6210" : "WebRTC file sent" : lang === "zh" ? "\u6587\u4EF6\u5DF2\u63D0\u4EA4\u53D1\u9001" : "File send started");
@@ -1664,6 +1700,7 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1664
1700
  onDelete: (id) => doDelete([id]),
1665
1701
  onCancel: doCancel,
1666
1702
  onRetry: doRetry,
1703
+ onCall,
1667
1704
  busy: m.id ? fileBusy[m.id] : void 0,
1668
1705
  selMode,
1669
1706
  selected: sel.has(m.id),
@@ -1691,7 +1728,7 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1691
1728
  stroke: 2.2,
1692
1729
  color: flash.kind === "err" ? "var(--danger)" : "var(--accent)"
1693
1730
  }
1694
- ), 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 + (sending.pct != null ? " \xB7 " + Math.min(100, Math.max(0, sending.pct)).toFixed(1) + "%" : "")), /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement(
1731
+ ), 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 + (sending.size ? " \xB7 " + dkFileSize(sending.sent || 0) + " / " + dkFileSize(sending.size) : "") + (sending.pct != null ? " \xB7 " + Math.min(100, Math.max(0, sending.pct)).toFixed(1) + "%" : "") + (sending.kbps != null ? " \xB7 " + dkSpeed(sending.kbps) : "")), /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement(
1695
1732
  "input",
1696
1733
  {
1697
1734
  ref: fileRef,
@@ -2008,13 +2045,13 @@ function dkRememberRtcFile(peerId, fileId, name, size, url) {
2008
2045
  window.__dkRtcFiles.set(peerId + "|" + name + "|" + size, item);
2009
2046
  return item;
2010
2047
  }
2011
- function useRtcFileController(selfId, onReceivedText) {
2048
+ function useRtcFileController(selfId, onReceivedFile) {
2012
2049
  const [incoming, setIncoming] = React.useState([]);
2013
2050
  const peersRef = React.useRef(/* @__PURE__ */ new Map());
2014
- const onReceivedTextRef = React.useRef(onReceivedText);
2051
+ const onReceivedFileRef = React.useRef(onReceivedFile);
2015
2052
  React.useEffect(() => {
2016
- onReceivedTextRef.current = onReceivedText;
2017
- }, [onReceivedText]);
2053
+ onReceivedFileRef.current = onReceivedFile;
2054
+ }, [onReceivedFile]);
2018
2055
  React.useEffect(() => {
2019
2056
  if (!selfId || !window.RTCPeerConnection || !window.dkRtcSignalBus) return;
2020
2057
  const bus = dkRtcSignalBus();
@@ -2055,7 +2092,7 @@ function useRtcFileController(selfId, onReceivedText) {
2055
2092
  const dl = dkFileDownload(blob, meta.name || "agentnet-file");
2056
2093
  dkRememberRtcFile(peerId, fileId, dl.name, blob.size, dl.url);
2057
2094
  setIncoming((arr) => [{ id: fileId, peerId, name: dl.name, size: blob.size, url: dl.url }].concat(arr).slice(0, 8));
2058
- if (onReceivedTextRef.current) onReceivedTextRef.current(peerId, "received file via WebRTC: " + dl.name + " (" + dkFileSize(blob.size) + ")");
2095
+ if (onReceivedFileRef.current) onReceivedFileRef.current(peerId, dl.name, blob);
2059
2096
  cleanup(fileId);
2060
2097
  } else if (msg.type === "cancel") {
2061
2098
  cleanup(fileId);
@@ -2176,16 +2213,36 @@ function useRtcFileController(selfId, onReceivedText) {
2176
2213
  await dkWaitForOpen(dc, DK_FILE_RTC_OPEN_TIMEOUT_MS, () => dkRtcState(pc));
2177
2214
  dc.send(JSON.stringify({ type: "meta", name: file.name, size: file.size, mime: file.type || "application/octet-stream" }));
2178
2215
  let sent = 0;
2216
+ const startedAt = Date.now();
2217
+ let lastProgressAt = 0;
2179
2218
  const highWater = 4 * 1024 * 1024;
2219
+ const reportProgress = (force) => {
2220
+ if (!onProgress) return;
2221
+ const now = Date.now();
2222
+ if (!force && now - lastProgressAt < 250 && sent < file.size) return;
2223
+ lastProgressAt = now;
2224
+ const elapsedMs = Math.max(1, now - startedAt);
2225
+ const kbps = sent / 1024 / (elapsedMs / 1e3);
2226
+ onProgress({
2227
+ sent,
2228
+ size: file.size,
2229
+ pct: file.size ? sent / file.size * 100 : 100,
2230
+ kbps,
2231
+ elapsedMs,
2232
+ via: "webrtc"
2233
+ });
2234
+ };
2235
+ reportProgress(true);
2180
2236
  while (sent < file.size) {
2181
2237
  if (dc.readyState !== "open") throw new Error("WebRTC data channel closed");
2182
2238
  const end = Math.min(file.size, sent + DK_FILE_RTC_CHUNK);
2183
2239
  const buf = await file.slice(sent, end).arrayBuffer();
2184
2240
  dc.send(buf);
2185
2241
  sent = end;
2186
- if (onProgress) onProgress({ sent, size: file.size, pct: file.size ? sent / file.size * 100 : 100 });
2242
+ reportProgress(false);
2187
2243
  await dkWaitBufferedLow(dc, highWater);
2188
2244
  }
2245
+ reportProgress(true);
2189
2246
  dc.send(JSON.stringify({ type: "done" }));
2190
2247
  await sendSignal("bye", { reason: "done" }).catch(() => {
2191
2248
  });
@@ -2224,13 +2281,17 @@ const CALL_ICE_SERVERS = [
2224
2281
  { urls: "turn:tokyo.fi.chat:3478", username: "allcom", credential: "allcompass" },
2225
2282
  { urls: "turn:tokyo.fi.chat:3478?transport=tcp", username: "allcom", credential: "allcompass" }
2226
2283
  ];
2227
- function useCallController(selfId) {
2284
+ function useCallController(selfId, onCallLog) {
2228
2285
  const [incoming, setIncoming] = React.useState(null);
2229
2286
  const [active, setActive] = React.useState(null);
2230
2287
  const [localStream, setLocalStream] = React.useState(null);
2231
2288
  const [remoteStream, setRemoteStream] = React.useState(null);
2232
2289
  const [sharing, setSharing] = React.useState(false);
2233
2290
  const engineRef = React.useRef(null);
2291
+ const onCallLogRef = React.useRef(onCallLog);
2292
+ React.useEffect(() => {
2293
+ onCallLogRef.current = onCallLog;
2294
+ }, [onCallLog]);
2234
2295
  React.useEffect(() => {
2235
2296
  if (!selfId || engineRef.current) return;
2236
2297
  if (!window.PeerWebRTC || !window.RTCPeerConnection) {
@@ -2248,7 +2309,10 @@ function useCallController(selfId) {
2248
2309
  iceServers: CALL_ICE_SERVERS,
2249
2310
  logger: (m) => console.log(m)
2250
2311
  });
2251
- engine.on("incomingCall", (info) => setIncoming(info));
2312
+ engine.on("incomingCall", (info) => {
2313
+ setIncoming(info);
2314
+ if (onCallLogRef.current) onCallLogRef.current(info.peerId, !!info.video, "incoming");
2315
+ });
2252
2316
  engine.on("stateChanged", (info, state) => setActive((a) => a && a.callId === info.callId ? { ...a, state } : a));
2253
2317
  engine.on("localStream", (id, s) => {
2254
2318
  setLocalStream(s);
@@ -2281,6 +2345,7 @@ function useCallController(selfId) {
2281
2345
  return;
2282
2346
  }
2283
2347
  setActive({ callId: null, peerId, video: !!video, direction: "outgoing", state: "ringing" });
2348
+ if (onCallLogRef.current) onCallLogRef.current(peerId, !!video, "outgoing");
2284
2349
  eng.call(peerId, { audio: true, video: !!video }).then((callId) => setActive((a) => a && a.peerId === peerId && a.callId === null ? { ...a, callId } : a)).catch((e) => {
2285
2350
  setActive((a) => a && a.peerId === peerId ? null : a);
2286
2351
  alert("Could not start call: " + (e && e.message || e));
@@ -2748,10 +2813,21 @@ function DkApp() {
2748
2813
  const T = STR[t.lang] || STR.en;
2749
2814
  const vars = dkTheme(t.theme, t.accent);
2750
2815
  const rowPad = t.density === "comfortable" ? "11px 12px" : "7px 10px";
2751
- const callCtl = useCallController(me.userId);
2816
+ const callCtl = useCallController(me.userId, (peerId, video, direction) => {
2817
+ const text = `WebRTC ${video ? "video" : "audio"} call: ${direction}`;
2818
+ dkApi.logLocal(peerId, direction === "outgoing" ? "out" : "in", text).then(() => {
2819
+ data.loadThread(peerId);
2820
+ data.refresh();
2821
+ });
2822
+ });
2752
2823
  const onCall = (peerId, video) => callCtl.start(peerId, !!video);
2753
- const rtcFileCtl = useRtcFileController(me.userId, (peerId, text) => {
2754
- dkApi.logLocal(peerId, "in", text).then(() => {
2824
+ const rtcFileCtl = useRtcFileController(me.userId, (peerId, name, blob) => {
2825
+ if (!blob) return;
2826
+ dkApi.saveWebrtcFile(peerId, name, blob).then((r) => {
2827
+ if (!r || r.ok === false) {
2828
+ return dkApi.logLocal(peerId, "in", "received file via WebRTC: " + name + " (" + dkFileSize(blob.size) + ")");
2829
+ }
2830
+ }).then(() => {
2755
2831
  data.loadThread(peerId);
2756
2832
  data.refresh();
2757
2833
  });
package/dist/ui/server.js CHANGED
@@ -346,6 +346,56 @@ export function startFriendUi(opts) {
346
346
  }
347
347
  return;
348
348
  }
349
+ // Persist a file received by browser WebRTC DataChannel. Unlike the
350
+ // ordinary daemon filetransfer path, the bytes arrive in the browser, so
351
+ // the browser uploads the completed Blob here; we save it under
352
+ // downloads/ and ask the daemon to append a normal inbound file chip.
353
+ if (req.method === "POST" && url === "/api/webrtc-file-save") {
354
+ if (!opts.downloadsDir) {
355
+ sendJson(res, 404, { ok: false, error: "downloads disabled" });
356
+ return;
357
+ }
358
+ const q = new URL(req.url || "", "http://x").searchParams;
359
+ const userid = q.get("userid") || "";
360
+ const rawName = q.get("name") || "agentnet-file";
361
+ const safe = rawName.replace(/[/\\]/g, "_").slice(0, 200) || "agentnet-file";
362
+ if (!userid) {
363
+ sendJson(res, 400, { ok: false, error: "userid required" });
364
+ return;
365
+ }
366
+ const fs = await import("node:fs");
367
+ const fsp = await import("node:fs/promises");
368
+ await fsp.mkdir(opts.downloadsDir, { recursive: true });
369
+ let finalName = safe;
370
+ let finalPath = join(opts.downloadsDir, finalName);
371
+ const dot = safe.lastIndexOf(".");
372
+ const base = dot > 0 ? safe.slice(0, dot) : safe;
373
+ const ext = dot > 0 ? safe.slice(dot) : "";
374
+ for (let i = 1; existsSync(finalPath); i++) {
375
+ finalName = `${base}-${i}${ext}`;
376
+ finalPath = join(opts.downloadsDir, finalName);
377
+ }
378
+ try {
379
+ await new Promise((resolve2, reject) => {
380
+ const ws = fs.createWriteStream(finalPath);
381
+ req.pipe(ws);
382
+ req.on("error", reject);
383
+ ws.on("error", reject);
384
+ ws.on("finish", () => resolve2());
385
+ });
386
+ const st = statSync(finalPath);
387
+ const r = await opts.call({ op: "file-log-local", userid, dir: "in", name: finalName, size: st.size });
388
+ if (!r.ok) {
389
+ sendJson(res, 502, r);
390
+ return;
391
+ }
392
+ sendJson(res, 200, { ok: true, name: finalName, size: st.size });
393
+ }
394
+ catch (e) {
395
+ sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
396
+ }
397
+ return;
398
+ }
349
399
  // Serve a received file by name, from <configDir>/downloads. Used both for
350
400
  // download (?dl=1 → attachment) and inline preview/playback (no dl → the
351
401
  // right media content-type + inline disposition + HTTP Range so <video>/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.227",
3
+ "version": "0.1.229",
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",