@decentnetwork/beagle 0.1.5 → 0.1.6

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,66 @@
1
+ // Carrier bootstrap TURN credentials for browser WebRTC.
2
+ //
3
+ // Android online calls use CarrierExtension.getTurnServerInfo() →
4
+ // carrier_get_turn_server(). Prefer @decentnetwork/peer getIceServers() when
5
+ // the installed peer version exports it; otherwise fall back to
6
+ // deriveCarrierTurnCreds via the package dist path (older peer builds).
7
+ import { readFileSync, existsSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
10
+ import { base58ToBytes, carrierIdFromPublicKey } from "@decentnetwork/peer";
11
+ function hexToBytes(hex) {
12
+ const clean = hex.trim().replace(/^0x/i, "");
13
+ if (clean.length % 2 !== 0)
14
+ throw new Error("odd hex length");
15
+ const out = new Uint8Array(clean.length / 2);
16
+ for (let i = 0; i < out.length; i++) {
17
+ out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
18
+ }
19
+ return out;
20
+ }
21
+ function loadKeyPair(keyFile) {
22
+ if (!existsSync(keyFile))
23
+ throw new Error(`key file missing: ${keyFile}`);
24
+ const parsed = JSON.parse(readFileSync(keyFile, "utf-8"));
25
+ if (parsed.format !== "decent-peer-tox-keypair-v1" || !parsed.publicKey || !parsed.secretKey) {
26
+ throw new Error(`unsupported key file: ${keyFile}`);
27
+ }
28
+ return {
29
+ publicKey: hexToBytes(parsed.publicKey),
30
+ secretKey: hexToBytes(parsed.secretKey),
31
+ };
32
+ }
33
+ /**
34
+ * Build RTCIceServer entries matching Android WebrtcClient.getIceServers().
35
+ */
36
+ export async function buildCarrierCallIceServers(opts) {
37
+ const nodes = opts.bootstrapNodes.filter((n) => n?.host && n?.pk).slice(0, opts.limit ?? 3);
38
+ if (!nodes.length)
39
+ throw new Error("no bootstrap nodes for TURN");
40
+ const { publicKey, secretKey } = loadKeyPair(opts.keyFile);
41
+ const userid = carrierIdFromPublicKey(publicKey);
42
+ const peerRoot = dirname(fileURLToPath(import.meta.resolve("@decentnetwork/peer")));
43
+ const peerMod = (await import(pathToFileURL(join(peerRoot, "index.js")).href));
44
+ if (typeof peerMod.getIceServers === "function") {
45
+ return peerMod.getIceServers({
46
+ bootstrapNodes: nodes,
47
+ ourUserid: userid,
48
+ ourSecretKey: secretKey,
49
+ limit: opts.limit ?? 3,
50
+ includeTcpTurn: true,
51
+ });
52
+ }
53
+ const { deriveCarrierTurnCreds } = (await import(pathToFileURL(join(peerRoot, "turn-creds.js")).href));
54
+ const iceServers = [];
55
+ for (const n of nodes) {
56
+ const creds = deriveCarrierTurnCreds({
57
+ bootnodeHost: n.host,
58
+ bootnodePublicKey: base58ToBytes(n.pk),
59
+ ourUserid: userid,
60
+ ourSecretKey: secretKey,
61
+ });
62
+ const base = `${creds.host}:${creds.port}`;
63
+ iceServers.push({ urls: `stun:${base}`, username: creds.username, credential: creds.password }, { urls: `turn:${base}`, username: creds.username, credential: creds.password }, { urls: `turn:${base}?transport=tcp`, username: creds.username, credential: creds.password });
64
+ }
65
+ return iceServers;
66
+ }
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.6";
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);
@@ -2515,6 +2626,12 @@ const DK_FILE_RTC_KIND = "file";
2515
2626
  const DK_FILE_RTC_CHUNK = 16 * 1024;
2516
2627
  const DK_FILE_RTC_OPEN_TIMEOUT_MS = 2e4;
2517
2628
  const DK_FILE_RTC_SIGNAL_TTL_MS = 10 * 60 * 1e3;
2629
+ let _dkFileIcePromise = null;
2630
+ function dkFileIceServers() {
2631
+ if (!_dkFileIcePromise)
2632
+ _dkFileIcePromise = dkLoadCallIceServers();
2633
+ return _dkFileIcePromise;
2634
+ }
2518
2635
  function dkFileRtcId() {
2519
2636
  if (window.crypto && window.crypto.randomUUID)
2520
2637
  return window.crypto.randomUUID();
@@ -2700,7 +2817,7 @@ function useRtcFileController(selfId, onReceivedFile) {
2700
2817
  console.warn("[file-rtc] duplicate offer ignored for " + fileId);
2701
2818
  return;
2702
2819
  }
2703
- const pc = new RTCPeerConnection({ iceServers: CALL_ICE_SERVERS });
2820
+ const pc = new RTCPeerConnection({ iceServers: await dkFileIceServers() });
2704
2821
  pc.oniceconnectionstatechange = () => console.warn("[file-rtc] receiver ice=" + pc.iceConnectionState + " conn=" + pc.connectionState);
2705
2822
  pc.onconnectionstatechange = () => console.warn("[file-rtc] receiver conn=" + pc.connectionState + " ice=" + pc.iceConnectionState);
2706
2823
  pc.onicecandidate = (ev) => {
@@ -2768,7 +2885,7 @@ function useRtcFileController(selfId, onReceivedFile) {
2768
2885
  }
2769
2886
  const bus = dkRtcSignalBus();
2770
2887
  const fileId = dkFileRtcId();
2771
- const pc = new RTCPeerConnection({ iceServers: CALL_ICE_SERVERS });
2888
+ const pc = new RTCPeerConnection({ iceServers: await dkFileIceServers() });
2772
2889
  const dc = pc.createDataChannel("agentnet-file", { ordered: true });
2773
2890
  const sess = { pc, dc, peerId, chunks: [], meta: null, pendingCandidates: [] };
2774
2891
  peersRef.current.set(fileId, sess);
@@ -2869,11 +2986,6 @@ function RtcFileInbox({ T, peers, ctl }) {
2869
2986
  }));
2870
2987
  }
2871
2988
  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
2989
  const VIDEO_CODEC_KEEP = /^video\/(H264|VP8|rtx|red|ulpfec)$/i;
2878
2990
  const SDP_BUDGET = 8192;
2879
2991
  function trimVideoCodecs(pc) {
@@ -2929,71 +3041,106 @@ function useCallController(selfId, onCallLog) {
2929
3041
  console.warn("[call] WebRTC or peer-webrtc unavailable \u2014 calls disabled");
2930
3042
  return;
2931
3043
  }
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) {
3044
+ dkRtcSignalBus().start();
3045
+ let cancelled = false;
3046
+ let signaling = null;
3047
+ (async () => {
3048
+ await dkAwaitCallIceServers(1e4);
3049
+ if (cancelled || engineRef.current)
3050
+ return;
3051
+ if (!DK_LIVE_ICE_SERVERS.length) {
3052
+ console.warn("[call] no ICE servers after wait \u2014 calls will likely fail NAT");
2954
3053
  }
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;
3054
+ const { CallEngine } = window.PeerWebRTC;
3055
+ signaling = makeDaemonSignaling();
3056
+ const engine = new CallEngine({
3057
+ selfId,
3058
+ signaling,
3059
+ createPeerConnection: (c) => createLeanPeerConnection(c),
3060
+ getLocalMedia: (k) => navigator.mediaDevices.getUserMedia({ audio: k.audio, video: k.video }),
3061
+ getDisplayMedia: (c) => navigator.mediaDevices.getDisplayMedia(c || { video: true, audio: false }),
3062
+ iceServers: DK_LIVE_ICE_SERVERS,
3063
+ logger: (m) => console.log(m)
3064
+ });
3065
+ if (cancelled) {
3066
+ try {
3067
+ signaling.stop();
3068
+ engine.dispose();
3069
+ } catch (e) {
3070
+ }
3071
+ return;
3072
+ }
3073
+ engine.on("incomingCall", (info) => {
3074
+ setIncoming(info);
3075
+ if (onCallLogRef.current)
3076
+ onCallLogRef.current(info.peerId, !!info.video, "incoming");
3077
+ });
3078
+ engine.on("stateChanged", (info, state) => setActive((a) => a && a.callId === info.callId ? { ...a, state } : a));
3079
+ engine.on("localStream", (id, s) => {
3080
+ setLocalStream(s);
3081
+ try {
3082
+ setSharing(engine.isSharingScreen(id));
3083
+ } catch (e) {
3084
+ }
3085
+ });
3086
+ engine.on("remoteStream", (id, s) => setRemoteStream(s));
3087
+ engine.on("ended", (id) => {
3088
+ setActive((a) => a && a.callId === id ? null : a);
3089
+ setIncoming((i) => i && i.callId === id ? null : i);
3090
+ setLocalStream(null);
3091
+ setRemoteStream(null);
3092
+ });
3093
+ engineRef.current = engine;
3094
+ console.log("[call] CallEngine ready with ICE:", DK_LIVE_ICE_SERVERS.map((s) => s.urls));
3095
+ })().catch((e) => console.warn("[call] engine init failed", e));
2964
3096
  return () => {
3097
+ cancelled = true;
2965
3098
  try {
2966
- signaling.stop();
2967
- engine.dispose();
3099
+ if (signaling)
3100
+ signaling.stop();
3101
+ if (engineRef.current)
3102
+ engineRef.current.dispose();
2968
3103
  } catch (e) {
2969
3104
  }
2970
3105
  engineRef.current = null;
2971
3106
  };
2972
3107
  }, [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;
3108
+ const waitEngine = React.useCallback(async () => {
3109
+ for (let i = 0; i < 100; i++) {
3110
+ if (engineRef.current)
3111
+ return engineRef.current;
3112
+ await new Promise((r) => setTimeout(r, 100));
2978
3113
  }
3114
+ return null;
3115
+ }, []);
3116
+ const start = React.useCallback((peerId, video) => {
2979
3117
  setActive({ callId: null, peerId, video: !!video, direction: "outgoing", state: "ringing" });
2980
3118
  if (onCallLogRef.current)
2981
3119
  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) => {
3120
+ waitEngine().then((eng) => {
3121
+ if (!eng)
3122
+ throw new Error("Calls unavailable (engine not ready)");
3123
+ return eng.call(peerId, { audio: true, video: !!video });
3124
+ }).then((callId) => setActive((a) => a && a.peerId === peerId && a.callId === null ? { ...a, callId } : a)).catch((e) => {
2983
3125
  setActive((a) => a && a.peerId === peerId ? null : a);
2984
3126
  alert("Could not start call: " + (e && e.message || e));
2985
3127
  });
2986
- }, []);
2987
- const accept = React.useCallback(async () => {
2988
- const eng = engineRef.current;
3128
+ }, [waitEngine]);
3129
+ const accept = React.useCallback(() => {
3130
+ let pending = null;
2989
3131
  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
- }
3132
+ pending = inc;
2994
3133
  return null;
2995
3134
  });
2996
- }, []);
3135
+ if (!pending)
3136
+ return;
3137
+ setActive({ callId: pending.callId, peerId: pending.peerId, video: pending.video, direction: "incoming", state: "connecting" });
3138
+ waitEngine().then((eng) => {
3139
+ if (!eng)
3140
+ throw new Error("Calls unavailable (engine not ready)");
3141
+ return eng.accept(pending.callId);
3142
+ }).catch((e) => alert("Accept failed: " + (e && e.message || e)));
3143
+ }, [waitEngine]);
2997
3144
  const reject = React.useCallback(() => {
2998
3145
  const eng = engineRef.current;
2999
3146
  setIncoming((inc) => {
@@ -3068,6 +3215,16 @@ function useRingtone(active) {
3068
3215
  }
3069
3216
  let stopped = false;
3070
3217
  const timers = [];
3218
+ const stop = () => {
3219
+ if (stopped)
3220
+ return;
3221
+ stopped = true;
3222
+ timers.forEach(clearTimeout);
3223
+ try {
3224
+ ctx.close();
3225
+ } catch (e) {
3226
+ }
3227
+ };
3071
3228
  const burst = () => {
3072
3229
  if (stopped || ctx.state === "closed")
3073
3230
  return;
@@ -3088,13 +3245,12 @@ function useRingtone(active) {
3088
3245
  timers.push(setTimeout(burst, 3e3));
3089
3246
  };
3090
3247
  burst();
3248
+ window.addEventListener("pagehide", stop);
3249
+ window.addEventListener("beforeunload", stop);
3091
3250
  return () => {
3092
- stopped = true;
3093
- timers.forEach(clearTimeout);
3094
- try {
3095
- ctx.close();
3096
- } catch (e) {
3097
- }
3251
+ window.removeEventListener("pagehide", stop);
3252
+ window.removeEventListener("beforeunload", stop);
3253
+ stop();
3098
3254
  };
3099
3255
  }, [active]);
3100
3256
  }
@@ -207,8 +207,22 @@ export class EmbeddedHost {
207
207
  // file chips oldest-first. Reading history() and pattern-matching its shape
208
208
  // would couple this to the store's serialization format for no reason.
209
209
  for (const msg of this.#messages.queuedOutgoing(pubkey)) {
210
- if (!msg.file)
210
+ // Queued TEXT delivers here too — it used to be skipped, which made
211
+ // "queued" a terminal state for text: the clock icon never resolved.
212
+ if (!msg.file) {
213
+ try {
214
+ await this.#node.sendText(pubkey, msg.text);
215
+ this.#messages.setStatus(pubkey, msg.id, "sent");
216
+ this.#events.emit("event", { type: "chat", userid: pubkey, dir: "out" });
217
+ }
218
+ catch (e) {
219
+ // Still unreachable — stays queued for the next reconnect. Stop
220
+ // flushing so later messages can't overtake this one.
221
+ this.#logger.warn(`queued text flush failed: ${e.message}`);
222
+ break;
223
+ }
211
224
  continue;
225
+ }
212
226
  const path = resolve(this.outboxDir, msg.id);
213
227
  if (!existsSync(path))
214
228
  continue;
@@ -347,11 +361,34 @@ export class EmbeddedHost {
347
361
  }
348
362
  case "chat-send": {
349
363
  const text = String(req.text ?? "");
350
- await this.#node.sendText(uid, text);
351
- this.#messages.append(uid, "out", text, Date.now(), "sent");
364
+ if (!text)
365
+ return {};
366
+ // Record the user's message BEFORE any network attempt. It used to be
367
+ // appended only after sendText resolved, so an ACK timeout made the
368
+ // user's own words vanish from their own thread — they watched the
369
+ // peer answer a question that wasn't on screen (INBOX 2026-08-02).
370
+ // A send failure is a delivery state ON the message, never its absence.
371
+ const msg = this.#messages.append(uid, "out", text, Date.now());
352
372
  this.#meta.ensure(uid);
353
373
  this.#events.emit("event", { type: "chat", userid: uid, dir: "out" });
354
- return {};
374
+ // Anything already queued for this peer goes first — the new message
375
+ // must not overtake older ones the moment the path recovers.
376
+ await this.#flushOutbox(uid).catch(() => { });
377
+ try {
378
+ await this.#node.sendText(uid, text);
379
+ this.#messages.setStatus(uid, msg.id, "sent");
380
+ return {};
381
+ }
382
+ catch (e) {
383
+ // Offline peer or failed delivery: same recovery either way — keep
384
+ // the message "queued" (the UI shows the clock) and deliver it on
385
+ // the friend's reconnect via #flushOutbox. This is a success from
386
+ // the caller's side: the store owns the message; only its delivery
387
+ // is pending.
388
+ this.#logger.info(`send to ${uid.slice(0, 8)} queued: ${e.message}`);
389
+ this.#messages.setStatus(uid, msg.id, "queued");
390
+ return { queued: true };
391
+ }
355
392
  }
356
393
  case "chat-log-local":
357
394
  this.#messages.append(uid, req.dir ?? "in", String(req.text ?? ""), Date.now());
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,7 +1,7 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.5",
4
- "description": "Beagle P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
3
+ "version": "0.1.6",
4
+ "description": "Beagle \u2014 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",
7
7
  "bin": {
@@ -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"