@decentnetwork/lan 0.1.227 → 0.1.228

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,
@@ -1664,6 +1694,7 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1664
1694
  onDelete: (id) => doDelete([id]),
1665
1695
  onCancel: doCancel,
1666
1696
  onRetry: doRetry,
1697
+ onCall,
1667
1698
  busy: m.id ? fileBusy[m.id] : void 0,
1668
1699
  selMode,
1669
1700
  selected: sel.has(m.id),
@@ -2008,13 +2039,13 @@ function dkRememberRtcFile(peerId, fileId, name, size, url) {
2008
2039
  window.__dkRtcFiles.set(peerId + "|" + name + "|" + size, item);
2009
2040
  return item;
2010
2041
  }
2011
- function useRtcFileController(selfId, onReceivedText) {
2042
+ function useRtcFileController(selfId, onReceivedFile) {
2012
2043
  const [incoming, setIncoming] = React.useState([]);
2013
2044
  const peersRef = React.useRef(/* @__PURE__ */ new Map());
2014
- const onReceivedTextRef = React.useRef(onReceivedText);
2045
+ const onReceivedFileRef = React.useRef(onReceivedFile);
2015
2046
  React.useEffect(() => {
2016
- onReceivedTextRef.current = onReceivedText;
2017
- }, [onReceivedText]);
2047
+ onReceivedFileRef.current = onReceivedFile;
2048
+ }, [onReceivedFile]);
2018
2049
  React.useEffect(() => {
2019
2050
  if (!selfId || !window.RTCPeerConnection || !window.dkRtcSignalBus) return;
2020
2051
  const bus = dkRtcSignalBus();
@@ -2055,7 +2086,7 @@ function useRtcFileController(selfId, onReceivedText) {
2055
2086
  const dl = dkFileDownload(blob, meta.name || "agentnet-file");
2056
2087
  dkRememberRtcFile(peerId, fileId, dl.name, blob.size, dl.url);
2057
2088
  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) + ")");
2089
+ if (onReceivedFileRef.current) onReceivedFileRef.current(peerId, dl.name, blob);
2059
2090
  cleanup(fileId);
2060
2091
  } else if (msg.type === "cancel") {
2061
2092
  cleanup(fileId);
@@ -2224,13 +2255,17 @@ const CALL_ICE_SERVERS = [
2224
2255
  { urls: "turn:tokyo.fi.chat:3478", username: "allcom", credential: "allcompass" },
2225
2256
  { urls: "turn:tokyo.fi.chat:3478?transport=tcp", username: "allcom", credential: "allcompass" }
2226
2257
  ];
2227
- function useCallController(selfId) {
2258
+ function useCallController(selfId, onCallLog) {
2228
2259
  const [incoming, setIncoming] = React.useState(null);
2229
2260
  const [active, setActive] = React.useState(null);
2230
2261
  const [localStream, setLocalStream] = React.useState(null);
2231
2262
  const [remoteStream, setRemoteStream] = React.useState(null);
2232
2263
  const [sharing, setSharing] = React.useState(false);
2233
2264
  const engineRef = React.useRef(null);
2265
+ const onCallLogRef = React.useRef(onCallLog);
2266
+ React.useEffect(() => {
2267
+ onCallLogRef.current = onCallLog;
2268
+ }, [onCallLog]);
2234
2269
  React.useEffect(() => {
2235
2270
  if (!selfId || engineRef.current) return;
2236
2271
  if (!window.PeerWebRTC || !window.RTCPeerConnection) {
@@ -2248,7 +2283,10 @@ function useCallController(selfId) {
2248
2283
  iceServers: CALL_ICE_SERVERS,
2249
2284
  logger: (m) => console.log(m)
2250
2285
  });
2251
- engine.on("incomingCall", (info) => setIncoming(info));
2286
+ engine.on("incomingCall", (info) => {
2287
+ setIncoming(info);
2288
+ if (onCallLogRef.current) onCallLogRef.current(info.peerId, !!info.video, "incoming");
2289
+ });
2252
2290
  engine.on("stateChanged", (info, state) => setActive((a) => a && a.callId === info.callId ? { ...a, state } : a));
2253
2291
  engine.on("localStream", (id, s) => {
2254
2292
  setLocalStream(s);
@@ -2281,6 +2319,7 @@ function useCallController(selfId) {
2281
2319
  return;
2282
2320
  }
2283
2321
  setActive({ callId: null, peerId, video: !!video, direction: "outgoing", state: "ringing" });
2322
+ if (onCallLogRef.current) onCallLogRef.current(peerId, !!video, "outgoing");
2284
2323
  eng.call(peerId, { audio: true, video: !!video }).then((callId) => setActive((a) => a && a.peerId === peerId && a.callId === null ? { ...a, callId } : a)).catch((e) => {
2285
2324
  setActive((a) => a && a.peerId === peerId ? null : a);
2286
2325
  alert("Could not start call: " + (e && e.message || e));
@@ -2748,10 +2787,21 @@ function DkApp() {
2748
2787
  const T = STR[t.lang] || STR.en;
2749
2788
  const vars = dkTheme(t.theme, t.accent);
2750
2789
  const rowPad = t.density === "comfortable" ? "11px 12px" : "7px 10px";
2751
- const callCtl = useCallController(me.userId);
2790
+ const callCtl = useCallController(me.userId, (peerId, video, direction) => {
2791
+ const text = `WebRTC ${video ? "video" : "audio"} call: ${direction}`;
2792
+ dkApi.logLocal(peerId, direction === "outgoing" ? "out" : "in", text).then(() => {
2793
+ data.loadThread(peerId);
2794
+ data.refresh();
2795
+ });
2796
+ });
2752
2797
  const onCall = (peerId, video) => callCtl.start(peerId, !!video);
2753
- const rtcFileCtl = useRtcFileController(me.userId, (peerId, text) => {
2754
- dkApi.logLocal(peerId, "in", text).then(() => {
2798
+ const rtcFileCtl = useRtcFileController(me.userId, (peerId, name, blob) => {
2799
+ if (!blob) return;
2800
+ dkApi.saveWebrtcFile(peerId, name, blob).then((r) => {
2801
+ if (!r || r.ok === false) {
2802
+ return dkApi.logLocal(peerId, "in", "received file via WebRTC: " + name + " (" + dkFileSize(blob.size) + ")");
2803
+ }
2804
+ }).then(() => {
2755
2805
  data.loadThread(peerId);
2756
2806
  data.refresh();
2757
2807
  });
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.228",
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",