@decentnetwork/lan 0.1.209 → 0.1.210

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.
@@ -80,6 +80,8 @@ export interface IpcHandlers {
80
80
  fileDelete: (peer: string, ids: string[]) => Promise<Record<string, unknown>>;
81
81
  /** Cancel in-flight sends by chat message id. */
82
82
  fileCancel: (peer: string, ids: string[]) => Promise<Record<string, unknown>>;
83
+ /** Restart incomplete sends from their retained outbox bytes. */
84
+ fileRetry: (peer: string, ids: string[]) => Promise<Record<string, unknown>>;
83
85
  /** Mark a conversation read up to `ts` (defaults to now) — clears unread. */
84
86
  chatMarkRead: (userid: string, ts?: number) => Promise<void>;
85
87
  /** Send a WebRTC call-signaling payload (RtcSignal JSON) to a friend over the
@@ -113,7 +115,7 @@ export interface IpcHandlers {
113
115
  selfRestart: () => Promise<Record<string, unknown>>;
114
116
  }
115
117
  export interface IpcRequest {
116
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "file-delete" | "file-cancel" | "chat-mark-read" | "subscribe" | "sign" | "call-signal" | "call-poll" | "proxy-reload" | "proxy-access" | "self-restart";
118
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "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";
117
119
  address?: string;
118
120
  hello?: string;
119
121
  userid?: string;
@@ -238,6 +238,11 @@ export class IpcServer {
238
238
  throw new Error("userid is required");
239
239
  return await this.handlers.fileCancel(req.userid, req.ids ?? []);
240
240
  }
241
+ case "file-retry": {
242
+ if (!req.userid)
243
+ throw new Error("userid is required");
244
+ return await this.handlers.fileRetry(req.userid, req.ids ?? []);
245
+ }
241
246
  case "chat-mark-read": {
242
247
  if (!req.userid)
243
248
  throw new Error("userid is required");
@@ -77,6 +77,8 @@ export declare class MessageStore {
77
77
  avgKbps?: number;
78
78
  kbps?: number;
79
79
  }): boolean;
80
+ /** Look up one persisted message by its stable UI id. */
81
+ get(peer: string, id: string): ChatMessage | undefined;
80
82
  /** Remove messages by id. Returns the removed ones so the caller can clean up
81
83
  * any on-disk file the chip pointed at. */
82
84
  deleteMessages(peer: string, ids: string[]): ChatMessage[];
@@ -91,6 +91,10 @@ export class MessageStore {
91
91
  this.scheduleSave();
92
92
  return true;
93
93
  }
94
+ /** Look up one persisted message by its stable UI id. */
95
+ get(peer, id) {
96
+ return this.byPeer.get(peer)?.find((m) => m.id === id);
97
+ }
94
98
  /** Remove messages by id. Returns the removed ones so the caller can clean up
95
99
  * any on-disk file the chip pointed at. */
96
100
  deleteMessages(peer, ids) {
@@ -497,15 +497,22 @@ export class DaemonServer {
497
497
  this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
498
498
  return { inline: true, name, size: data.length };
499
499
  }
500
- const fileId = this.peerManager?.sendFile(userid, new Uint8Array(data), name);
501
- if (!fileId)
502
- throw new Error("No free transfer slot (or friend unknown)");
503
- this.logger.info(`Offering file "${name}" (${data.length}B) to ${userid.slice(0, 8)}`);
504
500
  // Add the "out" chip immediately as STATUS=sending (not "sent" — the
505
501
  // transfer is reliable+acked, so it only flips to "sent" once the
506
502
  // receiver confirms every byte). Progress/complete/cancel events patch
507
- // it. The chip is tracked by fileId so those events can find it.
503
+ // it. Retain the bytes until delivery so Cancel/Retry and content-hash
504
+ // resume work even though the UI upload temp file is removed.
508
505
  const msg = this.messageStore?.appendFile(userid, "out", { name: sanitizeFileName(name), size: data.length, status: "sending", sent: 0 });
506
+ if (!msg)
507
+ throw new Error("Could not create file message");
508
+ await fs.mkdir(this.outboxDir, { recursive: true });
509
+ await fs.writeFile(resolve(this.outboxDir, msg.id), data);
510
+ const fileId = this.peerManager?.sendFile(userid, new Uint8Array(data), name);
511
+ if (!fileId) {
512
+ this.messageStore?.patchFile(userid, msg.id, { status: "failed" });
513
+ throw new Error("No free transfer slot (or friend unknown)");
514
+ }
515
+ this.logger.info(`Offering file "${name}" (${data.length}B) to ${userid.slice(0, 8)}`);
509
516
  if (msg)
510
517
  this.activeSends.set(fileId, { peer: userid, msgId: msg.id });
511
518
  this.friendMeta?.ensure(userid);
@@ -551,6 +558,44 @@ export class DaemonServer {
551
558
  this.logger.info(`Cancelled ${n} send(s) for ${userid.slice(0, 8)}`);
552
559
  return { cancelled: n };
553
560
  },
561
+ fileRetry: async (userid, ids) => {
562
+ const fs = await import("fs/promises");
563
+ if (!this.peerManager?.isFriendOnline(userid)) {
564
+ throw new Error("Peer is not online yet");
565
+ }
566
+ let retried = 0;
567
+ for (const id of ids) {
568
+ const msg = this.messageStore?.get(userid, id);
569
+ if (!msg?.file || msg.dir !== "out" || msg.file.status === "sent")
570
+ continue;
571
+ const bytesPath = resolve(this.outboxDir, id);
572
+ let data;
573
+ try {
574
+ data = await fs.readFile(bytesPath);
575
+ }
576
+ catch {
577
+ throw new Error(`Original bytes are no longer available for ${msg.file.name}`);
578
+ }
579
+ // Retry also acts as an immediate restart for a stuck 0%/partial
580
+ // transfer. Stop the old file-number first; the receiver retains a
581
+ // content-hash partial and answers the new offer with its resume ACK.
582
+ for (const [fid, active] of [...this.activeSends]) {
583
+ if (active.peer !== userid || active.msgId !== id)
584
+ continue;
585
+ this.peerManager.cancelSend(userid, fid);
586
+ this.activeSends.delete(fid);
587
+ }
588
+ const fileId = this.peerManager.sendFile(userid, new Uint8Array(data), msg.file.name);
589
+ if (!fileId)
590
+ throw new Error(`No free transfer slot for ${msg.file.name}`);
591
+ this.messageStore?.patchFile(userid, id, { status: "sending", sent: 0, kbps: undefined });
592
+ this.activeSends.set(fileId, { peer: userid, msgId: id });
593
+ retried++;
594
+ this.logger.info(`Retrying file "${msg.file.name}" (${data.length}B) to ${userid.slice(0, 8)}`);
595
+ }
596
+ this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
597
+ return { retried };
598
+ },
554
599
  chatMarkRead: async (userid, ts) => {
555
600
  this.friendMeta?.markRead(userid, ts);
556
601
  },
@@ -962,7 +1007,9 @@ export class DaemonServer {
962
1007
  // If this was a queued (offline) send and its bytes are still in the
963
1008
  // outbox, revert the chip to "queued" so it retries on the next
964
1009
  // reconnect instead of dying as "failed". Otherwise mark failed.
965
- const requeue = !!this.outboxDir && existsSync(resolve(this.outboxDir, t.msgId));
1010
+ const requeue = p.reason !== "no-response" &&
1011
+ !this.peerManager?.isFriendOnline(t.peer) &&
1012
+ !!this.outboxDir && existsSync(resolve(this.outboxDir, t.msgId));
966
1013
  this.messageStore?.patchFile(t.peer, t.msgId, { status: requeue ? "queued" : "failed", sent: 0 });
967
1014
  this.activeSends.delete(p.fileId);
968
1015
  this.ipcEvents.emit("event", { type: "chat", userid: t.peer, dir: "out" });
@@ -227,6 +227,7 @@ const dkApi = {
227
227
  markRead: (userid) => dkPost("/api/chat-mark-read", { userid }),
228
228
  delFiles: (userid, ids) => dkPost("/api/file-delete", { userid, ids }),
229
229
  cancelSend: (userid, ids) => dkPost("/api/file-cancel", { userid, ids }),
230
+ retrySend: (userid, ids) => dkPost("/api/file-retry", { userid, ids }),
230
231
  setProfile: (name, description) => dkPost("/api/set-profile", { name, description }),
231
232
  sendFile: async (userid, file) => {
232
233
  try {
@@ -1071,7 +1072,7 @@ const mediaBtnStyle = {
1071
1072
  justifyContent: "center",
1072
1073
  lineHeight: 0
1073
1074
  };
1074
- function Msg({ m, peer, T, onTheater, onDelete, onCancel, selMode, selected, onToggleSel }) {
1075
+ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, selMode, selected, onToggleSel }) {
1075
1076
  const mine = m.from === "me";
1076
1077
  return /* @__PURE__ */ React.createElement(
1077
1078
  "div",
@@ -1181,6 +1182,17 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, selMode, selected, onT
1181
1182
  style: { background: "rgba(255,255,255,0.16)", border: "none", borderRadius: 6, cursor: "pointer", padding: 3, marginLeft: 2, display: "inline-flex", flexShrink: 0 }
1182
1183
  },
1183
1184
  /* @__PURE__ */ React.createElement(Icon, { name: "x", size: 13, stroke: 2.4, color: "#fff" })
1185
+ ), m.file.status !== "sent" && m.id && /* @__PURE__ */ React.createElement(
1186
+ "button",
1187
+ {
1188
+ title: T.retry || "retry",
1189
+ onClick: (e) => {
1190
+ e.stopPropagation();
1191
+ onRetry(m.id);
1192
+ },
1193
+ style: { background: "rgba(255,255,255,0.16)", border: "none", borderRadius: 6, cursor: "pointer", padding: 3, marginLeft: 2, display: "inline-flex", flexShrink: 0 }
1194
+ },
1195
+ /* @__PURE__ */ React.createElement(Icon, { name: "refresh", size: 13, stroke: 2.2, color: "#fff" })
1184
1196
  ));
1185
1197
  }
1186
1198
  return /* @__PURE__ */ React.createElement(
@@ -1267,6 +1279,12 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1267
1279
  if (id) dkApi.cancelSend(peer.userId, [id]).catch(() => {
1268
1280
  });
1269
1281
  };
1282
+ const doRetry = (id) => {
1283
+ if (!id) return;
1284
+ dkApi.retrySend(peer.userId, [id]).then((r) => {
1285
+ if (r && r.ok === false) window.alert((lang === "zh" ? "\u91CD\u8BD5\u5931\u8D25: " : "Retry failed: ") + (r.error || ""));
1286
+ });
1287
+ };
1270
1288
  const doDelete = (ids) => {
1271
1289
  ids = ids.filter(Boolean);
1272
1290
  if (!ids.length) return;
@@ -1352,6 +1370,7 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1352
1370
  onTheater: setTheater,
1353
1371
  onDelete: (id) => doDelete([id]),
1354
1372
  onCancel: doCancel,
1373
+ onRetry: doRetry,
1355
1374
  selMode,
1356
1375
  selected: sel.has(m.id),
1357
1376
  onToggleSel: toggleSel
@@ -1956,6 +1975,8 @@ const STR = {
1956
1975
  pickPeer: "Select a peer to open the conversation",
1957
1976
  queued: "queued",
1958
1977
  open: "open",
1978
+ retry: "Retry",
1979
+ cancel: "Cancel",
1959
1980
  myIp: "my ip",
1960
1981
  copyAddr: "copy address",
1961
1982
  peersOnline: "peers online",
@@ -2036,6 +2057,8 @@ const STR = {
2036
2057
  pickPeer: "\u9009\u62E9\u4E00\u4F4D\u597D\u53CB\u5F00\u59CB\u4F1A\u8BDD",
2037
2058
  queued: "\u5F85\u53D1\u9001",
2038
2059
  open: "\u6253\u5F00",
2060
+ retry: "\u91CD\u8BD5",
2061
+ cancel: "\u53D6\u6D88",
2039
2062
  myIp: "\u6211\u7684 IP",
2040
2063
  copyAddr: "\u590D\u5236\u5730\u5740",
2041
2064
  peersOnline: "\u5728\u7EBF\u597D\u53CB",
package/dist/ui/server.js CHANGED
@@ -292,6 +292,19 @@ export function startFriendUi(opts) {
292
292
  sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, ...(r.data ?? {}) } : { ok: false, error: r.error });
293
293
  return;
294
294
  }
295
+ // Retry/resume incomplete sends from the daemon's retained outbox copy.
296
+ if (req.method === "POST" && url === "/api/file-retry") {
297
+ const body = await readBody(req);
298
+ const userid = typeof body.userid === "string" ? body.userid : "";
299
+ const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
300
+ if (!userid || !ids.length) {
301
+ sendJson(res, 400, { ok: false, error: "userid and ids required" });
302
+ return;
303
+ }
304
+ const r = await opts.call({ op: "file-retry", userid, ids });
305
+ sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, ...(r.data ?? {}) } : { ok: false, error: r.error });
306
+ return;
307
+ }
295
308
  // ── File transfer ──────────────────────────────────────────────────
296
309
  // Upload: the browser POSTs raw file bytes with ?userid=&name= in the
297
310
  // query. We stream them to a temp file (no in-memory buffering of large
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.209",
3
+ "version": "0.1.210",
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.103",
87
+ "@decentnetwork/peer": "^0.1.104",
88
88
  "@decentnetwork/peer-webrtc": "^0.2.10",
89
89
  "ink": "^5.2.1",
90
90
  "js-yaml": "^4.1.0",