@decentnetwork/beagle 0.1.4 → 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.3";
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);
@@ -1313,9 +1424,27 @@ function dkSafeHref(s) {
1313
1424
  }
1314
1425
  return "";
1315
1426
  }
1427
+ function dkTrimUrlTail(raw) {
1428
+ let url = raw;
1429
+ const TAIL = `.,;:!?'"\u3002\uFF0C\u3001\uFF1B\uFF1A\uFF01\uFF1F\u2026\u201D\u2019\u300D\u300F`;
1430
+ const PAIRS = [["(", ")"], ["[", "]"], ["{", "}"], ["\uFF08", "\uFF09"], ["\u3010", "\u3011"], ["\u300A", "\u300B"]];
1431
+ const count = (s, ch) => s.split(ch).length - 1;
1432
+ for (; ; ) {
1433
+ const before = url;
1434
+ while (url && TAIL.indexOf(url[url.length - 1]) >= 0)
1435
+ url = url.slice(0, -1);
1436
+ for (let i = 0; i < PAIRS.length; i += 1) {
1437
+ const open = PAIRS[i][0], close = PAIRS[i][1];
1438
+ if (url.slice(-1) === close && count(url, open) < count(url, close))
1439
+ url = url.slice(0, -1);
1440
+ }
1441
+ if (url === before)
1442
+ return url;
1443
+ }
1444
+ }
1316
1445
  function dkInlineMarkdown(s) {
1317
1446
  const text = String(s == null ? "" : s);
1318
- const tokenRe = /(`[^`\n]+`|\[[^\]\n]+\]\([^) \n]+(?: [^)]+)?\)|\*\*[^*\n]+?\*\*|\*[^*\n]+?\*)/g;
1447
+ const tokenRe = /(`[^`\n]+`|\[[^\]\n]+\]\([^) \n]+(?: [^)]+)?\)|https?:\/\/[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+|\*\*[^*\n]+?\*\*|\*[^*\n]+?\*)/g;
1319
1448
  let out = "";
1320
1449
  let last = 0;
1321
1450
  let m;
@@ -1328,6 +1457,11 @@ function dkInlineMarkdown(s) {
1328
1457
  const lm = /^\[([^\]\n]+)\]\(([^) \n]+)(?: [^)]+)?\)$/.exec(tok);
1329
1458
  const href = lm && dkSafeHref(lm[2]);
1330
1459
  out += href ? '<a href="' + dkHtmlEscape(href) + '" target="_blank" rel="noopener">' + dkHtmlEscape(lm[1]) + "</a>" : dkHtmlEscape(tok);
1460
+ } else if (/^https?:\/\//.test(tok)) {
1461
+ const url = dkTrimUrlTail(tok);
1462
+ const href = dkSafeHref(url);
1463
+ const tail = tok.slice(url.length);
1464
+ out += href ? '<a href="' + dkHtmlEscape(href) + '" target="_blank" rel="noopener">' + dkHtmlEscape(url) + "</a>" + dkHtmlEscape(tail) : dkHtmlEscape(tok);
1331
1465
  } else if (tok.startsWith("**")) {
1332
1466
  out += "<strong>" + dkHtmlEscape(tok.slice(2, -2)) + "</strong>";
1333
1467
  } else if (tok.startsWith("*")) {
@@ -2492,6 +2626,12 @@ const DK_FILE_RTC_KIND = "file";
2492
2626
  const DK_FILE_RTC_CHUNK = 16 * 1024;
2493
2627
  const DK_FILE_RTC_OPEN_TIMEOUT_MS = 2e4;
2494
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
+ }
2495
2635
  function dkFileRtcId() {
2496
2636
  if (window.crypto && window.crypto.randomUUID)
2497
2637
  return window.crypto.randomUUID();
@@ -2677,7 +2817,7 @@ function useRtcFileController(selfId, onReceivedFile) {
2677
2817
  console.warn("[file-rtc] duplicate offer ignored for " + fileId);
2678
2818
  return;
2679
2819
  }
2680
- const pc = new RTCPeerConnection({ iceServers: CALL_ICE_SERVERS });
2820
+ const pc = new RTCPeerConnection({ iceServers: await dkFileIceServers() });
2681
2821
  pc.oniceconnectionstatechange = () => console.warn("[file-rtc] receiver ice=" + pc.iceConnectionState + " conn=" + pc.connectionState);
2682
2822
  pc.onconnectionstatechange = () => console.warn("[file-rtc] receiver conn=" + pc.connectionState + " ice=" + pc.iceConnectionState);
2683
2823
  pc.onicecandidate = (ev) => {
@@ -2745,7 +2885,7 @@ function useRtcFileController(selfId, onReceivedFile) {
2745
2885
  }
2746
2886
  const bus = dkRtcSignalBus();
2747
2887
  const fileId = dkFileRtcId();
2748
- const pc = new RTCPeerConnection({ iceServers: CALL_ICE_SERVERS });
2888
+ const pc = new RTCPeerConnection({ iceServers: await dkFileIceServers() });
2749
2889
  const dc = pc.createDataChannel("agentnet-file", { ordered: true });
2750
2890
  const sess = { pc, dc, peerId, chunks: [], meta: null, pendingCandidates: [] };
2751
2891
  peersRef.current.set(fileId, sess);
@@ -2846,11 +2986,6 @@ function RtcFileInbox({ T, peers, ctl }) {
2846
2986
  }));
2847
2987
  }
2848
2988
  Object.assign(window, { useRtcFileController, RtcFileInbox });
2849
- const CALL_ICE_SERVERS = [
2850
- { urls: "stun:stun.l.google.com:19302" },
2851
- { urls: "turn:tokyo.fi.chat:3478", username: "allcom", credential: "allcompass" },
2852
- { urls: "turn:tokyo.fi.chat:3478?transport=tcp", username: "allcom", credential: "allcompass" }
2853
- ];
2854
2989
  const VIDEO_CODEC_KEEP = /^video\/(H264|VP8|rtx|red|ulpfec)$/i;
2855
2990
  const SDP_BUDGET = 8192;
2856
2991
  function trimVideoCodecs(pc) {
@@ -2906,71 +3041,106 @@ function useCallController(selfId, onCallLog) {
2906
3041
  console.warn("[call] WebRTC or peer-webrtc unavailable \u2014 calls disabled");
2907
3042
  return;
2908
3043
  }
2909
- const { CallEngine } = window.PeerWebRTC;
2910
- const signaling = makeDaemonSignaling();
2911
- const engine = new CallEngine({
2912
- selfId,
2913
- signaling,
2914
- createPeerConnection: (c) => createLeanPeerConnection(c),
2915
- getLocalMedia: (k) => navigator.mediaDevices.getUserMedia({ audio: k.audio, video: k.video }),
2916
- getDisplayMedia: (c) => navigator.mediaDevices.getDisplayMedia(c || { video: true, audio: false }),
2917
- iceServers: CALL_ICE_SERVERS,
2918
- logger: (m) => console.log(m)
2919
- });
2920
- engine.on("incomingCall", (info) => {
2921
- setIncoming(info);
2922
- if (onCallLogRef.current)
2923
- onCallLogRef.current(info.peerId, !!info.video, "incoming");
2924
- });
2925
- engine.on("stateChanged", (info, state) => setActive((a) => a && a.callId === info.callId ? { ...a, state } : a));
2926
- engine.on("localStream", (id, s) => {
2927
- setLocalStream(s);
2928
- try {
2929
- setSharing(engine.isSharingScreen(id));
2930
- } 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");
2931
3053
  }
2932
- });
2933
- engine.on("remoteStream", (id, s) => setRemoteStream(s));
2934
- engine.on("ended", (id) => {
2935
- setActive((a) => a && a.callId === id ? null : a);
2936
- setIncoming((i) => i && i.callId === id ? null : i);
2937
- setLocalStream(null);
2938
- setRemoteStream(null);
2939
- });
2940
- 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));
2941
3096
  return () => {
3097
+ cancelled = true;
2942
3098
  try {
2943
- signaling.stop();
2944
- engine.dispose();
3099
+ if (signaling)
3100
+ signaling.stop();
3101
+ if (engineRef.current)
3102
+ engineRef.current.dispose();
2945
3103
  } catch (e) {
2946
3104
  }
2947
3105
  engineRef.current = null;
2948
3106
  };
2949
3107
  }, [selfId]);
2950
- const start = React.useCallback((peerId, video) => {
2951
- const eng = engineRef.current;
2952
- if (!eng) {
2953
- alert("Calls unavailable (WebRTC not supported here)");
2954
- 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));
2955
3113
  }
3114
+ return null;
3115
+ }, []);
3116
+ const start = React.useCallback((peerId, video) => {
2956
3117
  setActive({ callId: null, peerId, video: !!video, direction: "outgoing", state: "ringing" });
2957
3118
  if (onCallLogRef.current)
2958
3119
  onCallLogRef.current(peerId, !!video, "outgoing");
2959
- 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) => {
2960
3125
  setActive((a) => a && a.peerId === peerId ? null : a);
2961
3126
  alert("Could not start call: " + (e && e.message || e));
2962
3127
  });
2963
- }, []);
2964
- const accept = React.useCallback(async () => {
2965
- const eng = engineRef.current;
3128
+ }, [waitEngine]);
3129
+ const accept = React.useCallback(() => {
3130
+ let pending = null;
2966
3131
  setIncoming((inc) => {
2967
- if (eng && inc) {
2968
- setActive({ callId: inc.callId, peerId: inc.peerId, video: inc.video, direction: "incoming", state: "connecting" });
2969
- eng.accept(inc.callId).catch((e) => alert("Accept failed: " + (e && e.message || e)));
2970
- }
3132
+ pending = inc;
2971
3133
  return null;
2972
3134
  });
2973
- }, []);
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]);
2974
3144
  const reject = React.useCallback(() => {
2975
3145
  const eng = engineRef.current;
2976
3146
  setIncoming((inc) => {
@@ -3045,6 +3215,16 @@ function useRingtone(active) {
3045
3215
  }
3046
3216
  let stopped = false;
3047
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
+ };
3048
3228
  const burst = () => {
3049
3229
  if (stopped || ctx.state === "closed")
3050
3230
  return;
@@ -3065,13 +3245,12 @@ function useRingtone(active) {
3065
3245
  timers.push(setTimeout(burst, 3e3));
3066
3246
  };
3067
3247
  burst();
3248
+ window.addEventListener("pagehide", stop);
3249
+ window.addEventListener("beforeunload", stop);
3068
3250
  return () => {
3069
- stopped = true;
3070
- timers.forEach(clearTimeout);
3071
- try {
3072
- ctx.close();
3073
- } catch (e) {
3074
- }
3251
+ window.removeEventListener("pagehide", stop);
3252
+ window.removeEventListener("beforeunload", stop);
3253
+ stop();
3075
3254
  };
3076
3255
  }, [active]);
3077
3256
  }
@@ -36,7 +36,12 @@
36
36
  .dk-md code { font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; font-size: 0.92em; background: rgba(0,0,0,0.18); border-radius: 4px; padding: 0.08em 0.3em; }
37
37
  .dk-md pre { margin: 0.45em 0 0; padding: 8px 10px; overflow: auto; border-radius: 8px; background: rgba(0,0,0,0.22); }
38
38
  .dk-md pre code { display: block; background: transparent; padding: 0; white-space: pre; }
39
- .dk-md a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
39
+ /* color:inherit is deliberate the same rule has to read well on the dark
40
+ incoming bubble AND on the accent-coloured outgoing one, where a blue link
41
+ would clash. The affordance comes from the underline and the cursor. */
42
+ .dk-md a { color: inherit; text-decoration: underline; text-underline-offset: 2px;
43
+ cursor: pointer; overflow-wrap: anywhere; }
44
+ .dk-md a:hover { text-decoration-thickness: 2px; opacity: 0.85; }
40
45
  @keyframes dkpulse { 0% { transform: scale(0.9); opacity: 0.7; } 70% { transform: scale(1.5); opacity: 0; } 100% { opacity: 0; } }
41
46
  </style>
42
47
  </head>
@@ -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.4",
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"