@decentnetwork/beagle 0.1.54 → 0.1.55

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.
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.54";
1
+ window.__DK_UI_VERSION="0.1.55";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
@@ -22,9 +22,26 @@ import { acquireIdentityLock } from "./identity-lock.js";
22
22
  * on disk until they reconnect, and an unbounded queue is how you fill a
23
23
  * user's disk by accident. Live transfers have no such limit. */
24
24
  const OUTBOX_MAX_FILE_BYTES = 25 * 1024 * 1024;
25
- /** Native (iOS/Android) clients receive files only as an inline envelope,
26
- * which is base64 (~1.34x) inside a 16MB bulkmsg cap. */
25
+ /** Inline envelope ceiling for a JS peer: base64 (~1.34x) inside a 16MB
26
+ * bulkmsg cap. */
27
27
  const INLINE_MAX_BYTES = 11 * 1024 * 1024;
28
+ /** Inline ceiling for a NATIVE (iOS/Android) peer — tiny files only.
29
+ *
30
+ * History: 11MB (the JS receiver's figure) created a dead band where
31
+ * multi-MB inline sends died silently in the native kernel (5.2MB failed,
32
+ * 52MB streamed fine); 2.5MB still sat inside the band where the envelope's
33
+ * only confirmation — the toxcore send window draining — is structurally
34
+ * unreliable: an 8.5MB envelope is ~11k bulkmsg fragments on a channel with
35
+ * no congestion control, and three live 8.5MB sends all timed out
36
+ * unconfirmed at 180s.
37
+ *
38
+ * Streaming (sendFile → DNFT1 for natives, peer >= 0.1.141) is the path
39
+ * built for size: offset acks, FEC, resume, and a real delivery verdict
40
+ * (verified 100MB JS→iPad, sent strictly after the receiver's disk write).
41
+ * Inline stays only as the one-round-trip shortcut for a thumbnail or a
42
+ * voice note: at 256KB (~350 fragments) the send window drains in seconds,
43
+ * so the transport ack actually means something. */
44
+ const INLINE_MAX_BYTES_NATIVE = 256 * 1024;
28
45
  const MEDIA_RE = /\.(jpe?g|png|gif|webp|heic|bmp|svg|mp4|mov|m4v|webm|mp3|m4a|wav|aac|ogg|flac)$/i;
29
46
  const isMediaFileName = (n) => MEDIA_RE.test(n);
30
47
  /** Strip any directory component and characters that would let a peer-supplied
@@ -79,6 +96,27 @@ export class EmbeddedHost {
79
96
  this.#messages = new MessageStore(resolve(opts.configDir, "messages.json"));
80
97
  this.#meta = new FriendMetaStore(resolve(opts.configDir, "friends-meta.json"));
81
98
  }
99
+ /** Friend-list order: pinned first, then most recent conversation, then
100
+ * never-messaged contacts by name. Returns a new array — `friends()`
101
+ * hands back the SDK's own list and sorting it in place would reorder
102
+ * the friend store as a side effect of merely rendering the sidebar. */
103
+ #sortForList(friends, last) {
104
+ const rank = (f) => [
105
+ this.#meta.get(f.carrierId)?.pinned ? 0 : 1,
106
+ // Negated so a LARGER timestamp sorts first. No conversation →
107
+ // MAX_SAFE_INTEGER, which parks those entries after every real one.
108
+ // Not Infinity: two never-messaged friends would then compare
109
+ // Infinity - Infinity = NaN, and the tie-break would only still work
110
+ // by accident of NaN being falsy.
111
+ last.has(f.carrierId) ? -last.get(f.carrierId).ts : Number.MAX_SAFE_INTEGER,
112
+ (f.name || f.carrierId).toLowerCase(),
113
+ ];
114
+ return [...friends].sort((a, b) => {
115
+ const [ap, at, an] = rank(a);
116
+ const [bp, bt, bn] = rank(b);
117
+ return ap - bp || at - bt || an.localeCompare(bn);
118
+ });
119
+ }
82
120
  get downloadsDir() {
83
121
  return resolve(this.#opts.configDir, "downloads");
84
122
  }
@@ -338,7 +376,15 @@ export class EmbeddedHost {
338
376
  case "friends-list": {
339
377
  const last = this.#messages.lastMessages();
340
378
  return {
341
- friends: this.#node.friends().map((f) => {
379
+ // Most-recent conversation first, pinned above everything. Without
380
+ // this the list came out in friend-store insertion order — the
381
+ // order contacts were ADDED, which on restart is just the order
382
+ // they load from disk. lastMessages() has always been gathered
383
+ // "for the friend-list preview/sort"; only the preview half was
384
+ // ever wired up, so the sort silently degraded to arbitrary.
385
+ // Friends you have never exchanged a message with sort last, by
386
+ // name, instead of being scattered through the list.
387
+ friends: this.#sortForList(this.#node.friends(), last).map((f) => {
342
388
  const meta = this.#meta.get(f.carrierId);
343
389
  const lastMsg = last.get(f.carrierId);
344
390
  return {
@@ -598,7 +644,11 @@ export class EmbeddedHost {
598
644
  // natives as DNFT1 frames inside friend messages (offset acks, FEC,
599
645
  // resume; verified 100MB JS→iPad). Only route to inline what fits it.
600
646
  const native = this.#node.isNativeFriend(userid);
601
- const inlineNative = native && data.length <= INLINE_MAX_BYTES;
647
+ // Per-peer ceiling: a native receiver's inline limit is far lower than a
648
+ // JS one's, and using the JS number for both is what created the dead
649
+ // band (see INLINE_MAX_BYTES_NATIVE).
650
+ const inlineLimit = native ? INLINE_MAX_BYTES_NATIVE : INLINE_MAX_BYTES;
651
+ const inlineNative = native && data.length <= inlineLimit;
602
652
  const msg = this.#messages.appendFile(userid, "out", { name: safe, size: data.length, status: "sending", sent: 0 });
603
653
  if (!msg)
604
654
  throw new Error("Could not create file message");
@@ -607,10 +657,10 @@ export class EmbeddedHost {
607
657
  const fileId = inlineNative ? null : this.#node.sendFile(userid, new Uint8Array(data), safe);
608
658
  if (!fileId) {
609
659
  // Native friend, or no transfer slot — the inline envelope path.
610
- if (data.length > INLINE_MAX_BYTES) {
660
+ if (data.length > inlineLimit) {
611
661
  this.#messages.patchFile(userid, msg.id, { status: "failed" });
612
662
  throw new Error(`Could not start the transfer, and the file is too large (${(data.length / 1024 / 1024).toFixed(1)} MB) ` +
613
- `for the inline fallback (limit ${(INLINE_MAX_BYTES / 1024 / 1024).toFixed(1)} MB).`);
663
+ `for the inline fallback (limit ${(inlineLimit / 1024 / 1024).toFixed(1)} MB).`);
614
664
  }
615
665
  // "sent" means the PEER CONFIRMED receipt — nothing less (the fake-sent
616
666
  // of INBOX 2026-08-21). And sends are NON-BLOCKING (the 2026-08-05
@@ -632,7 +682,8 @@ export class EmbeddedHost {
632
682
  const status = delivery === "acked" ? "sent" : delivery === "offline" ? "queued" : "failed";
633
683
  this.#messages.patchFile(userid, msg.id, { status, sent: delivery === "acked" ? data.length : 0 });
634
684
  this.#events.emit("event", { type: "chat", userid, dir: "out" });
635
- this.#logger.info(`inline file "${safe}" (${data.length}B) to ${userid.slice(0, 8)}: delivery=${delivery} → ${status}`);
685
+ this.#logger.info(`inline file "${safe}" (${data.length}B) to ${userid.slice(0, 8)}: delivery=${delivery} → ${status}` +
686
+ (delivery === "accepted" ? " (unconfirmed — the bytes went out and may have arrived, but the peer never acknowledged)" : ""));
636
687
  })();
637
688
  return { inline: true, name: safe, size: data.length, pending: true };
638
689
  }
package/dist/server.js CHANGED
@@ -1061,11 +1061,28 @@ export function startBeagleServer(opts) {
1061
1061
  }
1062
1062
  let destName = safe;
1063
1063
  let destPath = join(destDir, destName);
1064
+ // Re-sending a file the user already has must not duplicate it.
1065
+ // The -N suffix exists so two DIFFERENT files sharing a name both
1066
+ // survive — but an identical re-send hit it too, so every retry of
1067
+ // the same 52 MB video wrote another full copy (observed: four
1068
+ // byte-identical copies of one file, and a 3.9 GB downloads dir).
1069
+ // Reuse the existing path when the size already matches, so the
1070
+ // upload overwrites that copy instead of growing a new one. Tradeoff:
1071
+ // a different file with the same name AND the same byte count
1072
+ // replaces the older copy rather than sitting beside it — the same
1073
+ // thing any "save to downloads" does, and far better than unbounded
1074
+ // duplication. Hashing instead would mean buffering the whole upload.
1075
+ const uploadSize = Number(req.headers["content-length"] ?? NaN);
1064
1076
  if (keep) {
1065
1077
  const dot = safe.lastIndexOf(".");
1066
1078
  const base = dot > 0 ? safe.slice(0, dot) : safe;
1067
1079
  const ext = dot > 0 ? safe.slice(dot) : "";
1068
1080
  for (let i = 1; existsSync(destPath); i++) {
1081
+ // Same size is a cheap, reliable proxy here: these are the
1082
+ // sender's own files, not adversarial input, and the full
1083
+ // compare would mean buffering the upload to hash it.
1084
+ if (Number.isFinite(uploadSize) && statSync(destPath).size === uploadSize)
1085
+ break;
1069
1086
  destName = `${base}-${i}${ext}`;
1070
1087
  destPath = join(destDir, destName);
1071
1088
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.54",
3
+ "version": "0.1.55",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@decentnetwork/chat-components": "^0.1.3",
29
- "@decentnetwork/lan": "^0.1.281",
29
+ "@decentnetwork/lan": "^0.1.282",
30
30
  "@decentnetwork/peer": "^0.1.141",
31
31
  "@decentnetwork/peer-webrtc": "^0.2.16",
32
32
  "js-yaml": "^4.1.0",