@decentnetwork/beagle 0.1.54 → 0.1.56

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.56";
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,19 @@ 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
+ /** Native (iOS/Android) peers get NO inline shortcut at any size — they
29
+ * stream via sendFile → DNFT1 (peer >= 0.1.141) like everyone else, because
30
+ * the inline envelope has no usable confirmation against a native peer:
31
+ * protoVersion is structurally 0 (no text-ack), the bare FileModel carries no
32
+ * deliveryId to ack, and the transport-ack signal (our send window draining)
33
+ * was FIELD-FALSIFIED at every scale — a 282-BYTE single-fragment PNG sat
34
+ * unconfirmed for 20s on the same session that streamed 100MB at 0.0% loss.
35
+ * Gates of 11MB and 2.5MB and 256KB all just manufactured failed chips for
36
+ * files that had actually arrived. DNFT1's contiguous ack is the only true
37
+ * delivery signal a native can give; the cost is one offer round trip. */
28
38
  const MEDIA_RE = /\.(jpe?g|png|gif|webp|heic|bmp|svg|mp4|mov|m4v|webm|mp3|m4a|wav|aac|ogg|flac)$/i;
29
39
  const isMediaFileName = (n) => MEDIA_RE.test(n);
30
40
  /** Strip any directory component and characters that would let a peer-supplied
@@ -79,6 +89,27 @@ export class EmbeddedHost {
79
89
  this.#messages = new MessageStore(resolve(opts.configDir, "messages.json"));
80
90
  this.#meta = new FriendMetaStore(resolve(opts.configDir, "friends-meta.json"));
81
91
  }
92
+ /** Friend-list order: pinned first, then most recent conversation, then
93
+ * never-messaged contacts by name. Returns a new array — `friends()`
94
+ * hands back the SDK's own list and sorting it in place would reorder
95
+ * the friend store as a side effect of merely rendering the sidebar. */
96
+ #sortForList(friends, last) {
97
+ const rank = (f) => [
98
+ this.#meta.get(f.carrierId)?.pinned ? 0 : 1,
99
+ // Negated so a LARGER timestamp sorts first. No conversation →
100
+ // MAX_SAFE_INTEGER, which parks those entries after every real one.
101
+ // Not Infinity: two never-messaged friends would then compare
102
+ // Infinity - Infinity = NaN, and the tie-break would only still work
103
+ // by accident of NaN being falsy.
104
+ last.has(f.carrierId) ? -last.get(f.carrierId).ts : Number.MAX_SAFE_INTEGER,
105
+ (f.name || f.carrierId).toLowerCase(),
106
+ ];
107
+ return [...friends].sort((a, b) => {
108
+ const [ap, at, an] = rank(a);
109
+ const [bp, bt, bn] = rank(b);
110
+ return ap - bp || at - bt || an.localeCompare(bn);
111
+ });
112
+ }
82
113
  get downloadsDir() {
83
114
  return resolve(this.#opts.configDir, "downloads");
84
115
  }
@@ -338,7 +369,15 @@ export class EmbeddedHost {
338
369
  case "friends-list": {
339
370
  const last = this.#messages.lastMessages();
340
371
  return {
341
- friends: this.#node.friends().map((f) => {
372
+ // Most-recent conversation first, pinned above everything. Without
373
+ // this the list came out in friend-store insertion order — the
374
+ // order contacts were ADDED, which on restart is just the order
375
+ // they load from disk. lastMessages() has always been gathered
376
+ // "for the friend-list preview/sort"; only the preview half was
377
+ // ever wired up, so the sort silently degraded to arbitrary.
378
+ // Friends you have never exchanged a message with sort last, by
379
+ // name, instead of being scattered through the list.
380
+ friends: this.#sortForList(this.#node.friends(), last).map((f) => {
342
381
  const meta = this.#meta.get(f.carrierId);
343
382
  const lastMsg = last.get(f.carrierId);
344
383
  return {
@@ -592,21 +631,23 @@ export class EmbeddedHost {
592
631
  this.#events.emit("event", { type: "chat", userid, dir: "out" });
593
632
  return { queued: true, name: safe, size: data.length };
594
633
  }
595
- // A NATIVE friend (iOS/Android/C Carrier) takes the inline envelope for
596
- // small files (single blob, the Beagle apps' own fast path). Larger files
597
- // go through the normal sendFile below — peer >= 0.1.141 carries those to
598
- // natives as DNFT1 frames inside friend messages (offset acks, FEC,
599
- // resume; verified 100MB JS→iPad). Only route to inline what fits it.
634
+ // Everyone streams a native friend's packets ride DNFT1 frames inside
635
+ // friend messages (see the note on the deleted native inline gate above).
600
636
  const native = this.#node.isNativeFriend(userid);
601
- const inlineNative = native && data.length <= INLINE_MAX_BYTES;
602
637
  const msg = this.#messages.appendFile(userid, "out", { name: safe, size: data.length, status: "sending", sent: 0 });
603
638
  if (!msg)
604
639
  throw new Error("Could not create file message");
605
640
  await mkdir(this.outboxDir, { recursive: true });
606
641
  await writeFile(resolve(this.outboxDir, msg.id), data);
607
- const fileId = inlineNative ? null : this.#node.sendFile(userid, new Uint8Array(data), safe);
642
+ const fileId = this.#node.sendFile(userid, new Uint8Array(data), safe);
608
643
  if (!fileId) {
609
- // Native friend, or no transfer slot the inline envelope path.
644
+ // No transfer slot. The inline fallback only helps a JS friend (their
645
+ // text-ack makes it confirmable); to a native it is unconfirmable by
646
+ // construction, so fail honestly instead.
647
+ if (native) {
648
+ this.#messages.patchFile(userid, msg.id, { status: "failed" });
649
+ throw new Error("No free transfer slot — wait for a current transfer to finish and retry.");
650
+ }
610
651
  if (data.length > INLINE_MAX_BYTES) {
611
652
  this.#messages.patchFile(userid, msg.id, { status: "failed" });
612
653
  throw new Error(`Could not start the transfer, and the file is too large (${(data.length / 1024 / 1024).toFixed(1)} MB) ` +
@@ -632,7 +673,8 @@ export class EmbeddedHost {
632
673
  const status = delivery === "acked" ? "sent" : delivery === "offline" ? "queued" : "failed";
633
674
  this.#messages.patchFile(userid, msg.id, { status, sent: delivery === "acked" ? data.length : 0 });
634
675
  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}`);
676
+ this.#logger.info(`inline file "${safe}" (${data.length}B) to ${userid.slice(0, 8)}: delivery=${delivery} → ${status}` +
677
+ (delivery === "accepted" ? " (unconfirmed — the bytes went out and may have arrived, but the peer never acknowledged)" : ""));
636
678
  })();
637
679
  return { inline: true, name: safe, size: data.length, pending: true };
638
680
  }
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.56",
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.283",
30
30
  "@decentnetwork/peer": "^0.1.141",
31
31
  "@decentnetwork/peer-webrtc": "^0.2.16",
32
32
  "js-yaml": "^4.1.0",