@decentnetwork/lan 0.1.226 → 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(
@@ -1181,8 +1197,23 @@ const mediaBtnStyle = {
1181
1197
  justifyContent: "center",
1182
1198
  lineHeight: 0
1183
1199
  };
1184
- function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, selMode, selected, onToggleSel, busy }) {
1200
+ function dkRtcFileFromText(peerId, text) {
1201
+ const m = /^received file via WebRTC: (.+) \(([^)]+)\)$/.exec(String(text || ""));
1202
+ if (!m || !window.__dkRtcFiles) return null;
1203
+ const name = m[1];
1204
+ for (const item of window.__dkRtcFiles.values()) {
1205
+ if (item && item.peerId === peerId && item.name === name) return item;
1206
+ }
1207
+ return null;
1208
+ }
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 }) {
1185
1214
  const mine = m.from === "me";
1215
+ const rtcFile = !mine && !m.file ? dkRtcFileFromText(peer.userId || peer.id, m.text) : null;
1216
+ const callRec = !m.file ? dkCallFromText(m.text) : null;
1186
1217
  return /* @__PURE__ */ React.createElement(
1187
1218
  "div",
1188
1219
  {
@@ -1212,7 +1243,61 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, selMode, sele
1212
1243
  justifyContent: "center"
1213
1244
  } }, selected && /* @__PURE__ */ React.createElement(Icon, { name: "check", size: 12, stroke: 3, color: "#fff" })),
1214
1245
  !mine && /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 24, radius: 6, dot: false }),
1215
- /* @__PURE__ */ React.createElement("div", { style: { maxWidth: m.file && (m.file.media === "image" || m.file.media === "video") ? "min(680px, 88%)" : "64%", display: "flex", flexDirection: "column", alignItems: mine ? "flex-end" : "flex-start" } }, m.file ? (
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: {
1256
+ display: "flex",
1257
+ flexDirection: "column",
1258
+ gap: 6,
1259
+ maxWidth: rtcFile.media === "image" || rtcFile.media === "video" ? "100%" : 300,
1260
+ width: rtcFile.media === "image" || rtcFile.media === "video" ? "100%" : void 0,
1261
+ background: "var(--bub-them)",
1262
+ border: "1px solid var(--line)",
1263
+ borderRadius: 12,
1264
+ padding: 6
1265
+ } }, (rtcFile.media === "image" || rtcFile.media === "video") && /* @__PURE__ */ React.createElement("div", { "data-media": true, style: { position: "relative", lineHeight: 0 } }, rtcFile.media === "image" ? /* @__PURE__ */ React.createElement(
1266
+ "img",
1267
+ {
1268
+ src: rtcFile.url,
1269
+ alt: rtcFile.name,
1270
+ style: { display: "block", width: "100%", maxHeight: "72vh", borderRadius: 8, objectFit: "contain" }
1271
+ }
1272
+ ) : /* @__PURE__ */ React.createElement(
1273
+ "video",
1274
+ {
1275
+ src: rtcFile.url,
1276
+ controls: true,
1277
+ preload: "metadata",
1278
+ style: { display: "block", width: "100%", maxHeight: "72vh", borderRadius: 8, objectFit: "contain", background: "#000" }
1279
+ }
1280
+ ), /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", top: 8, right: 8, display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(
1281
+ "button",
1282
+ {
1283
+ onClick: () => onTheater({ kind: rtcFile.media, url: rtcFile.url, name: rtcFile.name }),
1284
+ title: "theater / \u7F51\u9875\u5168\u5C4F",
1285
+ style: mediaBtnStyle
1286
+ },
1287
+ /* @__PURE__ */ React.createElement(Icon, { name: "theater", size: 16, stroke: 2, color: "#fff" })
1288
+ ), /* @__PURE__ */ React.createElement(
1289
+ "button",
1290
+ {
1291
+ onClick: (e) => {
1292
+ const w = e.currentTarget.closest("[data-media]");
1293
+ const el = w && w.querySelector("video,img");
1294
+ if (el && el.requestFullscreen) el.requestFullscreen();
1295
+ },
1296
+ title: "fullscreen / \u5168\u5C4F",
1297
+ style: mediaBtnStyle
1298
+ },
1299
+ /* @__PURE__ */ React.createElement(Icon, { name: "maximize", size: 16, stroke: 2, color: "#fff" })
1300
+ ))), rtcFile.media === "audio" && /* @__PURE__ */ React.createElement("audio", { src: rtcFile.url, 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" } }, rtcFile.name), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", flexShrink: 0 } }, dkFileSize(rtcFile.size)), /* @__PURE__ */ React.createElement("a", { href: rtcFile.url, download: rtcFile.name, title: "download", style: { display: "inline-flex", flexShrink: 0 } }, /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 15, stroke: 2, color: "var(--accent)" })))) : m.file ? (
1216
1301
  // Inline preview/player. Received media: as soon as it's saved.
1217
1302
  // Sent media: once it's fully delivered ('sent') — the daemon keeps a
1218
1303
  // local copy so it plays on the sender's side too.
@@ -1609,6 +1694,7 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1609
1694
  onDelete: (id) => doDelete([id]),
1610
1695
  onCancel: doCancel,
1611
1696
  onRetry: doRetry,
1697
+ onCall,
1612
1698
  busy: m.id ? fileBusy[m.id] : void 0,
1613
1699
  selMode,
1614
1700
  selected: sel.has(m.id),
@@ -1947,13 +2033,19 @@ function dkFileDownload(blob, name) {
1947
2033
  a.textContent = name || "agentnet-file";
1948
2034
  return { url, name: a.download };
1949
2035
  }
1950
- function useRtcFileController(selfId, onReceivedText) {
2036
+ function dkRememberRtcFile(peerId, fileId, name, size, url) {
2037
+ if (!window.__dkRtcFiles) window.__dkRtcFiles = /* @__PURE__ */ new Map();
2038
+ const item = { id: fileId, peerId, name, size, url, media: dkFileMediaKind(name) };
2039
+ window.__dkRtcFiles.set(peerId + "|" + name + "|" + size, item);
2040
+ return item;
2041
+ }
2042
+ function useRtcFileController(selfId, onReceivedFile) {
1951
2043
  const [incoming, setIncoming] = React.useState([]);
1952
2044
  const peersRef = React.useRef(/* @__PURE__ */ new Map());
1953
- const onReceivedTextRef = React.useRef(onReceivedText);
2045
+ const onReceivedFileRef = React.useRef(onReceivedFile);
1954
2046
  React.useEffect(() => {
1955
- onReceivedTextRef.current = onReceivedText;
1956
- }, [onReceivedText]);
2047
+ onReceivedFileRef.current = onReceivedFile;
2048
+ }, [onReceivedFile]);
1957
2049
  React.useEffect(() => {
1958
2050
  if (!selfId || !window.RTCPeerConnection || !window.dkRtcSignalBus) return;
1959
2051
  const bus = dkRtcSignalBus();
@@ -1992,8 +2084,9 @@ function useRtcFileController(selfId, onReceivedText) {
1992
2084
  const meta = sess.meta || {};
1993
2085
  const blob = new Blob(sess.chunks, { type: meta.mime || "application/octet-stream" });
1994
2086
  const dl = dkFileDownload(blob, meta.name || "agentnet-file");
2087
+ dkRememberRtcFile(peerId, fileId, dl.name, blob.size, dl.url);
1995
2088
  setIncoming((arr) => [{ id: fileId, peerId, name: dl.name, size: blob.size, url: dl.url }].concat(arr).slice(0, 8));
1996
- 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);
1997
2090
  cleanup(fileId);
1998
2091
  } else if (msg.type === "cancel") {
1999
2092
  cleanup(fileId);
@@ -2162,13 +2255,17 @@ const CALL_ICE_SERVERS = [
2162
2255
  { urls: "turn:tokyo.fi.chat:3478", username: "allcom", credential: "allcompass" },
2163
2256
  { urls: "turn:tokyo.fi.chat:3478?transport=tcp", username: "allcom", credential: "allcompass" }
2164
2257
  ];
2165
- function useCallController(selfId) {
2258
+ function useCallController(selfId, onCallLog) {
2166
2259
  const [incoming, setIncoming] = React.useState(null);
2167
2260
  const [active, setActive] = React.useState(null);
2168
2261
  const [localStream, setLocalStream] = React.useState(null);
2169
2262
  const [remoteStream, setRemoteStream] = React.useState(null);
2170
2263
  const [sharing, setSharing] = React.useState(false);
2171
2264
  const engineRef = React.useRef(null);
2265
+ const onCallLogRef = React.useRef(onCallLog);
2266
+ React.useEffect(() => {
2267
+ onCallLogRef.current = onCallLog;
2268
+ }, [onCallLog]);
2172
2269
  React.useEffect(() => {
2173
2270
  if (!selfId || engineRef.current) return;
2174
2271
  if (!window.PeerWebRTC || !window.RTCPeerConnection) {
@@ -2186,7 +2283,10 @@ function useCallController(selfId) {
2186
2283
  iceServers: CALL_ICE_SERVERS,
2187
2284
  logger: (m) => console.log(m)
2188
2285
  });
2189
- 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
+ });
2190
2290
  engine.on("stateChanged", (info, state) => setActive((a) => a && a.callId === info.callId ? { ...a, state } : a));
2191
2291
  engine.on("localStream", (id, s) => {
2192
2292
  setLocalStream(s);
@@ -2219,6 +2319,7 @@ function useCallController(selfId) {
2219
2319
  return;
2220
2320
  }
2221
2321
  setActive({ callId: null, peerId, video: !!video, direction: "outgoing", state: "ringing" });
2322
+ if (onCallLogRef.current) onCallLogRef.current(peerId, !!video, "outgoing");
2222
2323
  eng.call(peerId, { audio: true, video: !!video }).then((callId) => setActive((a) => a && a.peerId === peerId && a.callId === null ? { ...a, callId } : a)).catch((e) => {
2223
2324
  setActive((a) => a && a.peerId === peerId ? null : a);
2224
2325
  alert("Could not start call: " + (e && e.message || e));
@@ -2686,10 +2787,21 @@ function DkApp() {
2686
2787
  const T = STR[t.lang] || STR.en;
2687
2788
  const vars = dkTheme(t.theme, t.accent);
2688
2789
  const rowPad = t.density === "comfortable" ? "11px 12px" : "7px 10px";
2689
- 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
+ });
2690
2797
  const onCall = (peerId, video) => callCtl.start(peerId, !!video);
2691
- const rtcFileCtl = useRtcFileController(me.userId, (peerId, text) => {
2692
- 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(() => {
2693
2805
  data.loadThread(peerId);
2694
2806
  data.refresh();
2695
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.226",
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",