@decentnetwork/lan 0.1.214 → 0.1.217

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.
@@ -33,7 +33,7 @@ export interface ChatMessage {
33
33
  file?: {
34
34
  name: string;
35
35
  size: number;
36
- status?: "queued" | "sending" | "sent" | "failed";
36
+ status?: "queued" | "sending" | "sent" | "failed" | "cancelled";
37
37
  sent?: number;
38
38
  durationMs?: number;
39
39
  avgKbps?: number;
@@ -65,13 +65,13 @@ export declare class MessageStore {
65
65
  appendFile(peer: string, dir: "in" | "out", file: {
66
66
  name: string;
67
67
  size: number;
68
- status?: "queued" | "sending" | "sent" | "failed";
68
+ status?: "queued" | "sending" | "sent" | "failed" | "cancelled";
69
69
  sent?: number;
70
70
  }, ts?: number): ChatMessage;
71
71
  /** Patch an existing file message's transfer fields (status / sent bytes).
72
72
  * No-op if the id isn't found. Returns true if it patched. */
73
73
  patchFile(peer: string, id: string, patch: {
74
- status?: "queued" | "sending" | "sent" | "failed";
74
+ status?: "queued" | "sending" | "sent" | "failed" | "cancelled";
75
75
  sent?: number;
76
76
  durationMs?: number;
77
77
  avgKbps?: number;
@@ -98,6 +98,12 @@ export declare class DaemonServer {
98
98
  * restarts). Also ensures a friend-meta entry exists so the friend shows up
99
99
  * in the UI list even before it's been renamed. */
100
100
  private logChat;
101
+ /** A live file send cannot make progress once the Carrier session drops.
102
+ * Move any active chips for that peer out of "sending" immediately instead
103
+ * of leaving the UI to show a misleading 1 KB/s forever. If the original
104
+ * bytes are still in the outbox, queue it for the next reconnect; otherwise
105
+ * mark failed and let the user explicitly retry/cancel. */
106
+ private markPeerActiveSendsInterrupted;
101
107
  /** On startup, re-queue any offline file transfer that was mid-flight when the
102
108
  * daemon last stopped: an out chip stuck at "sending" whose bytes still sit in
103
109
  * the outbox. Flips it back to "queued" so the next reconnect redelivers it
@@ -585,7 +585,7 @@ export class DaemonServer {
585
585
  killed = true;
586
586
  }
587
587
  if (killed || wasActive) {
588
- this.messageStore?.patchFile(userid, id, { status: "failed", sent: 0, kbps: undefined });
588
+ this.messageStore?.patchFile(userid, id, { status: "cancelled", sent: 0, kbps: undefined });
589
589
  n++;
590
590
  }
591
591
  this.logger.info(`file-cancel peer=${userid.slice(0, 8)} msg=${id} killed=${killed ? 1 : 0} wasActive=${wasActive ? 1 : 0}`);
@@ -1007,6 +1007,9 @@ export class DaemonServer {
1007
1007
  if (evt?.status === "connected" && evt.pubkey) {
1008
1008
  void this.flushOutbox(evt.pubkey).catch((e) => this.logger.warn(`Outbox flush for ${evt.pubkey.slice(0, 8)} failed: ${e.message}`));
1009
1009
  }
1010
+ else if (evt?.status === "disconnected" && evt.pubkey) {
1011
+ this.markPeerActiveSendsInterrupted(evt.pubkey, "peer-disconnected");
1012
+ }
1010
1013
  });
1011
1014
  // File transfer (toxcore-standard): auto-accept offers from friends and
1012
1015
  // save completed files under <configDir>/downloads/.
@@ -1395,6 +1398,35 @@ export class DaemonServer {
1395
1398
  this.friendMeta?.ensure(userid);
1396
1399
  this.ipcEvents.emit("event", { type: "chat", userid, dir });
1397
1400
  }
1401
+ /** A live file send cannot make progress once the Carrier session drops.
1402
+ * Move any active chips for that peer out of "sending" immediately instead
1403
+ * of leaving the UI to show a misleading 1 KB/s forever. If the original
1404
+ * bytes are still in the outbox, queue it for the next reconnect; otherwise
1405
+ * mark failed and let the user explicitly retry/cancel. */
1406
+ markPeerActiveSendsInterrupted(userid, reason) {
1407
+ const affected = [...this.activeSends].filter(([, t]) => t.peer === userid);
1408
+ if (!affected.length)
1409
+ return;
1410
+ let queued = 0;
1411
+ let failed = 0;
1412
+ for (const [fileId, t] of affected) {
1413
+ this.activeSends.delete(fileId);
1414
+ this.peerManager?.cancelSend(userid, fileId);
1415
+ const canQueue = !!this.outboxDir && existsSync(resolve(this.outboxDir, t.msgId));
1416
+ this.messageStore?.patchFile(userid, t.msgId, {
1417
+ status: canQueue ? "queued" : "failed",
1418
+ sent: 0,
1419
+ kbps: undefined,
1420
+ });
1421
+ if (canQueue)
1422
+ queued++;
1423
+ else
1424
+ failed++;
1425
+ }
1426
+ this.logger.warn(`Interrupted ${affected.length} active file send(s) to ${userid.slice(0, 8)} ` +
1427
+ `(${reason}; queued=${queued}, failed=${failed})`);
1428
+ this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
1429
+ }
1398
1430
  /** On startup, re-queue any offline file transfer that was mid-flight when the
1399
1431
  * daemon last stopped: an out chip stuck at "sending" whose bytes still sit in
1400
1432
  * the outbox. Flips it back to "queued" so the next reconnect redelivers it
@@ -332,6 +332,113 @@ Object.assign(window, {
332
332
  DK_ME_FALLBACK,
333
333
  dkCopy
334
334
  });
335
+ function dkRtcParseEnvelope(data) {
336
+ const text = String(data || "").replace(/\0+$/u, "").trim();
337
+ if (!text) return null;
338
+ try {
339
+ const obj = JSON.parse(text);
340
+ if (obj && obj.dkRtc === 1 && typeof obj.kind === "string") return obj;
341
+ } catch (e) {
342
+ }
343
+ return null;
344
+ }
345
+ function dkRtcSignalBus() {
346
+ if (window.__dkRtcSignalBus) return window.__dkRtcSignalBus;
347
+ const PW = window.PeerWebRTC;
348
+ const handlers = /* @__PURE__ */ new Map();
349
+ let started = false;
350
+ let stopped = false;
351
+ function emit(kind, userid, payload) {
352
+ const set = handlers.get(kind);
353
+ if (!set) return;
354
+ for (const h of Array.from(set)) {
355
+ try {
356
+ h(userid, payload);
357
+ } catch (e) {
358
+ console.warn("[rtc] signal handler failed", e);
359
+ }
360
+ }
361
+ }
362
+ async function pollLoop() {
363
+ while (!stopped) {
364
+ let signals = [];
365
+ try {
366
+ const r = await fetch("/api/call-poll", { headers: { "cache-control": "no-cache" } });
367
+ const d = await r.json();
368
+ signals = d && d.signals || [];
369
+ } catch (e) {
370
+ await new Promise((res) => setTimeout(res, 1e3));
371
+ continue;
372
+ }
373
+ for (const s of signals) {
374
+ const env = dkRtcParseEnvelope(s.data);
375
+ if (env) {
376
+ emit(env.kind, s.userid, env);
377
+ continue;
378
+ }
379
+ if (PW) {
380
+ try {
381
+ emit("call", s.userid, PW.decodeSignal(s.data));
382
+ } catch (e) {
383
+ }
384
+ }
385
+ }
386
+ }
387
+ }
388
+ const bus = {
389
+ start() {
390
+ if (started) return;
391
+ started = true;
392
+ stopped = false;
393
+ pollLoop();
394
+ },
395
+ on(kind, cb) {
396
+ if (!handlers.has(kind)) handlers.set(kind, /* @__PURE__ */ new Set());
397
+ handlers.get(kind).add(cb);
398
+ this.start();
399
+ return () => {
400
+ var _a;
401
+ return (_a = handlers.get(kind)) == null ? void 0 : _a.delete(cb);
402
+ };
403
+ },
404
+ async send(userid, data) {
405
+ const body = JSON.stringify({ userid, data: typeof data === "string" ? data : JSON.stringify(data) });
406
+ const r = await fetch("/api/call-signal", {
407
+ method: "POST",
408
+ headers: { "content-type": "application/json" },
409
+ body
410
+ });
411
+ const d = await r.json().catch(() => ({ ok: r.ok }));
412
+ if (!d.ok) throw new Error(d.error || "call-signal send failed");
413
+ },
414
+ stop() {
415
+ stopped = true;
416
+ }
417
+ };
418
+ window.__dkRtcSignalBus = bus;
419
+ return bus;
420
+ }
421
+ function makeDaemonSignaling() {
422
+ const PW = window.PeerWebRTC;
423
+ const bus = dkRtcSignalBus();
424
+ let off = null;
425
+ return {
426
+ async send(peerId, signal) {
427
+ await bus.send(peerId, PW.encodeSignal(signal));
428
+ },
429
+ onSignal(cb) {
430
+ if (off) off();
431
+ off = bus.on("call", cb);
432
+ },
433
+ stop() {
434
+ if (off) {
435
+ off();
436
+ off = null;
437
+ }
438
+ }
439
+ };
440
+ }
441
+ Object.assign(window, { dkRtcSignalBus, makeDaemonSignaling });
335
442
  function DkIdenticon({ seed, size = 30, radius = 7 }) {
336
443
  const { cells, hue } = dkIdenticon(seed);
337
444
  const cell = size / 5;
@@ -1108,7 +1215,7 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, selMode, sele
1108
1215
  // Inline preview/player. Received media: as soon as it's saved.
1109
1216
  // Sent media: once it's fully delivered ('sent') — the daemon keeps a
1110
1217
  // local copy so it plays on the sender's side too.
1111
- (m.file.media === "image" || m.file.media === "video" || m.file.media === "audio") && (mine ? m.file.status === "sent" : m.file.status !== "sending" && m.file.status !== "failed" && m.file.status !== "queued") ? (
1218
+ (m.file.media === "image" || m.file.media === "video" || m.file.media === "audio") && (mine ? m.file.status === "sent" : m.file.status !== "sending" && m.file.status !== "failed" && m.file.status !== "queued" && m.file.status !== "cancelled") ? (
1112
1219
  // Received media → inline preview / player, with name, size, download.
1113
1220
  /* @__PURE__ */ React.createElement("div", { style: {
1114
1221
  display: "flex",
@@ -1170,10 +1277,10 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, selMode, sele
1170
1277
  cursor: mine ? "default" : "pointer"
1171
1278
  };
1172
1279
  const icon = /* @__PURE__ */ React.createElement("div", { style: { width: 34, height: 34, borderRadius: 7, flexShrink: 0, background: mine ? "rgba(255,255,255,0.16)" : "var(--chip)", display: "flex", alignItems: "center", justifyContent: "center", color: mine ? "#fff" : "var(--accent)" } }, /* @__PURE__ */ React.createElement(Icon, { name: m.file.media === "image" ? "image" : m.file.media === "video" ? "video" : m.file.media === "audio" ? "play" : "file", size: 18, stroke: 1.9 }));
1173
- const meta = /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: mine ? "#fff" : "var(--text)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11, color: mine ? "rgba(255,255,255,0.7)" : "var(--faint)", marginTop: 1 } }, busy === "cancel" ? `${m.file.size} \xB7 ${T.cancelling || "Cancelling\u2026"}` : busy === "retry" ? `${m.file.size} \xB7 ${T.retrying || "Retrying\u2026"}` : m.file.status === "queued" ? `${m.file.size} \xB7 ${T.queued || "queued"}` : m.file.status === "sending" ? `${m.file.size} \xB7 ${dkPct(m.file.pct)}${m.file.kbps ? " \xB7 " + dkSpeed(m.file.kbps) : ""}` : m.file.status === "failed" ? `${m.file.size} \xB7 failed` : mine ? `${m.file.size} \xB7 sent` : `${m.file.size} \xB7 ${T.open || "open"}`));
1280
+ const meta = /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: mine ? "#fff" : "var(--text)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11, color: mine ? "rgba(255,255,255,0.7)" : "var(--faint)", marginTop: 1 } }, busy === "cancel" ? `${m.file.size} \xB7 ${T.cancelling || "Cancelling\u2026"}` : busy === "retry" ? `${m.file.size} \xB7 ${T.retrying || "Retrying\u2026"}` : m.file.status === "queued" ? `${m.file.size} \xB7 ${T.queued || "queued"}` : m.file.status === "sending" ? `${m.file.size} \xB7 ${dkPct(m.file.pct)}${m.file.kbps ? " \xB7 " + dkSpeed(m.file.kbps) : ""}` : m.file.status === "failed" ? `${m.file.size} \xB7 failed` : m.file.status === "cancelled" ? `${m.file.size} \xB7 ${T.cancelled || "cancelled"}` : mine ? `${m.file.size} \xB7 sent` : `${m.file.size} \xB7 ${T.open || "open"}`));
1174
1281
  if (mine) {
1175
1282
  const btnBusy = !!busy;
1176
- return /* @__PURE__ */ React.createElement("div", { style: { ...cardStyle, opacity: btnBusy ? 0.85 : 1 } }, icon, meta, busy === "cancel" || busy === "retry" ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : m.file.status === "queued" ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : m.file.status === "sending" ? /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, color: "rgba(255,255,255,0.9)", whiteSpace: "nowrap" } }, dkPct(m.file.pct), m.file.kbps ? " \xB7 " + dkSpeed(m.file.kbps) : "") : m.file.status === "failed" ? /* @__PURE__ */ React.createElement(Icon, { name: "x", size: 16, stroke: 2.4, color: "rgba(255,200,190,0.95)" }) : /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }), (m.file.status === "sending" || m.file.status === "queued" || busy === "cancel") && m.id && /* @__PURE__ */ React.createElement(
1283
+ return /* @__PURE__ */ React.createElement("div", { style: { ...cardStyle, opacity: btnBusy ? 0.85 : 1 } }, icon, meta, busy === "cancel" || busy === "retry" ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : m.file.status === "queued" ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : m.file.status === "sending" ? /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, color: "rgba(255,255,255,0.9)", whiteSpace: "nowrap" } }, dkPct(m.file.pct), m.file.kbps ? " \xB7 " + dkSpeed(m.file.kbps) : "") : m.file.status === "failed" || m.file.status === "cancelled" ? /* @__PURE__ */ React.createElement(Icon, { name: "x", size: 16, stroke: 2.4, color: "rgba(255,200,190,0.95)" }) : /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }), (m.file.status === "sending" || m.file.status === "queued" || busy === "cancel") && m.id && /* @__PURE__ */ React.createElement(
1177
1284
  "button",
1178
1285
  {
1179
1286
  title: T.cancel || "cancel",
@@ -1260,6 +1367,7 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, selMode, sele
1260
1367
  }
1261
1368
  function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread }) {
1262
1369
  const scrollRef = React.useRef(null);
1370
+ const followScrollRef = React.useRef(true);
1263
1371
  const fileRef = React.useRef(null);
1264
1372
  const [menu, setMenu] = React.useState(false);
1265
1373
  const [selMode, setSelMode] = React.useState(false);
@@ -1284,6 +1392,7 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1284
1392
  setFileBusy({});
1285
1393
  setFilePatch({});
1286
1394
  setFlash(null);
1395
+ followScrollRef.current = true;
1287
1396
  }, [peer.id]);
1288
1397
  React.useEffect(() => {
1289
1398
  setFilePatch((p) => {
@@ -1307,9 +1416,18 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1307
1416
  return { ...m, file: { ...m.file, ...filePatch[m.id] } };
1308
1417
  });
1309
1418
  }, [baseThread, filePatch]);
1310
- React.useEffect(() => {
1311
- if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
1312
- }, [peer.id, threadProp]);
1419
+ const scrollToBottom = () => {
1420
+ const el = scrollRef.current;
1421
+ if (el) el.scrollTop = el.scrollHeight;
1422
+ };
1423
+ const updateFollowScroll = () => {
1424
+ const el = scrollRef.current;
1425
+ if (!el) return;
1426
+ followScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
1427
+ };
1428
+ React.useLayoutEffect(() => {
1429
+ if (followScrollRef.current) scrollToBottom();
1430
+ }, [peer.id, thread]);
1313
1431
  const toggleSel = (id) => setSel((s) => {
1314
1432
  const n = new Set(s);
1315
1433
  n.has(id) ? n.delete(id) : n.add(id);
@@ -1334,7 +1452,7 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1334
1452
  showFlash("err", msg);
1335
1453
  window.alert(msg);
1336
1454
  } else {
1337
- setFilePatch((p) => ({ ...p, [id]: { status: "failed", pct: void 0, kbps: void 0 } }));
1455
+ setFilePatch((p) => ({ ...p, [id]: { status: "cancelled", pct: void 0, kbps: void 0 } }));
1338
1456
  showFlash("ok", T.cancelled || "Transfer cancelled");
1339
1457
  if (onReloadThread) onReloadThread();
1340
1458
  }
@@ -1417,17 +1535,23 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1417
1535
  }, [theater]);
1418
1536
  const sendDraft = () => {
1419
1537
  if (draft.trim()) {
1538
+ followScrollRef.current = true;
1420
1539
  onSend(draft);
1421
1540
  setDraft("");
1541
+ requestAnimationFrame(scrollToBottom);
1422
1542
  }
1423
1543
  };
1424
1544
  const sendFiles = (files) => {
1425
1545
  if (!files || !files.length || !onSendFile) return;
1426
1546
  const file = files[0];
1547
+ followScrollRef.current = true;
1427
1548
  setSending({ name: file.name });
1428
1549
  Promise.resolve(onSendFile(file)).then((r) => {
1429
1550
  if (r && r.ok === false) window.alert((lang === "zh" ? "\u53D1\u9001\u5931\u8D25: " : "Send failed: ") + (r.error || ""));
1430
- }).finally(() => setSending(null));
1551
+ }).finally(() => {
1552
+ setSending(null);
1553
+ requestAnimationFrame(scrollToBottom);
1554
+ });
1431
1555
  };
1432
1556
  const onDrop = (e) => {
1433
1557
  e.preventDefault();
@@ -1458,7 +1582,7 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1458
1582
  setMenu(false);
1459
1583
  onRemove(peer);
1460
1584
  } }))))),
1461
- /* @__PURE__ */ React.createElement("div", { ref: scrollRef, style: { flex: 1, overflow: "auto", padding: "18px 22px" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto" } }, thread.filter((m) => m.day || !hidden.has(m.id)).map((m, i) => m.day ? /* @__PURE__ */ React.createElement("div", { key: i, style: { display: "flex", justifyContent: "center", margin: "14px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, fontWeight: 600, color: "var(--faint)", background: "var(--chip)", padding: "3px 10px", borderRadius: 999 } }, m.day)) : /* @__PURE__ */ React.createElement(
1585
+ /* @__PURE__ */ React.createElement("div", { ref: scrollRef, onScroll: updateFollowScroll, style: { flex: 1, overflow: "auto", padding: "18px 22px" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto" } }, thread.filter((m) => m.day || !hidden.has(m.id)).map((m, i) => m.day ? /* @__PURE__ */ React.createElement("div", { key: i, style: { display: "flex", justifyContent: "center", margin: "14px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, fontWeight: 600, color: "var(--faint)", background: "var(--chip)", padding: "3px 10px", borderRadius: 999 } }, m.day)) : /* @__PURE__ */ React.createElement(
1462
1586
  Msg,
1463
1587
  {
1464
1588
  key: m.id || i,
@@ -1710,53 +1834,261 @@ function ProfileTab({ T, me, onEdit }) {
1710
1834
  } }));
1711
1835
  }
1712
1836
  Object.assign(window, { ProfileTab });
1713
- function makeDaemonSignaling() {
1714
- const PW = window.PeerWebRTC;
1715
- let handler = null;
1716
- let stopped = false;
1717
- async function pollLoop() {
1718
- while (!stopped) {
1719
- let signals = [];
1837
+ const DK_FILE_RTC_KIND = "file";
1838
+ const DK_FILE_RTC_CHUNK = 64 * 1024;
1839
+ const DK_FILE_RTC_OPEN_TIMEOUT_MS = 8e3;
1840
+ const DK_FILE_RTC_SIGNAL_TTL_MS = 10 * 60 * 1e3;
1841
+ function dkFileRtcId() {
1842
+ if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID();
1843
+ return "frtc-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
1844
+ }
1845
+ function dkFileRtcEnvelope(fileId, type, extra) {
1846
+ return Object.assign({ dkRtc: 1, kind: DK_FILE_RTC_KIND, fileId, type, ts: Date.now() }, extra || {});
1847
+ }
1848
+ function dkWaitForOpen(dc, timeoutMs) {
1849
+ if (dc.readyState === "open") return Promise.resolve();
1850
+ return new Promise((resolve, reject) => {
1851
+ const timer = setTimeout(() => {
1852
+ cleanup();
1853
+ reject(new Error("WebRTC data channel open timed out"));
1854
+ }, timeoutMs);
1855
+ const cleanup = () => {
1856
+ clearTimeout(timer);
1857
+ dc.removeEventListener("open", onOpen);
1858
+ dc.removeEventListener("error", onError);
1859
+ dc.removeEventListener("close", onClose);
1860
+ };
1861
+ const onOpen = () => {
1862
+ cleanup();
1863
+ resolve();
1864
+ };
1865
+ const onError = () => {
1866
+ cleanup();
1867
+ reject(new Error("WebRTC data channel failed"));
1868
+ };
1869
+ const onClose = () => {
1870
+ cleanup();
1871
+ reject(new Error("WebRTC data channel closed"));
1872
+ };
1873
+ dc.addEventListener("open", onOpen);
1874
+ dc.addEventListener("error", onError);
1875
+ dc.addEventListener("close", onClose);
1876
+ });
1877
+ }
1878
+ function dkWaitBufferedLow(dc, highWater) {
1879
+ if (dc.bufferedAmount < highWater) return Promise.resolve();
1880
+ return new Promise((resolve) => {
1881
+ const prev = dc.bufferedAmountLowThreshold;
1882
+ dc.bufferedAmountLowThreshold = Math.floor(highWater / 2);
1883
+ const done = () => {
1884
+ dc.removeEventListener("bufferedamountlow", done);
1885
+ dc.bufferedAmountLowThreshold = prev;
1886
+ resolve();
1887
+ };
1888
+ dc.addEventListener("bufferedamountlow", done);
1889
+ });
1890
+ }
1891
+ function dkFileDownload(blob, name) {
1892
+ const url = URL.createObjectURL(blob);
1893
+ const a = document.createElement("a");
1894
+ a.href = url;
1895
+ a.download = name || "agentnet-file";
1896
+ a.textContent = name || "agentnet-file";
1897
+ return { url, name: a.download };
1898
+ }
1899
+ function useRtcFileController(selfId, onReceivedText) {
1900
+ const [incoming, setIncoming] = React.useState([]);
1901
+ const peersRef = React.useRef(/* @__PURE__ */ new Map());
1902
+ React.useEffect(() => {
1903
+ if (!selfId || !window.RTCPeerConnection || !window.dkRtcSignalBus) return;
1904
+ const bus = dkRtcSignalBus();
1905
+ const cleanup = (fileId) => {
1906
+ const sess = peersRef.current.get(fileId);
1907
+ if (!sess) return;
1720
1908
  try {
1721
- const r = await fetch("/api/call-poll", { headers: { "cache-control": "no-cache" } });
1722
- const d = await r.json();
1723
- signals = d && d.signals || [];
1909
+ sess.dc && sess.dc.close();
1724
1910
  } catch (e) {
1725
- await new Promise((res) => setTimeout(res, 1e3));
1726
- continue;
1727
1911
  }
1728
- for (const s of signals) {
1729
- try {
1730
- const signal = PW.decodeSignal(s.data);
1731
- handler && handler(s.userid, signal);
1732
- } catch (e) {
1912
+ try {
1913
+ sess.pc && sess.pc.close();
1914
+ } catch (e) {
1915
+ }
1916
+ peersRef.current.delete(fileId);
1917
+ };
1918
+ const sendSignal = (peerId, fileId, type, extra) => bus.send(peerId, dkFileRtcEnvelope(fileId, type, extra));
1919
+ const handleDataChannel = (peerId, fileId, pc, dc, initialMeta) => {
1920
+ dc.binaryType = "arraybuffer";
1921
+ const prev = peersRef.current.get(fileId);
1922
+ const sess = Object.assign(prev || {}, { pc, dc, peerId, meta: initialMeta || null, chunks: [], received: 0 });
1923
+ peersRef.current.set(fileId, sess);
1924
+ dc.onmessage = (ev) => {
1925
+ if (typeof ev.data === "string") {
1926
+ let msg = null;
1927
+ try {
1928
+ msg = JSON.parse(ev.data);
1929
+ } catch (e) {
1930
+ }
1931
+ if (!msg) return;
1932
+ if (msg.type === "meta") {
1933
+ sess.meta = msg;
1934
+ sess.chunks = [];
1935
+ sess.received = 0;
1936
+ } else if (msg.type === "done") {
1937
+ const meta = sess.meta || {};
1938
+ const blob = new Blob(sess.chunks, { type: meta.mime || "application/octet-stream" });
1939
+ const dl = dkFileDownload(blob, meta.name || "agentnet-file");
1940
+ setIncoming((arr) => [{ id: fileId, peerId, name: dl.name, size: blob.size, url: dl.url }].concat(arr).slice(0, 8));
1941
+ if (onReceivedText) onReceivedText(peerId, "received file via WebRTC: " + dl.name + " (" + dkFileSize(blob.size) + ")");
1942
+ cleanup(fileId);
1943
+ } else if (msg.type === "cancel") {
1944
+ cleanup(fileId);
1945
+ }
1946
+ return;
1733
1947
  }
1948
+ sess.chunks.push(ev.data);
1949
+ sess.received += ev.data.byteLength || ev.data.size || 0;
1950
+ };
1951
+ dc.onclose = () => setTimeout(() => cleanup(fileId), 1e3);
1952
+ dc.onerror = () => cleanup(fileId);
1953
+ };
1954
+ const off = bus.on(DK_FILE_RTC_KIND, async (peerId, env) => {
1955
+ if (!env || !env.fileId || !env.type) return;
1956
+ if (Date.now() - (env.ts || Date.now()) > DK_FILE_RTC_SIGNAL_TTL_MS) return;
1957
+ const fileId = env.fileId;
1958
+ let sess = peersRef.current.get(fileId);
1959
+ try {
1960
+ if (env.type === "offer") {
1961
+ if (sess) cleanup(fileId);
1962
+ const pc = new RTCPeerConnection({ iceServers: CALL_ICE_SERVERS });
1963
+ pc.onicecandidate = (ev) => {
1964
+ if (ev.candidate) sendSignal(peerId, fileId, "candidate", { candidate: ev.candidate.toJSON() }).catch(() => {
1965
+ });
1966
+ };
1967
+ pc.ondatachannel = (ev) => handleDataChannel(peerId, fileId, pc, ev.channel, env.meta || null);
1968
+ peersRef.current.set(fileId, { pc, dc: null, peerId, chunks: [], meta: env.meta || null, pendingCandidates: [] });
1969
+ await pc.setRemoteDescription({ type: "offer", sdp: env.sdp });
1970
+ sess = peersRef.current.get(fileId);
1971
+ if (sess && sess.pendingCandidates) {
1972
+ for (const c of sess.pendingCandidates.splice(0)) {
1973
+ try {
1974
+ await pc.addIceCandidate(c);
1975
+ } catch (e) {
1976
+ }
1977
+ }
1978
+ }
1979
+ const answer = await pc.createAnswer();
1980
+ await pc.setLocalDescription(answer);
1981
+ await sendSignal(peerId, fileId, "answer", { sdp: answer.sdp });
1982
+ } else if (env.type === "answer" && sess) {
1983
+ await sess.pc.setRemoteDescription({ type: "answer", sdp: env.sdp });
1984
+ if (sess.pendingCandidates) {
1985
+ for (const c of sess.pendingCandidates.splice(0)) {
1986
+ try {
1987
+ await sess.pc.addIceCandidate(c);
1988
+ } catch (e) {
1989
+ }
1990
+ }
1991
+ }
1992
+ } else if (env.type === "candidate" && sess && env.candidate) {
1993
+ if (sess.pc.remoteDescription) {
1994
+ try {
1995
+ await sess.pc.addIceCandidate(env.candidate);
1996
+ } catch (e) {
1997
+ }
1998
+ } else {
1999
+ if (!sess.pendingCandidates) sess.pendingCandidates = [];
2000
+ sess.pendingCandidates.push(env.candidate);
2001
+ }
2002
+ } else if (env.type === "bye") {
2003
+ cleanup(fileId);
2004
+ }
2005
+ } catch (e) {
2006
+ console.warn("[file-rtc] signal failed", e);
2007
+ cleanup(fileId);
1734
2008
  }
2009
+ });
2010
+ return () => {
2011
+ off();
2012
+ for (const id of Array.from(peersRef.current.keys())) cleanup(id);
2013
+ };
2014
+ }, [selfId, onReceivedText]);
2015
+ const sendFile = React.useCallback(async (peerId, file, onProgress) => {
2016
+ if (!peerId || !file || !window.RTCPeerConnection || !window.dkRtcSignalBus) {
2017
+ return { ok: false, fallback: true, error: "WebRTC unavailable" };
1735
2018
  }
1736
- }
1737
- return {
1738
- async send(peerId, signal) {
1739
- const body = JSON.stringify({ userid: peerId, data: PW.encodeSignal(signal) });
1740
- const r = await fetch("/api/call-signal", {
1741
- method: "POST",
1742
- headers: { "content-type": "application/json" },
1743
- body
2019
+ const bus = dkRtcSignalBus();
2020
+ const fileId = dkFileRtcId();
2021
+ const pc = new RTCPeerConnection({ iceServers: CALL_ICE_SERVERS });
2022
+ const dc = pc.createDataChannel("agentnet-file", { ordered: true });
2023
+ const sess = { pc, dc, peerId, chunks: [], meta: null, pendingCandidates: [] };
2024
+ peersRef.current.set(fileId, sess);
2025
+ const cleanup = () => {
2026
+ try {
2027
+ dc.close();
2028
+ } catch (e) {
2029
+ }
2030
+ try {
2031
+ pc.close();
2032
+ } catch (e) {
2033
+ }
2034
+ peersRef.current.delete(fileId);
2035
+ };
2036
+ const sendSignal = (type, extra) => bus.send(peerId, dkFileRtcEnvelope(fileId, type, extra));
2037
+ try {
2038
+ pc.onicecandidate = (ev) => {
2039
+ if (ev.candidate) sendSignal("candidate", { candidate: ev.candidate.toJSON() }).catch(() => {
2040
+ });
2041
+ };
2042
+ const offer = await pc.createOffer();
2043
+ await pc.setLocalDescription(offer);
2044
+ await sendSignal("offer", {
2045
+ sdp: offer.sdp,
2046
+ meta: { name: file.name, size: file.size, mime: file.type || "application/octet-stream" }
1744
2047
  });
1745
- const d = await r.json().catch(() => ({ ok: r.ok }));
1746
- if (!d.ok) throw new Error(d.error || "call-signal send failed");
1747
- },
1748
- onSignal(cb) {
1749
- handler = cb;
1750
- if (stopped === false && !this._started) {
1751
- this._started = true;
1752
- pollLoop();
2048
+ await dkWaitForOpen(dc, DK_FILE_RTC_OPEN_TIMEOUT_MS);
2049
+ dc.send(JSON.stringify({ type: "meta", name: file.name, size: file.size, mime: file.type || "application/octet-stream" }));
2050
+ let sent = 0;
2051
+ const highWater = 4 * 1024 * 1024;
2052
+ while (sent < file.size) {
2053
+ if (dc.readyState !== "open") throw new Error("WebRTC data channel closed");
2054
+ const end = Math.min(file.size, sent + DK_FILE_RTC_CHUNK);
2055
+ const buf = await file.slice(sent, end).arrayBuffer();
2056
+ dc.send(buf);
2057
+ sent = end;
2058
+ if (onProgress) onProgress({ sent, size: file.size, pct: file.size ? sent / file.size * 100 : 100 });
2059
+ await dkWaitBufferedLow(dc, highWater);
1753
2060
  }
1754
- },
1755
- stop() {
1756
- stopped = true;
2061
+ dc.send(JSON.stringify({ type: "done" }));
2062
+ await sendSignal("bye", { reason: "done" }).catch(() => {
2063
+ });
2064
+ setTimeout(cleanup, 1500);
2065
+ return { ok: true, via: "webrtc", fileId };
2066
+ } catch (e) {
2067
+ await sendSignal("bye", { reason: "fallback" }).catch(() => {
2068
+ });
2069
+ cleanup();
2070
+ return { ok: false, fallback: true, error: e && e.message || String(e) };
1757
2071
  }
1758
- };
1759
- }
2072
+ }, []);
2073
+ const dismissIncoming = React.useCallback((id) => {
2074
+ setIncoming((arr) => {
2075
+ const item = arr.find((x) => x.id === id);
2076
+ if (item && item.url) setTimeout(() => URL.revokeObjectURL(item.url), 1e3);
2077
+ return arr.filter((x) => x.id !== id);
2078
+ });
2079
+ }, []);
2080
+ return { incoming, sendFile, dismissIncoming };
2081
+ }
2082
+ function RtcFileInbox({ T, peers, ctl }) {
2083
+ const items = ctl && ctl.incoming || [];
2084
+ if (!items.length) return null;
2085
+ return /* @__PURE__ */ React.createElement("div", { style: { position: "fixed", right: 18, bottom: 18, zIndex: 90, width: 360, display: "flex", flexDirection: "column", gap: 10 } }, items.map((it) => {
2086
+ const peer = (peers || []).find((p) => p.id === it.peerId || p.userId === it.peerId) || {};
2087
+ const who = peer.alias || peer.name || shortKey(it.peerId, 8, 5);
2088
+ return /* @__PURE__ */ React.createElement("div", { key: it.id, style: { border: "1px solid var(--line)", background: "var(--panel)", borderRadius: 12, padding: 12, boxShadow: "0 14px 40px rgba(0,0,0,0.35)" } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)", marginBottom: 6 } }, T && T.receivedFile || "Received file", " \xB7 ", who), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 13, fontWeight: 700, color: "var(--text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, it.name), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", marginTop: 3 } }, dkFileSize(it.size)), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 10 } }, /* @__PURE__ */ React.createElement("button", { onClick: () => ctl.dismissIncoming(it.id), style: { height: 30, borderRadius: 8, border: "1px solid var(--line)", background: "transparent", color: "var(--dim)", cursor: "pointer" } }, T && T.cancel || "Close"), /* @__PURE__ */ React.createElement("a", { href: it.url, download: it.name, onClick: () => setTimeout(() => ctl.dismissIncoming(it.id), 500), style: { height: 30, borderRadius: 8, padding: "6px 10px", background: "var(--accent)", color: "#fff", textDecoration: "none", fontFamily: "var(--mono)", fontSize: 12 } }, T && T.open || "Download")));
2089
+ }));
2090
+ }
2091
+ Object.assign(window, { useRtcFileController, RtcFileInbox });
1760
2092
  const CALL_ICE_SERVERS = [
1761
2093
  { urls: "stun:stun.l.google.com:19302" },
1762
2094
  { urls: "turn:tokyo.fi.chat:3478", username: "allcom", credential: "allcompass" }
@@ -2085,6 +2417,7 @@ const STR = {
2085
2417
  sendFile: "Send file",
2086
2418
  block: "Block peer",
2087
2419
  remove: "Remove friend",
2420
+ receivedFile: "Received file",
2088
2421
  attach: "Attach file",
2089
2422
  message: "Message",
2090
2423
  send: "Send",
@@ -2175,6 +2508,7 @@ const STR = {
2175
2508
  sendFile: "\u53D1\u9001\u6587\u4EF6",
2176
2509
  block: "\u62C9\u9ED1",
2177
2510
  remove: "\u5220\u9664\u597D\u53CB",
2511
+ receivedFile: "\u6536\u5230\u6587\u4EF6",
2178
2512
  attach: "\u9644\u4EF6",
2179
2513
  message: "\u6D88\u606F",
2180
2514
  send: "\u53D1\u9001",
@@ -2279,6 +2613,7 @@ function DkApp() {
2279
2613
  const rowPad = t.density === "comfortable" ? "11px 12px" : "7px 10px";
2280
2614
  const callCtl = useCallController(me.userId);
2281
2615
  const onCall = (peerId, video) => callCtl.start(peerId, !!video);
2616
+ const rtcFileCtl = useRtcFileController(me.userId);
2282
2617
  React.useEffect(() => {
2283
2618
  if (!activeId && peers.length) setActiveId(peers[0].id);
2284
2619
  }, [peers, activeId]);
@@ -2315,7 +2650,12 @@ function DkApp() {
2315
2650
  };
2316
2651
  const onSendFile = (file) => {
2317
2652
  if (!activeId || !file) return Promise.resolve({ ok: false });
2318
- return dkApi.sendFile(activeId, file).then((r) => {
2653
+ return rtcFileCtl.sendFile(activeId, file).then((r) => {
2654
+ if (r && r.ok && r.via === "webrtc") {
2655
+ 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" }));
2656
+ }
2657
+ return dkApi.sendFile(activeId, file);
2658
+ }).then((r) => {
2319
2659
  data.loadThread(activeId);
2320
2660
  data.refresh();
2321
2661
  return r;
@@ -2381,6 +2721,6 @@ function DkApp() {
2381
2721
  color: "#fff",
2382
2722
  fontSize: 12,
2383
2723
  fontWeight: 600
2384
- } }, n.label)))), /* @__PURE__ */ React.createElement(IncomingCallModal, { T, ctl: callCtl, peers }), /* @__PURE__ */ React.createElement(CallOverlay, { T, ctl: callCtl, peers }));
2724
+ } }, n.label)))), /* @__PURE__ */ React.createElement(IncomingCallModal, { T, ctl: callCtl, peers }), /* @__PURE__ */ React.createElement(CallOverlay, { T, ctl: callCtl, peers }), /* @__PURE__ */ React.createElement(RtcFileInbox, { T, peers, ctl: rtcFileCtl }));
2385
2725
  }
2386
2726
  ReactDOM.createRoot(document.getElementById("root")).render(/* @__PURE__ */ React.createElement(DkApp, null));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.214",
3
+ "version": "0.1.217",
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",
@@ -84,7 +84,7 @@
84
84
  },
85
85
  "dependencies": {
86
86
  "@decentnetwork/dora": "^0.1.14",
87
- "@decentnetwork/peer": "^0.1.107",
87
+ "@decentnetwork/peer": "^0.1.109",
88
88
  "@decentnetwork/peer-webrtc": "^0.2.10",
89
89
  "ink": "^5.2.1",
90
90
  "js-yaml": "^4.1.0",