@decentnetwork/lan 0.1.112 → 0.1.114

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.
Binary file
Binary file
Binary file
Binary file
@@ -1054,7 +1054,12 @@ var DaemonClient = class _DaemonClient {
1054
1054
  const arr = chats[userid] ?? [];
1055
1055
  return arr.map((m) => {
1056
1056
  const file = m.file;
1057
- const text = file ? `\u{1F4CE} ${file.name ?? "file"} (${fileSize(typeof file.size === "number" ? file.size : 0)})` : String(m.text ?? "");
1057
+ let suffix = "";
1058
+ if (file?.status === "sending") {
1059
+ const pct = file.size ? Math.min(100, Math.floor((file.sent ?? 0) / file.size * 100)) : 0;
1060
+ suffix = ` \u2014 sending ${pct}%`;
1061
+ } else if (file?.status === "failed") suffix = " \u2014 failed";
1062
+ const text = file ? `\u{1F4CE} ${file.name ?? "file"} (${fileSize(typeof file.size === "number" ? file.size : 0)})${suffix}` : String(m.text ?? "");
1058
1063
  return {
1059
1064
  t: hhmm(typeof m.ts === "number" ? m.ts : void 0),
1060
1065
  who: m.dir === "out" ? "me" : "them",
@@ -16,10 +16,14 @@ export interface ChatMessage {
16
16
  id: string;
17
17
  /** Present when this entry is a file transfer rather than a text message.
18
18
  * `name`/`size` describe the file; received files (dir:"in") are saved to
19
- * <configDir>/downloads/<name> and downloadable via the UI. */
19
+ * <configDir>/downloads/<name> and downloadable via the UI. For outgoing
20
+ * files, `status` tracks delivery and `sent` is the acked byte count (live
21
+ * progress) — the receiver confirms every byte before status becomes "sent". */
20
22
  file?: {
21
23
  name: string;
22
24
  size: number;
25
+ status?: "sending" | "sent" | "failed";
26
+ sent?: number;
23
27
  };
24
28
  }
25
29
  export declare class MessageStore {
@@ -33,11 +37,19 @@ export declare class MessageStore {
33
37
  private load;
34
38
  /** Append a message and schedule a flush. Returns the stored message. */
35
39
  append(peer: string, dir: "in" | "out", text: string, ts?: number): ChatMessage;
36
- /** Append a file-transfer entry (shown as a download chip in the UI). */
40
+ /** Append a file-transfer entry (shown as a file chip in the UI). */
37
41
  appendFile(peer: string, dir: "in" | "out", file: {
38
42
  name: string;
39
43
  size: number;
44
+ status?: "sending" | "sent" | "failed";
45
+ sent?: number;
40
46
  }, ts?: number): ChatMessage;
47
+ /** Patch an existing file message's transfer fields (status / sent bytes).
48
+ * No-op if the id isn't found. Returns true if it patched. */
49
+ patchFile(peer: string, id: string, patch: {
50
+ status?: "sending" | "sent" | "failed";
51
+ sent?: number;
52
+ }): boolean;
41
53
  private push;
42
54
  /**
43
55
  * Return history. With no peer, returns every peer's full thread (the legacy
@@ -47,10 +47,21 @@ export class MessageStore {
47
47
  append(peer, dir, text, ts = Date.now()) {
48
48
  return this.push(peer, { dir, text, ts, id: `${ts}-${this.seq++}` });
49
49
  }
50
- /** Append a file-transfer entry (shown as a download chip in the UI). */
50
+ /** Append a file-transfer entry (shown as a file chip in the UI). */
51
51
  appendFile(peer, dir, file, ts = Date.now()) {
52
52
  return this.push(peer, { dir, text: "", ts, id: `${ts}-${this.seq++}`, file });
53
53
  }
54
+ /** Patch an existing file message's transfer fields (status / sent bytes).
55
+ * No-op if the id isn't found. Returns true if it patched. */
56
+ patchFile(peer, id, patch) {
57
+ const arr = this.byPeer.get(peer);
58
+ const msg = arr?.find((m) => m.id === id);
59
+ if (!msg || !msg.file)
60
+ return false;
61
+ msg.file = { ...msg.file, ...patch };
62
+ this.scheduleSave();
63
+ return true;
64
+ }
54
65
  push(peer, msg) {
55
66
  let arr = this.byPeer.get(peer);
56
67
  if (!arr) {
@@ -41,6 +41,9 @@ export declare class DaemonServer {
41
41
  * chat, presence changes, and friend-requests so clients can refresh on
42
42
  * demand instead of polling on a tight interval. */
43
43
  private readonly ipcEvents;
44
+ /** Outgoing file transfers in flight: fileId → the chat message tracking it,
45
+ * so progress/complete/cancel events can patch its status + sent bytes. */
46
+ private readonly activeSends;
44
47
  private startedAt;
45
48
  private isRunning;
46
49
  private statusTimer?;
@@ -33,6 +33,21 @@ function shellQuote(parts) {
33
33
  .map((p) => "'" + p.replace(/'/g, `'\\''`) + "'")
34
34
  .join(" ");
35
35
  }
36
+ /** Make an incoming filename safe to write to disk AND friendly to open from a
37
+ * shell. Strips path separators + control chars and normalizes exotic Unicode
38
+ * whitespace (e.g. macOS's U+202F narrow no-break space before "PM" in
39
+ * screenshot/recording names) to a plain space, so files don't need shell
40
+ * escaping. Never empty. */
41
+ function sanitizeFileName(name) {
42
+ const cleaned = (name || "file")
43
+ .replace(/[/\\]/g, "_")
44
+ // eslint-disable-next-line no-control-regex
45
+ .replace(/[\u0000-\u001f\u007f]/g, "")
46
+ .replace(/[\u00a0\u2000-\u200f\u2028\u2029\u202f\u205f\u3000\ufeff]/g, " ")
47
+ .replace(/\s+/g, " ")
48
+ .trim();
49
+ return cleaned || "file";
50
+ }
36
51
  import { ConfigLoader, DEFAULT_DORAS } from "../config/loader.js";
37
52
  export class DaemonServer {
38
53
  config;
@@ -61,6 +76,9 @@ export class DaemonServer {
61
76
  * chat, presence changes, and friend-requests so clients can refresh on
62
77
  * demand instead of polling on a tight interval. */
63
78
  ipcEvents = new EventEmitter();
79
+ /** Outgoing file transfers in flight: fileId → the chat message tracking it,
80
+ * so progress/complete/cancel events can patch its status + sent bytes. */
81
+ activeSends = new Map();
64
82
  startedAt = 0;
65
83
  isRunning = false;
66
84
  statusTimer;
@@ -310,8 +328,13 @@ export class DaemonServer {
310
328
  if (!fileId)
311
329
  throw new Error("No free transfer slot (or friend unknown)");
312
330
  this.logger.info(`Offering file "${name}" (${data.length}B) to ${userid.slice(0, 8)}`);
313
- // Record in chat history (as an outgoing file chip) and push to the UI.
314
- this.messageStore?.appendFile(userid, "out", { name, size: data.length });
331
+ // Add the "out" chip immediately as STATUS=sending (not "sent" the
332
+ // transfer is reliable+acked, so it only flips to "sent" once the
333
+ // receiver confirms every byte). Progress/complete/cancel events patch
334
+ // it. The chip is tracked by fileId so those events can find it.
335
+ const msg = this.messageStore?.appendFile(userid, "out", { name: sanitizeFileName(name), size: data.length, status: "sending", sent: 0 });
336
+ if (msg)
337
+ this.activeSends.set(fileId, { peer: userid, msgId: msg.id });
315
338
  this.friendMeta?.ensure(userid);
316
339
  this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
317
340
  return { fileId, name, size: data.length };
@@ -588,17 +611,47 @@ export class DaemonServer {
588
611
  this.logger.info(`Incoming file "${o.name}" (${o.size}B) from ${o.friendId.slice(0, 8)} — accepting`);
589
612
  this.peerManager?.acceptFile(o.friendId, o.fileNumber);
590
613
  });
614
+ // Live send progress → patch the chip's sent bytes + push so the UI shows
615
+ // "sending …%". Throttled implicitly by the SDK's progress cadence.
616
+ this.peerManager.on("file-progress", (p) => {
617
+ if (!p.sending || !p.fileId)
618
+ return;
619
+ const t = this.activeSends.get(p.fileId);
620
+ if (!t)
621
+ return;
622
+ this.messageStore?.patchFile(t.peer, t.msgId, { sent: p.received });
623
+ this.ipcEvents.emit("event", { type: "chat", userid: t.peer, dir: "out" });
624
+ });
625
+ this.peerManager.on("file-cancel", (p) => {
626
+ if (!p.sending)
627
+ return;
628
+ this.logger.warn(`File send to ${p.friendId.slice(0, 8)} aborted (${p.reason ?? "cancelled"})`);
629
+ const t = p.fileId ? this.activeSends.get(p.fileId) : undefined;
630
+ if (t) {
631
+ this.messageStore?.patchFile(t.peer, t.msgId, { status: "failed" });
632
+ this.activeSends.delete(p.fileId);
633
+ this.ipcEvents.emit("event", { type: "chat", userid: t.peer, dir: "out" });
634
+ }
635
+ });
591
636
  this.peerManager.on("file-complete", (p) => {
592
637
  if (p.sending || !p.data) {
593
- if (p.sending)
594
- this.logger.info(`File "${p.name}" sent to ${p.friendId.slice(0, 8)}`);
638
+ if (p.sending) {
639
+ // The receiver has ACKed the whole file — now it's truly delivered.
640
+ this.logger.info(`File "${p.name}" delivered to ${p.friendId.slice(0, 8)}`);
641
+ const t = p.fileId ? this.activeSends.get(p.fileId) : undefined;
642
+ if (t) {
643
+ this.messageStore?.patchFile(t.peer, t.msgId, { status: "sent", sent: p.size });
644
+ this.activeSends.delete(p.fileId);
645
+ this.ipcEvents.emit("event", { type: "chat", userid: t.peer, dir: "out" });
646
+ }
647
+ }
595
648
  return;
596
649
  }
597
650
  void (async () => {
598
651
  const dir = resolve(this.configDir, "downloads");
599
652
  const fs = await import("fs/promises");
600
653
  await fs.mkdir(dir, { recursive: true });
601
- const safe = (p.name || "file").replace(/[/\\]/g, "_");
654
+ const safe = sanitizeFileName(p.name);
602
655
  await fs.writeFile(resolve(dir, safe), Buffer.from(p.data));
603
656
  this.logger.info(`Saved file "${p.name}" (${p.size}B) from ${p.friendId.slice(0, 8)} → ${resolve(dir, safe)}`);
604
657
  // Record in chat history (as a download chip) and push to the UI.
@@ -267,7 +267,13 @@ function useDaemonData() {
267
267
  from: m.dir === "out" ? "me" : "them",
268
268
  time: dkClock(m.ts),
269
269
  text: m.text,
270
- file: m.file ? { name: m.file.name, size: dkFileSize(m.file.size), dir: m.dir } : void 0,
270
+ file: m.file ? {
271
+ name: m.file.name,
272
+ size: dkFileSize(m.file.size),
273
+ dir: m.dir,
274
+ status: m.file.status,
275
+ pct: m.file.status === "sending" && m.file.size ? Math.min(100, Math.floor((m.file.sent || 0) / m.file.size * 100)) : void 0
276
+ } : void 0,
271
277
  status: m.dir === "out" ? "read" : void 0
272
278
  }));
273
279
  const withDay = msgs.length ? [{ day: dkDayLabel(arr[0].ts) }].concat(msgs) : [];
@@ -1033,8 +1039,8 @@ function Msg({ m, peer, T }) {
1033
1039
  }
1034
1040
  },
1035
1041
  /* @__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.kind || "file", size: 18, stroke: 1.9 })),
1036
- /* @__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 } }, m.file.size, mine ? "" : " \xB7 download")),
1037
- mine ? /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 16, stroke: 2, color: "var(--accent)" })
1042
+ /* @__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 } }, m.file.status === "sending" ? `${m.file.size} \xB7 sending ${m.file.pct != null ? m.file.pct + "%" : "\u2026"}` : m.file.status === "failed" ? `${m.file.size} \xB7 failed` : mine ? `${m.file.size} \xB7 sent` : `${m.file.size} \xB7 download`)),
1043
+ mine ? m.file.status === "sending" ? /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, color: "rgba(255,255,255,0.9)" } }, m.file.pct != null ? m.file.pct + "%" : "\u2026") : 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)" }) : /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 16, stroke: 2, color: "var(--accent)" })
1038
1044
  ) : /* @__PURE__ */ React.createElement("div", { style: {
1039
1045
  padding: "8px 12px",
1040
1046
  borderRadius: 12,
package/dist/ui/server.js CHANGED
@@ -307,9 +307,15 @@ export function startFriendUi(opts) {
307
307
  res.end("not found");
308
308
  return;
309
309
  }
310
+ // Content-Disposition must be ASCII — Node throws ERR_INVALID_CHAR on a
311
+ // non-ASCII header value (filenames from macOS contain U+202F before
312
+ // "PM", and others may be Chinese, etc.). Provide an ASCII-only
313
+ // `filename=` fallback plus the RFC 5987 `filename*=UTF-8''…` with the
314
+ // real (percent-encoded) name for clients that support it.
315
+ const asciiName = name.replace(/[^\x20-\x7e]/g, "_").replace(/"/g, "");
310
316
  res.writeHead(200, {
311
317
  "content-type": "application/octet-stream",
312
- "content-disposition": `attachment; filename="${name.replace(/"/g, "")}"`,
318
+ "content-disposition": `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(name)}`,
313
319
  });
314
320
  res.end(readFileSync(file));
315
321
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.112",
3
+ "version": "0.1.114",
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",
@@ -79,7 +79,7 @@
79
79
  },
80
80
  "dependencies": {
81
81
  "@decentnetwork/dora": "^0.1.11",
82
- "@decentnetwork/peer": "^0.1.49",
82
+ "@decentnetwork/peer": "^0.1.50",
83
83
  "ink": "^5.2.1",
84
84
  "js-yaml": "^4.1.0",
85
85
  "react": "^18.3.1",