@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 +6 -0
- package/dist/call-ice.d.ts +14 -0
- package/dist/call-ice.js +45 -0
- package/dist/cli.js +4 -0
- package/dist/desktop/app.js +229 -63
- package/dist/embedded-host.js +54 -3
- package/dist/message-store.d.ts +8 -6
- package/dist/message-store.js +5 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +30 -1
- package/package.json +2 -2
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[]>;
|
package/dist/call-ice.js
ADDED
|
@@ -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
|
package/dist/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
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"
|
|
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:
|
|
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:
|
|
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
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
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
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
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
|
|
2967
|
-
|
|
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
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
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
|
-
|
|
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(
|
|
2988
|
-
|
|
3136
|
+
}, [waitEngine]);
|
|
3137
|
+
const accept = React.useCallback(() => {
|
|
3138
|
+
let pending = null;
|
|
2989
3139
|
setIncoming((inc) => {
|
|
2990
|
-
|
|
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
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
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",
|
package/dist/embedded-host.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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":
|
package/dist/message-store.d.ts
CHANGED
|
@@ -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. "
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
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. */
|
package/dist/message-store.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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"
|