@decentnetwork/beagle 0.1.5 → 0.1.7

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.
package/README.md CHANGED
@@ -28,6 +28,12 @@ Then open <http://localhost:8766>.
28
28
  Port 8766 rather than decentlan's 8765, so during the transition the old
29
29
  `agentnet ui` and Beagle can run side by side on one machine.
30
30
 
31
+ ## Calls (web ↔ Android)
32
+
33
+ Online WebRTC with the Android app uses **Carrier bootstrap TURN**, not the
34
+ tokyo messaging relay. See [docs/webrtc-android-interop.md](docs/webrtc-android-interop.md)
35
+ for the failure modes and how the first-call cold-start was fixed.
36
+
31
37
  ## Status: Phase 1
32
38
 
33
39
  Beagle picks a **backend** for its Carrier identity. Exactly one is ever live,
@@ -0,0 +1,14 @@
1
+ import type { BootstrapNode } from "./node-config.js";
2
+ export interface RtcIceServer {
3
+ urls: string | string[];
4
+ username?: string;
5
+ credential?: string;
6
+ }
7
+ /**
8
+ * Build RTCIceServer entries matching Android WebrtcClient.getIceServers().
9
+ */
10
+ export declare function buildCarrierCallIceServers(opts: {
11
+ keyFile: string;
12
+ bootstrapNodes: BootstrapNode[];
13
+ limit?: number;
14
+ }): Promise<RtcIceServer[]>;
@@ -0,0 +1,45 @@
1
+ // Carrier bootstrap TURN credentials for browser WebRTC.
2
+ //
3
+ // Android online calls use CarrierExtension.getTurnServerInfo() →
4
+ // carrier_get_turn_server(). We call @decentnetwork/peer getIceServers()
5
+ // (0.1.136+) with the same credential scheme.
6
+ import { readFileSync, existsSync } from "node:fs";
7
+ import { carrierIdFromPublicKey, getIceServers } from "@decentnetwork/peer";
8
+ function hexToBytes(hex) {
9
+ const clean = hex.trim().replace(/^0x/i, "");
10
+ if (clean.length % 2 !== 0)
11
+ throw new Error("odd hex length");
12
+ const out = new Uint8Array(clean.length / 2);
13
+ for (let i = 0; i < out.length; i++) {
14
+ out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
15
+ }
16
+ return out;
17
+ }
18
+ function loadKeyPair(keyFile) {
19
+ if (!existsSync(keyFile))
20
+ throw new Error(`key file missing: ${keyFile}`);
21
+ const parsed = JSON.parse(readFileSync(keyFile, "utf-8"));
22
+ if (parsed.format !== "decent-peer-tox-keypair-v1" || !parsed.publicKey || !parsed.secretKey) {
23
+ throw new Error(`unsupported key file: ${keyFile}`);
24
+ }
25
+ return {
26
+ publicKey: hexToBytes(parsed.publicKey),
27
+ secretKey: hexToBytes(parsed.secretKey),
28
+ };
29
+ }
30
+ /**
31
+ * Build RTCIceServer entries matching Android WebrtcClient.getIceServers().
32
+ */
33
+ export async function buildCarrierCallIceServers(opts) {
34
+ const nodes = opts.bootstrapNodes.filter((n) => n?.host && n?.pk).slice(0, opts.limit ?? 3);
35
+ if (!nodes.length)
36
+ throw new Error("no bootstrap nodes for TURN");
37
+ const { publicKey, secretKey } = loadKeyPair(opts.keyFile);
38
+ return getIceServers({
39
+ bootstrapNodes: nodes,
40
+ ourUserid: carrierIdFromPublicKey(publicKey),
41
+ ourSecretKey: secretKey,
42
+ limit: opts.limit ?? 3,
43
+ includeTcpTurn: true,
44
+ });
45
+ }
package/dist/cli.js CHANGED
@@ -147,6 +147,10 @@ async function main() {
147
147
  routesPath: resolve(args.configDir, "routes.yaml"),
148
148
  doraRosterPath,
149
149
  downloadsDir: resolve(args.configDir, "downloads"),
150
+ callIce: {
151
+ keyFile: resolve(decentlanCarrierDir(args.configDir), "keypair.json"),
152
+ bootstrapNodes,
153
+ },
150
154
  meExtra: {
151
155
  // The panel reads "lan <x> · peer <y>". Report the real decentlan version
152
156
  // powering this backend, not beagle's own — conflating them made the
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.4";
1
+ window.__DK_UI_VERSION="0.1.7";
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"/>',
@@ -377,6 +377,98 @@ Object.assign(window, {
377
377
  DK_ME_FALLBACK,
378
378
  dkCopy
379
379
  });
380
+ const DK_FALLBACK_ICE_SERVERS = [
381
+ { urls: "stun:stun.l.google.com:19302" },
382
+ { urls: "turn:tokyo.fi.chat:3478", username: "allcom", credential: "allcompass" },
383
+ { urls: "turn:tokyo.fi.chat:3478?transport=tcp", username: "allcom", credential: "allcompass" }
384
+ ];
385
+ const DK_LIVE_ICE_SERVERS = [];
386
+ let _dkIcePromise = null;
387
+ let _dkIceWarmPromise = null;
388
+ function dkWarmCallIce(iceServers) {
389
+ if (_dkIceWarmPromise)
390
+ return _dkIceWarmPromise;
391
+ if (!window.RTCPeerConnection || !iceServers || !iceServers.length) {
392
+ _dkIceWarmPromise = Promise.resolve();
393
+ return _dkIceWarmPromise;
394
+ }
395
+ _dkIceWarmPromise = new Promise((resolve) => {
396
+ let pc;
397
+ let finished = false;
398
+ const done = (why) => {
399
+ if (finished)
400
+ return;
401
+ finished = true;
402
+ try {
403
+ if (pc)
404
+ pc.close();
405
+ } catch (e) {
406
+ }
407
+ console.log("[rtc] ICE warmup done (" + why + ")");
408
+ resolve();
409
+ };
410
+ try {
411
+ pc = new RTCPeerConnection({ iceServers, iceTransportPolicy: "all" });
412
+ const timer = setTimeout(() => done("timeout"), 5e3);
413
+ pc.onicecandidate = (ev) => {
414
+ const c = ev.candidate && ev.candidate.candidate;
415
+ if (c && /typ relay/.test(c)) {
416
+ clearTimeout(timer);
417
+ done("relay");
418
+ }
419
+ };
420
+ pc.onicegatheringstatechange = () => {
421
+ if (pc.iceGatheringState === "complete") {
422
+ clearTimeout(timer);
423
+ done("gather-complete");
424
+ }
425
+ };
426
+ pc.createDataChannel("ice-warm");
427
+ pc.createOffer().then((o) => pc.setLocalDescription(o)).catch(() => done("offer-fail"));
428
+ } catch (e) {
429
+ done("error");
430
+ }
431
+ });
432
+ return _dkIceWarmPromise;
433
+ }
434
+ function dkLoadCallIceServers() {
435
+ if (_dkIcePromise)
436
+ return _dkIcePromise;
437
+ _dkIcePromise = (async () => {
438
+ try {
439
+ const r = await fetch("/api/call-ice-servers", { headers: { "cache-control": "no-cache" } });
440
+ const d = await r.json();
441
+ if (d && d.ok && Array.isArray(d.iceServers) && d.iceServers.length) {
442
+ DK_LIVE_ICE_SERVERS.splice(0, DK_LIVE_ICE_SERVERS.length, ...d.iceServers);
443
+ console.log("[rtc] Carrier bootstrap ICE servers:", DK_LIVE_ICE_SERVERS.map((s) => s.urls));
444
+ await dkWarmCallIce(DK_LIVE_ICE_SERVERS);
445
+ return DK_LIVE_ICE_SERVERS;
446
+ }
447
+ console.warn("[rtc] call-ice-servers unavailable:", d && d.error);
448
+ } catch (e) {
449
+ console.warn("[rtc] call-ice-servers fetch failed", e);
450
+ }
451
+ console.warn("[rtc] falling back to tokyo TURN \u2014 web\u2194Android online ICE may fail");
452
+ DK_LIVE_ICE_SERVERS.splice(0, DK_LIVE_ICE_SERVERS.length, ...DK_FALLBACK_ICE_SERVERS.map((s) => ({ ...s })));
453
+ await dkWarmCallIce(DK_LIVE_ICE_SERVERS);
454
+ return DK_LIVE_ICE_SERVERS;
455
+ })();
456
+ return _dkIcePromise;
457
+ }
458
+ function dkAwaitCallIceServers(ms) {
459
+ const budget = typeof ms === "number" ? ms : 1e4;
460
+ return Promise.race([
461
+ dkLoadCallIceServers(),
462
+ new Promise((resolve) => setTimeout(() => {
463
+ if (!DK_LIVE_ICE_SERVERS.length) {
464
+ DK_LIVE_ICE_SERVERS.splice(0, DK_LIVE_ICE_SERVERS.length, ...DK_FALLBACK_ICE_SERVERS.map((s) => ({ ...s })));
465
+ console.warn("[rtc] ICE wait timed out \u2014 using fallback");
466
+ }
467
+ resolve(DK_LIVE_ICE_SERVERS);
468
+ }, budget))
469
+ ]).then(() => DK_LIVE_ICE_SERVERS);
470
+ }
471
+ dkLoadCallIceServers();
380
472
  function dkRtcParseEnvelope(data) {
381
473
  const text = String(data || "").replace(/\0+$/u, "").trim();
382
474
  if (!text)
@@ -394,12 +486,20 @@ function dkRtcSignalBus() {
394
486
  return window.__dkRtcSignalBus;
395
487
  const PW = window.PeerWebRTC;
396
488
  const handlers = /* @__PURE__ */ new Map();
489
+ const pendingCalls = [];
490
+ const PENDING_CALL_CAP = 16;
397
491
  let started = false;
398
492
  let stopped = false;
399
493
  function emit(kind, userid, payload) {
400
494
  const set = handlers.get(kind);
401
- if (!set)
495
+ if (!set || !set.size) {
496
+ if (kind === "call") {
497
+ pendingCalls.push({ userid, payload });
498
+ if (pendingCalls.length > PENDING_CALL_CAP)
499
+ pendingCalls.shift();
500
+ }
402
501
  return;
502
+ }
403
503
  for (const h of Array.from(set)) {
404
504
  try {
405
505
  h(userid, payload);
@@ -447,6 +547,17 @@ function dkRtcSignalBus() {
447
547
  handlers.set(kind, /* @__PURE__ */ new Set());
448
548
  handlers.get(kind).add(cb);
449
549
  this.start();
550
+ if (kind === "call" && pendingCalls.length) {
551
+ const queued = pendingCalls.splice(0);
552
+ console.log("[rtc] flushing " + queued.length + " buffered call signal(s)");
553
+ for (const q of queued) {
554
+ try {
555
+ cb(q.userid, q.payload);
556
+ } catch (e) {
557
+ console.warn("[rtc] buffered call handler failed", e);
558
+ }
559
+ }
560
+ }
450
561
  return () => {
451
562
  var _a;
452
563
  return (_a = handlers.get(kind)) == null ? void 0 : _a.delete(cb);
@@ -1853,7 +1964,15 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onReveal, onC
1853
1964
  style: { background: "none", border: "none", cursor: "pointer", padding: 0, marginLeft: 2, display: "inline-flex", opacity: 0.55 }
1854
1965
  },
1855
1966
  /* @__PURE__ */ React.createElement(Icon, { name: "trash", size: 11, stroke: 2, color: "var(--faint)" })
1856
- ), mine && m.status === "queued" ? /* @__PURE__ */ React.createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 2 }, title: "waiting for peer \u2014 will send when they're online" }, /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 11, stroke: 2.2, color: "var(--faint)" }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, T.queued || "queued")) : mine && m.status && /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 12, stroke: 2.2, color: m.status === "read" ? "var(--accent)" : "var(--faint)" })))
1967
+ ), mine && (m.status === "queued" || m.status === "sending") ? /* @__PURE__ */ React.createElement(
1968
+ "span",
1969
+ {
1970
+ style: { display: "inline-flex", alignItems: "center", gap: 2 },
1971
+ title: m.status === "sending" ? "delivering\u2026" : "waiting for peer \u2014 will send when they're online"
1972
+ },
1973
+ /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 11, stroke: 2.2, color: "var(--faint)" }),
1974
+ /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, m.status === "sending" ? T.sendingMsg || "sending" : T.queued || "queued")
1975
+ ) : mine && m.status && /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 12, stroke: 2.2, color: m.status === "read" ? "var(--accent)" : "var(--faint)" })))
1857
1976
  );
1858
1977
  }
1859
1978
  function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread }) {
@@ -2515,6 +2634,12 @@ const DK_FILE_RTC_KIND = "file";
2515
2634
  const DK_FILE_RTC_CHUNK = 16 * 1024;
2516
2635
  const DK_FILE_RTC_OPEN_TIMEOUT_MS = 2e4;
2517
2636
  const DK_FILE_RTC_SIGNAL_TTL_MS = 10 * 60 * 1e3;
2637
+ let _dkFileIcePromise = null;
2638
+ function dkFileIceServers() {
2639
+ if (!_dkFileIcePromise)
2640
+ _dkFileIcePromise = dkLoadCallIceServers();
2641
+ return _dkFileIcePromise;
2642
+ }
2518
2643
  function dkFileRtcId() {
2519
2644
  if (window.crypto && window.crypto.randomUUID)
2520
2645
  return window.crypto.randomUUID();
@@ -2700,7 +2825,7 @@ function useRtcFileController(selfId, onReceivedFile) {
2700
2825
  console.warn("[file-rtc] duplicate offer ignored for " + fileId);
2701
2826
  return;
2702
2827
  }
2703
- const pc = new RTCPeerConnection({ iceServers: CALL_ICE_SERVERS });
2828
+ const pc = new RTCPeerConnection({ iceServers: await dkFileIceServers() });
2704
2829
  pc.oniceconnectionstatechange = () => console.warn("[file-rtc] receiver ice=" + pc.iceConnectionState + " conn=" + pc.connectionState);
2705
2830
  pc.onconnectionstatechange = () => console.warn("[file-rtc] receiver conn=" + pc.connectionState + " ice=" + pc.iceConnectionState);
2706
2831
  pc.onicecandidate = (ev) => {
@@ -2768,7 +2893,7 @@ function useRtcFileController(selfId, onReceivedFile) {
2768
2893
  }
2769
2894
  const bus = dkRtcSignalBus();
2770
2895
  const fileId = dkFileRtcId();
2771
- const pc = new RTCPeerConnection({ iceServers: CALL_ICE_SERVERS });
2896
+ const pc = new RTCPeerConnection({ iceServers: await dkFileIceServers() });
2772
2897
  const dc = pc.createDataChannel("agentnet-file", { ordered: true });
2773
2898
  const sess = { pc, dc, peerId, chunks: [], meta: null, pendingCandidates: [] };
2774
2899
  peersRef.current.set(fileId, sess);
@@ -2869,11 +2994,6 @@ function RtcFileInbox({ T, peers, ctl }) {
2869
2994
  }));
2870
2995
  }
2871
2996
  Object.assign(window, { useRtcFileController, RtcFileInbox });
2872
- const CALL_ICE_SERVERS = [
2873
- { urls: "stun:stun.l.google.com:19302" },
2874
- { urls: "turn:tokyo.fi.chat:3478", username: "allcom", credential: "allcompass" },
2875
- { urls: "turn:tokyo.fi.chat:3478?transport=tcp", username: "allcom", credential: "allcompass" }
2876
- ];
2877
2997
  const VIDEO_CODEC_KEEP = /^video\/(H264|VP8|rtx|red|ulpfec)$/i;
2878
2998
  const SDP_BUDGET = 8192;
2879
2999
  function trimVideoCodecs(pc) {
@@ -2929,71 +3049,106 @@ function useCallController(selfId, onCallLog) {
2929
3049
  console.warn("[call] WebRTC or peer-webrtc unavailable \u2014 calls disabled");
2930
3050
  return;
2931
3051
  }
2932
- const { CallEngine } = window.PeerWebRTC;
2933
- const signaling = makeDaemonSignaling();
2934
- const engine = new CallEngine({
2935
- selfId,
2936
- signaling,
2937
- createPeerConnection: (c) => createLeanPeerConnection(c),
2938
- getLocalMedia: (k) => navigator.mediaDevices.getUserMedia({ audio: k.audio, video: k.video }),
2939
- getDisplayMedia: (c) => navigator.mediaDevices.getDisplayMedia(c || { video: true, audio: false }),
2940
- iceServers: CALL_ICE_SERVERS,
2941
- logger: (m) => console.log(m)
2942
- });
2943
- engine.on("incomingCall", (info) => {
2944
- setIncoming(info);
2945
- if (onCallLogRef.current)
2946
- onCallLogRef.current(info.peerId, !!info.video, "incoming");
2947
- });
2948
- engine.on("stateChanged", (info, state) => setActive((a) => a && a.callId === info.callId ? { ...a, state } : a));
2949
- engine.on("localStream", (id, s) => {
2950
- setLocalStream(s);
2951
- try {
2952
- setSharing(engine.isSharingScreen(id));
2953
- } catch (e) {
3052
+ dkRtcSignalBus().start();
3053
+ let cancelled = false;
3054
+ let signaling = null;
3055
+ (async () => {
3056
+ await dkAwaitCallIceServers(1e4);
3057
+ if (cancelled || engineRef.current)
3058
+ return;
3059
+ if (!DK_LIVE_ICE_SERVERS.length) {
3060
+ console.warn("[call] no ICE servers after wait \u2014 calls will likely fail NAT");
2954
3061
  }
2955
- });
2956
- engine.on("remoteStream", (id, s) => setRemoteStream(s));
2957
- engine.on("ended", (id) => {
2958
- setActive((a) => a && a.callId === id ? null : a);
2959
- setIncoming((i) => i && i.callId === id ? null : i);
2960
- setLocalStream(null);
2961
- setRemoteStream(null);
2962
- });
2963
- engineRef.current = engine;
3062
+ const { CallEngine } = window.PeerWebRTC;
3063
+ signaling = makeDaemonSignaling();
3064
+ const engine = new CallEngine({
3065
+ selfId,
3066
+ signaling,
3067
+ createPeerConnection: (c) => createLeanPeerConnection(c),
3068
+ getLocalMedia: (k) => navigator.mediaDevices.getUserMedia({ audio: k.audio, video: k.video }),
3069
+ getDisplayMedia: (c) => navigator.mediaDevices.getDisplayMedia(c || { video: true, audio: false }),
3070
+ iceServers: DK_LIVE_ICE_SERVERS,
3071
+ logger: (m) => console.log(m)
3072
+ });
3073
+ if (cancelled) {
3074
+ try {
3075
+ signaling.stop();
3076
+ engine.dispose();
3077
+ } catch (e) {
3078
+ }
3079
+ return;
3080
+ }
3081
+ engine.on("incomingCall", (info) => {
3082
+ setIncoming(info);
3083
+ if (onCallLogRef.current)
3084
+ onCallLogRef.current(info.peerId, !!info.video, "incoming");
3085
+ });
3086
+ engine.on("stateChanged", (info, state) => setActive((a) => a && a.callId === info.callId ? { ...a, state } : a));
3087
+ engine.on("localStream", (id, s) => {
3088
+ setLocalStream(s);
3089
+ try {
3090
+ setSharing(engine.isSharingScreen(id));
3091
+ } catch (e) {
3092
+ }
3093
+ });
3094
+ engine.on("remoteStream", (id, s) => setRemoteStream(s));
3095
+ engine.on("ended", (id) => {
3096
+ setActive((a) => a && a.callId === id ? null : a);
3097
+ setIncoming((i) => i && i.callId === id ? null : i);
3098
+ setLocalStream(null);
3099
+ setRemoteStream(null);
3100
+ });
3101
+ engineRef.current = engine;
3102
+ console.log("[call] CallEngine ready with ICE:", DK_LIVE_ICE_SERVERS.map((s) => s.urls));
3103
+ })().catch((e) => console.warn("[call] engine init failed", e));
2964
3104
  return () => {
3105
+ cancelled = true;
2965
3106
  try {
2966
- signaling.stop();
2967
- engine.dispose();
3107
+ if (signaling)
3108
+ signaling.stop();
3109
+ if (engineRef.current)
3110
+ engineRef.current.dispose();
2968
3111
  } catch (e) {
2969
3112
  }
2970
3113
  engineRef.current = null;
2971
3114
  };
2972
3115
  }, [selfId]);
2973
- const start = React.useCallback((peerId, video) => {
2974
- const eng = engineRef.current;
2975
- if (!eng) {
2976
- alert("Calls unavailable (WebRTC not supported here)");
2977
- return;
3116
+ const waitEngine = React.useCallback(async () => {
3117
+ for (let i = 0; i < 100; i++) {
3118
+ if (engineRef.current)
3119
+ return engineRef.current;
3120
+ await new Promise((r) => setTimeout(r, 100));
2978
3121
  }
3122
+ return null;
3123
+ }, []);
3124
+ const start = React.useCallback((peerId, video) => {
2979
3125
  setActive({ callId: null, peerId, video: !!video, direction: "outgoing", state: "ringing" });
2980
3126
  if (onCallLogRef.current)
2981
3127
  onCallLogRef.current(peerId, !!video, "outgoing");
2982
- eng.call(peerId, { audio: true, video: !!video }).then((callId) => setActive((a) => a && a.peerId === peerId && a.callId === null ? { ...a, callId } : a)).catch((e) => {
3128
+ waitEngine().then((eng) => {
3129
+ if (!eng)
3130
+ throw new Error("Calls unavailable (engine not ready)");
3131
+ return eng.call(peerId, { audio: true, video: !!video });
3132
+ }).then((callId) => setActive((a) => a && a.peerId === peerId && a.callId === null ? { ...a, callId } : a)).catch((e) => {
2983
3133
  setActive((a) => a && a.peerId === peerId ? null : a);
2984
3134
  alert("Could not start call: " + (e && e.message || e));
2985
3135
  });
2986
- }, []);
2987
- const accept = React.useCallback(async () => {
2988
- const eng = engineRef.current;
3136
+ }, [waitEngine]);
3137
+ const accept = React.useCallback(() => {
3138
+ let pending = null;
2989
3139
  setIncoming((inc) => {
2990
- if (eng && inc) {
2991
- setActive({ callId: inc.callId, peerId: inc.peerId, video: inc.video, direction: "incoming", state: "connecting" });
2992
- eng.accept(inc.callId).catch((e) => alert("Accept failed: " + (e && e.message || e)));
2993
- }
3140
+ pending = inc;
2994
3141
  return null;
2995
3142
  });
2996
- }, []);
3143
+ if (!pending)
3144
+ return;
3145
+ setActive({ callId: pending.callId, peerId: pending.peerId, video: pending.video, direction: "incoming", state: "connecting" });
3146
+ waitEngine().then((eng) => {
3147
+ if (!eng)
3148
+ throw new Error("Calls unavailable (engine not ready)");
3149
+ return eng.accept(pending.callId);
3150
+ }).catch((e) => alert("Accept failed: " + (e && e.message || e)));
3151
+ }, [waitEngine]);
2997
3152
  const reject = React.useCallback(() => {
2998
3153
  const eng = engineRef.current;
2999
3154
  setIncoming((inc) => {
@@ -3068,6 +3223,16 @@ function useRingtone(active) {
3068
3223
  }
3069
3224
  let stopped = false;
3070
3225
  const timers = [];
3226
+ const stop = () => {
3227
+ if (stopped)
3228
+ return;
3229
+ stopped = true;
3230
+ timers.forEach(clearTimeout);
3231
+ try {
3232
+ ctx.close();
3233
+ } catch (e) {
3234
+ }
3235
+ };
3071
3236
  const burst = () => {
3072
3237
  if (stopped || ctx.state === "closed")
3073
3238
  return;
@@ -3088,13 +3253,12 @@ function useRingtone(active) {
3088
3253
  timers.push(setTimeout(burst, 3e3));
3089
3254
  };
3090
3255
  burst();
3256
+ window.addEventListener("pagehide", stop);
3257
+ window.addEventListener("beforeunload", stop);
3091
3258
  return () => {
3092
- stopped = true;
3093
- timers.forEach(clearTimeout);
3094
- try {
3095
- ctx.close();
3096
- } catch (e) {
3097
- }
3259
+ window.removeEventListener("pagehide", stop);
3260
+ window.removeEventListener("beforeunload", stop);
3261
+ stop();
3098
3262
  };
3099
3263
  }, [active]);
3100
3264
  }
@@ -3310,6 +3474,7 @@ const STR = {
3310
3474
  send: "Send",
3311
3475
  pickPeer: "Select a peer to open the conversation",
3312
3476
  queued: "queued",
3477
+ sendingMsg: "sending",
3313
3478
  open: "open",
3314
3479
  retry: "Retry",
3315
3480
  cancel: "Cancel",
@@ -3404,6 +3569,7 @@ const STR = {
3404
3569
  send: "\u53D1\u9001",
3405
3570
  pickPeer: "\u9009\u62E9\u4E00\u4F4D\u597D\u53CB\u5F00\u59CB\u4F1A\u8BDD",
3406
3571
  queued: "\u5F85\u53D1\u9001",
3572
+ sendingMsg: "\u53D1\u9001\u4E2D",
3407
3573
  open: "\u6253\u5F00",
3408
3574
  retry: "\u91CD\u8BD5",
3409
3575
  cancel: "\u53D6\u6D88",
@@ -47,6 +47,10 @@ export class EmbeddedHost {
47
47
  #logger = new Logger({ prefix: "Beagle" });
48
48
  #opts;
49
49
  #events = new EventEmitter();
50
+ /** Per-peer delivery chains: background sends to one peer run strictly in
51
+ * order, so a new message can never overtake an older one whose ACK is
52
+ * still pending. Entry removed when the chain drains. */
53
+ #sendChains = new Map();
50
54
  /** Friend requests held for manual accept when auto-accept is off. */
51
55
  #pending = new Map();
52
56
  /** Inbound call signals waiting for the UI's long-poll to collect them. */
@@ -207,8 +211,22 @@ export class EmbeddedHost {
207
211
  // file chips oldest-first. Reading history() and pattern-matching its shape
208
212
  // would couple this to the store's serialization format for no reason.
209
213
  for (const msg of this.#messages.queuedOutgoing(pubkey)) {
210
- if (!msg.file)
214
+ // Queued TEXT delivers here too — it used to be skipped, which made
215
+ // "queued" a terminal state for text: the clock icon never resolved.
216
+ if (!msg.file) {
217
+ try {
218
+ await this.#node.sendText(pubkey, msg.text);
219
+ this.#messages.setStatus(pubkey, msg.id, "sent");
220
+ this.#events.emit("event", { type: "chat", userid: pubkey, dir: "out" });
221
+ }
222
+ catch (e) {
223
+ // Still unreachable — stays queued for the next reconnect. Stop
224
+ // flushing so later messages can't overtake this one.
225
+ this.#logger.warn(`queued text flush failed: ${e.message}`);
226
+ break;
227
+ }
211
228
  continue;
229
+ }
212
230
  const path = resolve(this.outboxDir, msg.id);
213
231
  if (!existsSync(path))
214
232
  continue;
@@ -347,10 +365,43 @@ export class EmbeddedHost {
347
365
  }
348
366
  case "chat-send": {
349
367
  const text = String(req.text ?? "");
350
- await this.#node.sendText(uid, text);
351
- this.#messages.append(uid, "out", text, Date.now(), "sent");
368
+ if (!text)
369
+ return {};
370
+ // Record the user's message BEFORE any network attempt. It used to be
371
+ // appended only after sendText resolved, so an ACK timeout made the
372
+ // user's own words vanish from their own thread — they watched the
373
+ // peer answer a question that wasn't on screen (INBOX 2026-08-02).
374
+ // A send failure is a delivery state ON the message, never its absence.
375
+ const msg = this.#messages.append(uid, "out", text, Date.now(), "sending");
352
376
  this.#meta.ensure(uid);
353
377
  this.#events.emit("event", { type: "chat", userid: uid, dir: "out" });
378
+ // Return NOW. The delivery attempt runs in the background — an ACK
379
+ // that takes 15s per hop must cost the network 15s, never the user
380
+ // (INBOX 2026-08-05: a peer that never ACKed made every send block
381
+ // the UI for 30s, which reads as "stuck" and provokes re-sends).
382
+ // Per-peer chaining keeps FIFO: anything queued flushes first, and a
383
+ // second send waits for this one's attempt before its own.
384
+ const prev = this.#sendChains.get(uid) ?? Promise.resolve();
385
+ const job = prev.then(async () => {
386
+ await this.#flushOutbox(uid).catch(() => { });
387
+ try {
388
+ await this.#node.sendText(uid, text);
389
+ this.#messages.setStatus(uid, msg.id, "sent");
390
+ }
391
+ catch (e) {
392
+ // Offline peer or failed delivery: same recovery either way —
393
+ // "queued" (the UI shows the clock) and #flushOutbox delivers it
394
+ // on the friend's reconnect. A status update, not a failure.
395
+ this.#logger.info(`send to ${uid.slice(0, 8)} queued: ${e.message}`);
396
+ this.#messages.setStatus(uid, msg.id, "queued");
397
+ }
398
+ this.#events.emit("event", { type: "chat", userid: uid, dir: "out" });
399
+ });
400
+ this.#sendChains.set(uid, job);
401
+ void job.finally(() => {
402
+ if (this.#sendChains.get(uid) === job)
403
+ this.#sendChains.delete(uid);
404
+ });
354
405
  return {};
355
406
  }
356
407
  case "chat-log-local":
@@ -14,10 +14,12 @@ export interface ChatMessage {
14
14
  ts: number;
15
15
  /** Stable per-message id (ts + per-process sequence) for UI keys / dedup. */
16
16
  id: string;
17
- /** Outgoing-text delivery state. "queued" means the peer was offline when the
18
- * user hit send, so the daemon stored it and will deliver it (in order) the
19
- * moment the friend reconnects. Cleared once actually sent. */
20
- status?: "queued" | "sent" | "failed";
17
+ /** Outgoing-text delivery state. "sending" = the background delivery attempt
18
+ * is still running (chat-send returns before the network is consulted, so
19
+ * the UI never blocks on a slow peer). "queued" = that attempt gave up; the
20
+ * daemon will deliver it (in order) the moment the friend reconnects.
21
+ * Cleared once actually sent. */
22
+ status?: "sending" | "queued" | "sent" | "failed";
21
23
  /** How this message traversed the network, for the UI to distinguish at a
22
24
  * glance (different colors). "online" = live net_crypto session (direct/relay);
23
25
  * "offline" = express store-and-forward (the friend or we were offline). Lets
@@ -52,11 +54,11 @@ export declare class MessageStore {
52
54
  /** Append a message and schedule a flush. Returns the stored message.
53
55
  * Pass `status: "queued"` for an outgoing text the peer wasn't online to
54
56
  * receive — the daemon flushes it on reconnect. */
55
- append(peer: string, dir: "in" | "out", text: string, ts?: number, status?: "queued" | "sent" | "failed", via?: "online" | "offline"): ChatMessage;
57
+ append(peer: string, dir: "in" | "out", text: string, ts?: number, status?: "sending" | "queued" | "sent" | "failed", via?: "online" | "offline"): ChatMessage;
56
58
  /** Set (or, with undefined, clear) the delivery status on a text message.
57
59
  * Used to flip a "queued" message to delivered once it's actually sent.
58
60
  * No-op if the id isn't found or is a file entry. Returns true if patched. */
59
- setStatus(peer: string, id: string, status?: "queued" | "sent" | "failed"): boolean;
61
+ setStatus(peer: string, id: string, status?: "sending" | "queued" | "sent" | "failed"): boolean;
60
62
  /** Outgoing messages still awaiting delivery (peer was offline), oldest
61
63
  * first — both queued text and queued file chips. The daemon drains this
62
64
  * on a friend's reconnect. */
@@ -33,6 +33,11 @@ export class MessageStore {
33
33
  let total = 0;
34
34
  for (const [peer, msgs] of Object.entries(raw)) {
35
35
  if (Array.isArray(msgs)) {
36
+ // A "sending" state cannot survive a restart — the attempt it
37
+ // described died with the process. Requeue so #flushOutbox owns it.
38
+ for (const m of msgs)
39
+ if (m.status === "sending")
40
+ m.status = "queued";
36
41
  this.byPeer.set(peer, msgs);
37
42
  total += msgs.length;
38
43
  }
package/dist/server.d.ts CHANGED
@@ -37,6 +37,17 @@ export interface BeagleServerOptions {
37
37
  listenHost?: string;
38
38
  listenPort?: number;
39
39
  log?: (msg: string) => void;
40
+ /** Carrier identity keyfile + bootstrap fleet — used to mint TURN creds for
41
+ * browser WebRTC so online calls use the same relays as Android
42
+ * CarrierExtension.getTurnServerInfo(). */
43
+ callIce?: {
44
+ keyFile: string;
45
+ bootstrapNodes: Array<{
46
+ host: string;
47
+ port: number;
48
+ pk: string;
49
+ }>;
50
+ };
40
51
  }
41
52
  export declare function startBeagleServer(opts: BeagleServerOptions): {
42
53
  stop: () => void;
package/dist/server.js CHANGED
@@ -645,6 +645,17 @@ export function startBeagleServer(opts) {
645
645
  }
646
646
  // Is THIS node one of the official exit nodes? (so the UI can badge it).
647
647
  const meExit = DEFAULT_EXITS.find((e) => e.userid && e.userid === identity.userid);
648
+ // "online" must mean "on the Carrier network", and the two backends
649
+ // know that differently. The daemon has a TUN device, so a TUN IP is
650
+ // a fine proxy. The embedded backend NEVER creates a TUN — judging it
651
+ // by tun.ip pinned this light at false forever, and every
652
+ // beagle-desktop user (embedded-only, no root) saw a dead app while
653
+ // messages flowed normally (INBOX 2026-08-02). For embedded, ask the
654
+ // peer itself: a live TCP-relay connection or a joined DHT is being
655
+ // on the network. TUN keeps its own field (`lan`) — it answers "do I
656
+ // have a virtual LAN", which is a different question.
657
+ const dht = d.dht ?? null;
658
+ const embeddedOnline = !!dht && ((Number(dht.tcpRelayConnected) || 0) > 0 || (Number(dht.knownNodesCount) || 0) > 0);
648
659
  const me = {
649
660
  name: node.name || (identity.userid ?? "").slice(0, 8),
650
661
  // Handle reads as a network address: <name>@decentnetwork.
@@ -654,7 +665,8 @@ export function startBeagleServer(opts) {
654
665
  carrier: identity.address ?? "",
655
666
  netKey: identity.userid ?? "",
656
667
  ip: tun.ip ?? d.allocatedIp ?? "",
657
- online: !!tun.ip,
668
+ online: node.backend === "embedded" ? embeddedOnline : !!tun.ip,
669
+ lan: !!tun.ip,
658
670
  lanVer: opts.meExtra?.lanVer ?? "",
659
671
  peerVer: opts.meExtra?.peerVer ?? "",
660
672
  channel: opts.meExtra?.channel ?? "@next",
@@ -861,6 +873,23 @@ export function startBeagleServer(opts) {
861
873
  sendJson(res, r.ok ? 200 : 400, r);
862
874
  return;
863
875
  }
876
+ // Carrier bootstrap TURN for browser RTCPeerConnection — same scheme as
877
+ // Android WebrtcClient.getIceServers() / carrier_get_turn_server().
878
+ if (req.method === "GET" && url === "/api/call-ice-servers") {
879
+ if (!opts.callIce) {
880
+ sendJson(res, 503, { ok: false, error: "call ICE not configured" });
881
+ return;
882
+ }
883
+ try {
884
+ const { buildCarrierCallIceServers } = await import("./call-ice.js");
885
+ const iceServers = await buildCarrierCallIceServers(opts.callIce);
886
+ sendJson(res, 200, { ok: true, iceServers });
887
+ }
888
+ catch (error) {
889
+ sendJson(res, 500, { ok: false, error: error.message });
890
+ }
891
+ return;
892
+ }
864
893
  if (req.method === "GET" && url === "/api/call-poll") {
865
894
  // The daemon holds this up to ~20s (long-poll). opts.call's own IPC
866
895
  // timeout is 30s, so it returns before that; on any error, resolve
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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",
@@ -27,7 +27,7 @@
27
27
  "dependencies": {
28
28
  "@decentnetwork/chat-components": "^0.1.3",
29
29
  "@decentnetwork/lan": "^0.1.256",
30
- "@decentnetwork/peer": "^0.1.123",
30
+ "@decentnetwork/peer": "^0.1.136",
31
31
  "@decentnetwork/peer-webrtc": "^0.2.10",
32
32
  "js-yaml": "^4.1.0",
33
33
  "yargs": "^17.7.2"