@decentnetwork/beagle 0.1.55 → 0.1.57

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.55";
1
+ window.__DK_UI_VERSION="0.1.57";
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"/>',
@@ -1275,9 +1275,39 @@ Object.assign(window, {
1275
1275
  TweakButton
1276
1276
  });
1277
1277
  function RequestsBlock({ T, requests, onAct }) {
1278
- if (!requests.length)
1278
+ const [acted, setActed] = React.useState({});
1279
+ const [failed, setFailed] = React.useState({});
1280
+ const act = (r, kind) => {
1281
+ if (acted[r.id])
1282
+ return;
1283
+ setActed((a) => ({ ...a, [r.id]: kind }));
1284
+ setFailed((f) => {
1285
+ const n = { ...f };
1286
+ delete n[r.id];
1287
+ return n;
1288
+ });
1289
+ Promise.resolve(onAct(r.id, kind)).then((res) => {
1290
+ if (res && res.ok === false) {
1291
+ setActed((a) => {
1292
+ const n = { ...a };
1293
+ delete n[r.id];
1294
+ return n;
1295
+ });
1296
+ setFailed((f) => ({ ...f, [r.id]: res.error || "failed" }));
1297
+ }
1298
+ }).catch((e) => {
1299
+ setActed((a) => {
1300
+ const n = { ...a };
1301
+ delete n[r.id];
1302
+ return n;
1303
+ });
1304
+ setFailed((f) => ({ ...f, [r.id]: String(e && e.message || e) }));
1305
+ });
1306
+ };
1307
+ const visible = requests.filter((r) => !acted[r.id]);
1308
+ if (!visible.length)
1279
1309
  return null;
1280
- return /* @__PURE__ */ React.createElement("div", { style: { padding: "4px 0 8px" } }, /* @__PURE__ */ React.createElement(Section, { label: T.requests, count: requests.length, style: { margin: "4px 4px 8px" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 6 } }, requests.map((r) => /* @__PURE__ */ React.createElement("div", { key: r.id, style: {
1310
+ return /* @__PURE__ */ React.createElement("div", { style: { padding: "4px 0 8px" } }, /* @__PURE__ */ React.createElement(Section, { label: T.requests, count: visible.length, style: { margin: "4px 4px 8px" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 6 } }, visible.map((r) => /* @__PURE__ */ React.createElement("div", { key: r.id, style: {
1281
1311
  padding: "9px 10px",
1282
1312
  borderRadius: 8,
1283
1313
  background: "var(--panel-2)",
@@ -1285,7 +1315,7 @@ function RequestsBlock({ T, requests, onAct }) {
1285
1315
  display: "flex",
1286
1316
  flexDirection: "column",
1287
1317
  gap: 8
1288
- } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkIdenticon, { seed: r.carrier, size: 26, radius: 6 }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: 1 } }, /* @__PURE__ */ React.createElement(Mono, { size: 12, copy: r.carrier, title: r.carrier }, shortKey(r.carrier, 8, 6)), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)", marginTop: 2 } }, "via ", r.via, " \xB7 ", r.time))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(Btn, { tone: "ok", icon: "check", size: "sm", onClick: () => onAct(r.id, "accept"), style: { flex: 1 } }, T.accept), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", icon: "x", size: "sm", onClick: () => onAct(r.id, "reject"), style: { flex: 1 } }, T.reject))))));
1318
+ } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkIdenticon, { seed: r.carrier, size: 26, radius: 6 }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: 1 } }, /* @__PURE__ */ React.createElement(Mono, { size: 12, copy: r.carrier, title: r.carrier }, shortKey(r.carrier, 8, 6)), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)", marginTop: 2 } }, "via ", r.via, " \xB7 ", r.time))), failed[r.id] && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11, color: "var(--danger)" } }, failed[r.id]), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(Btn, { tone: "ok", icon: "check", size: "sm", onClick: () => act(r, "accept"), style: { flex: 1 } }, T.accept), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", icon: "x", size: "sm", onClick: () => act(r, "reject"), style: { flex: 1 } }, T.reject))))));
1289
1319
  }
1290
1320
  function PeerRow({ peer, T, active, onClick }) {
1291
1321
  const name = peer.alias || peer.userId;
@@ -5031,7 +5061,10 @@ function DkApp() {
5031
5061
  const onAct = (id, kind) => {
5032
5062
  const r = requests.find((x) => x.id === id);
5033
5063
  const uid = r && r.userid || id;
5034
- (kind === "accept" ? dkApi.accept(uid) : dkApi.reject(uid)).then(data.refresh);
5064
+ return (kind === "accept" ? dkApi.accept(uid) : dkApi.reject(uid)).then((res) => {
5065
+ data.refresh();
5066
+ return res;
5067
+ });
5035
5068
  };
5036
5069
  const onRemove = (peer) => {
5037
5070
  dkApi.remove(peer.id).then(data.refresh);
@@ -25,23 +25,16 @@ const OUTBOX_MAX_FILE_BYTES = 25 * 1024 * 1024;
25
25
  /** Inline envelope ceiling for a JS peer: base64 (~1.34x) inside a 16MB
26
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
+ /** 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. */
45
38
  const MEDIA_RE = /\.(jpe?g|png|gif|webp|heic|bmp|svg|mp4|mov|m4v|webm|mp3|m4a|wav|aac|ogg|flac)$/i;
46
39
  const isMediaFileName = (n) => MEDIA_RE.test(n);
47
40
  /** Strip any directory component and characters that would let a peer-supplied
@@ -69,6 +62,7 @@ export class EmbeddedHost {
69
62
  * order, so a new message can never overtake an older one whose ACK is
70
63
  * still pending. Entry removed when the chain drains. */
71
64
  #sendChains = new Map();
65
+ #sweepTimer = null;
72
66
  /** Friend requests held for manual accept when auto-accept is off. */
73
67
  #pending = new Map();
74
68
  /** Inbound call signals as a broadcast ring: every poller reads at its own
@@ -150,6 +144,50 @@ export class EmbeddedHost {
150
144
  await this.#node.start();
151
145
  await this.#node.join();
152
146
  await mkdir(this.downloadsDir, { recursive: true }).catch(() => undefined);
147
+ // The sweep is what actually makes "queued" resolve.
148
+ //
149
+ // Flushing only on the connected EDGE looked right and was not: if the
150
+ // peer was already connected when the message was queued, that edge had
151
+ // long since passed and nothing ever tried again — the message sat queued
152
+ // while the friend showed online. Ask it the other way round on a timer:
153
+ // who has mail waiting, and can we reach them now. Level-triggered, so a
154
+ // missed or early event costs one interval instead of the message.
155
+ this.#sweepTimer = setInterval(() => {
156
+ if (this.#sweeping)
157
+ return;
158
+ this.#sweeping = true;
159
+ void (async () => {
160
+ try {
161
+ for (const uid of this.#messages.queuedPeers()) {
162
+ if (this.#sessionUsable(uid))
163
+ await this.#chainFlush(uid);
164
+ }
165
+ }
166
+ finally {
167
+ this.#sweeping = false;
168
+ }
169
+ })();
170
+ }, 8000);
171
+ this.#sweepTimer.unref?.();
172
+ }
173
+ #sweeping = false;
174
+ /** A session that can actually carry traffic right now. */
175
+ #sessionUsable(pubkey) {
176
+ try {
177
+ const st = this.#node.sessionStatus(pubkey);
178
+ return !!(st && st.established && (st.udpRemote || st.hasTcpRoute));
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ }
184
+ /** Queue a flush behind whatever that peer already has in flight, so a
185
+ * sweep and a send can never interleave and reorder the outbox. */
186
+ #chainFlush(pubkey) {
187
+ const prev = this.#sendChains.get(pubkey) ?? Promise.resolve();
188
+ const job = prev.then(() => this.#flushOutbox(pubkey).catch(() => { }));
189
+ this.#sendChains.set(pubkey, job.catch(() => { }));
190
+ return job;
153
191
  }
154
192
  #wire() {
155
193
  this.#node.on("message", (pubkey, text, via) => {
@@ -159,6 +197,9 @@ export class EmbeddedHost {
159
197
  this.#messages.append(pubkey, "in", text, Date.now(), undefined, via);
160
198
  this.#meta.ensure(pubkey);
161
199
  this.#events.emit("event", { type: "chat", userid: pubkey, dir: "in" });
200
+ // Hearing from them proves the session carries traffic — better
201
+ // evidence than any status flag, so use it.
202
+ void this.#chainFlush(pubkey);
162
203
  });
163
204
  this.#node.on("friend-request", (req) => {
164
205
  const userid = req.userid ?? req.pubkey;
@@ -186,15 +227,8 @@ export class EmbeddedHost {
186
227
  // sat visibly stuck despite the peer being online. One delayed retry
187
228
  // covers that gap; the per-send chain and later reconnects cover the
188
229
  // rest.
189
- if (e.status === "connected") {
190
- void this.#flushOutbox(e.pubkey)
191
- .catch(() => { })
192
- .then(() => {
193
- if (this.#messages.queuedOutgoing(e.pubkey).length === 0)
194
- return;
195
- return new Promise((r) => setTimeout(r, 4000)).then(() => this.#flushOutbox(e.pubkey).catch(() => { }));
196
- });
197
- }
230
+ if (e.status === "connected")
231
+ void this.#chainFlush(e.pubkey); // fast path
198
232
  });
199
233
  this.#node.on("call-signal", (evt) => {
200
234
  this.#callLog.push({
@@ -329,6 +363,10 @@ export class EmbeddedHost {
329
363
  return () => this.#events.off("event", fn);
330
364
  }
331
365
  async stop() {
366
+ if (this.#sweepTimer) {
367
+ clearInterval(this.#sweepTimer);
368
+ this.#sweepTimer = null;
369
+ }
332
370
  // Stop the peer FIRST, then release the lock. The reverse order would open
333
371
  // a window where a daemon could start while our peer is still live.
334
372
  await this.#node.stop();
@@ -412,16 +450,38 @@ export class EmbeddedHost {
412
450
  }),
413
451
  };
414
452
  }
415
- case "friend-request":
416
- await this.#node.sendFriendRequest(String(req.address ?? ""), req.hello);
453
+ case "friend-request": {
454
+ // sendFriendRequest waits for our own announce to store and then walks
455
+ // the DHT toward the target — tens of seconds is normal. server.ts
456
+ // already races this with a 2.5s timer so the HTTP layer cannot hang,
457
+ // but a validation error has to come back fast to be useful, so do the
458
+ // cheap check here and let the slow part run unattended.
459
+ const address = String(req.address ?? "");
460
+ if (!address)
461
+ throw new Error("friend-request requires an address");
462
+ void this.#node
463
+ .sendFriendRequest(address, req.hello)
464
+ .catch((e) => this.#logger.info(`friend-request to ${address.slice(0, 8)} failed: ${e.message}`));
417
465
  return {};
466
+ }
418
467
  case "friends-pending":
419
468
  return { pending: [...this.#pending.values()] };
420
469
  case "friends-accept": {
421
470
  const entry = this.#pending.get(uid);
422
- if (entry) {
471
+ if (!entry)
472
+ return {};
473
+ // Drop it from pending BEFORE the node call, and answer without
474
+ // waiting on anything the network owns. The request card is rendered
475
+ // from a poll, so awaiting here left the user clicking Accept with
476
+ // nothing happening on screen at all — the same class of bug as
477
+ // chat-send's, which this file already fixed for messages.
478
+ this.#pending.delete(uid);
479
+ try {
423
480
  await this.#node.acceptFriendRequest(entry.pubkey);
424
- this.#pending.delete(uid);
481
+ }
482
+ catch (e) {
483
+ this.#pending.set(uid, entry); // refused: leave state as it was
484
+ throw e;
425
485
  }
426
486
  return {};
427
487
  }
@@ -638,29 +698,27 @@ export class EmbeddedHost {
638
698
  this.#events.emit("event", { type: "chat", userid, dir: "out" });
639
699
  return { queued: true, name: safe, size: data.length };
640
700
  }
641
- // A NATIVE friend (iOS/Android/C Carrier) takes the inline envelope for
642
- // small files (single blob, the Beagle apps' own fast path). Larger files
643
- // go through the normal sendFile below — peer >= 0.1.141 carries those to
644
- // natives as DNFT1 frames inside friend messages (offset acks, FEC,
645
- // resume; verified 100MB JS→iPad). Only route to inline what fits it.
701
+ // Everyone streams a native friend's packets ride DNFT1 frames inside
702
+ // friend messages (see the note on the deleted native inline gate above).
646
703
  const native = this.#node.isNativeFriend(userid);
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;
652
704
  const msg = this.#messages.appendFile(userid, "out", { name: safe, size: data.length, status: "sending", sent: 0 });
653
705
  if (!msg)
654
706
  throw new Error("Could not create file message");
655
707
  await mkdir(this.outboxDir, { recursive: true });
656
708
  await writeFile(resolve(this.outboxDir, msg.id), data);
657
- const fileId = inlineNative ? null : this.#node.sendFile(userid, new Uint8Array(data), safe);
709
+ const fileId = this.#node.sendFile(userid, new Uint8Array(data), safe);
658
710
  if (!fileId) {
659
- // Native friend, or no transfer slot the inline envelope path.
660
- if (data.length > inlineLimit) {
711
+ // No transfer slot. The inline fallback only helps a JS friend (their
712
+ // text-ack makes it confirmable); to a native it is unconfirmable by
713
+ // construction, so fail honestly instead.
714
+ if (native) {
715
+ this.#messages.patchFile(userid, msg.id, { status: "failed" });
716
+ throw new Error("No free transfer slot — wait for a current transfer to finish and retry.");
717
+ }
718
+ if (data.length > INLINE_MAX_BYTES) {
661
719
  this.#messages.patchFile(userid, msg.id, { status: "failed" });
662
720
  throw new Error(`Could not start the transfer, and the file is too large (${(data.length / 1024 / 1024).toFixed(1)} MB) ` +
663
- `for the inline fallback (limit ${(inlineLimit / 1024 / 1024).toFixed(1)} MB).`);
721
+ `for the inline fallback (limit ${(INLINE_MAX_BYTES / 1024 / 1024).toFixed(1)} MB).`);
664
722
  }
665
723
  // "sent" means the PEER CONFIRMED receipt — nothing less (the fake-sent
666
724
  // of INBOX 2026-08-21). And sends are NON-BLOCKING (the 2026-08-05
@@ -66,6 +66,9 @@ export declare class MessageStore {
66
66
  /** Outgoing messages still awaiting delivery (peer was offline), oldest
67
67
  * first — both queued text and queued file chips. The daemon drains this
68
68
  * on a friend's reconnect. */
69
+ /** Every peer with outgoing mail still waiting, so the outbox sweep does
70
+ * not have to ask per friend. */
71
+ queuedPeers(): string[];
69
72
  queuedOutgoing(peer: string): ChatMessage[];
70
73
  /** Append a file-transfer entry (shown as a file chip in the UI). */
71
74
  appendFile(peer: string, dir: "in" | "out", file: {
@@ -83,6 +83,16 @@ export class MessageStore {
83
83
  /** Outgoing messages still awaiting delivery (peer was offline), oldest
84
84
  * first — both queued text and queued file chips. The daemon drains this
85
85
  * on a friend's reconnect. */
86
+ /** Every peer with outgoing mail still waiting, so the outbox sweep does
87
+ * not have to ask per friend. */
88
+ queuedPeers() {
89
+ const out = [];
90
+ for (const [peer, arr] of this.byPeer) {
91
+ if (arr.some((m) => m.dir === "out" && (m.status === "queued" || m.file?.status === "queued")))
92
+ out.push(peer);
93
+ }
94
+ return out;
95
+ }
86
96
  queuedOutgoing(peer) {
87
97
  const arr = this.byPeer.get(peer) ?? [];
88
98
  return arr.filter((m) => m.dir === "out" && (m.status === "queued" || m.file?.status === "queued"));
package/dist/server.js CHANGED
@@ -120,13 +120,43 @@ async function ensSignAndSet(call, record) {
120
120
  // nftid=<punk id> on the beagles.eth name. Proxied server-side (browser can't
121
121
  // reach the origin's port cross-origin reliably); NOTE the upstream's TLS cert
122
122
  // must be valid — renew it when these routes start failing.
123
- const PUNKS_API_URL = process.env.PUNKS_API_URL || "https://app.beagle.chat:1337";
123
+ // api.beagle.chat/punksapi, NOT app.beagle.chat:1337. That name was repointed
124
+ // to GitHub Pages when the browser client took it over, so this default aimed
125
+ // at a host that serves nothing on 1337 and every punk avatar in the desktop
126
+ // app silently fell back to an identicon. The service itself never moved — it
127
+ // is the same box, now reached through Caddy on 443 instead of its own TLS
128
+ // listener, so there is no second certificate to keep alive.
129
+ // Endpoint of last resort. The real one comes from bgservers.json (punksApi)
130
+ // via punksBase() below, so the host can move without shipping a new build —
131
+ // which is the failure this whole comment exists because of: app.beagle.chat
132
+ // :1337 was baked in here, the name was repointed at GitHub Pages, and every
133
+ // punk avatar in the app quietly became an identicon.
134
+ const PUNKS_API_URL = process.env.PUNKS_API_URL || "https://api.beagle.chat/punksapi";
135
+ const BGSERVERS_URL = process.env.BGSERVERS_URL || "https://beagle.chat/assets/bgservers.json";
136
+ let punksBaseCache = null;
137
+ function punksBase() {
138
+ if (process.env.PUNKS_API_URL)
139
+ return Promise.resolve(process.env.PUNKS_API_URL);
140
+ if (!punksBaseCache) {
141
+ punksBaseCache = (async () => {
142
+ try {
143
+ const cfg = (await fetchDiscoverJson(BGSERVERS_URL));
144
+ const u = cfg?.punksApi?.[0]?.url;
145
+ if (typeof u === "string" && u)
146
+ return u.replace(/\/+$/, "");
147
+ }
148
+ catch { /* fall through to the built-in default */ }
149
+ return PUNKS_API_URL;
150
+ })();
151
+ }
152
+ return punksBaseCache;
153
+ }
124
154
  const punkCache = new Map(); // punk id → JSON; immutable set
125
155
  async function punksFetch(path) {
126
156
  const ctl = new AbortController();
127
157
  const timer = setTimeout(() => ctl.abort(), 10_000);
128
158
  try {
129
- const r = await fetch(`${PUNKS_API_URL}${path}`, { signal: ctl.signal });
159
+ const r = await fetch(`${await punksBase()}${path}`, { signal: ctl.signal });
130
160
  if (!r.ok)
131
161
  throw new Error(`punks upstream ${r.status}`);
132
162
  return await r.json();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
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.282",
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",