@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 +6 -0
- package/dist/call-ice.d.ts +14 -0
- package/dist/call-ice.js +66 -0
- package/dist/cli.js +4 -0
- package/dist/desktop/app.js +218 -62
- package/dist/embedded-host.js +41 -4
- package/dist/server.d.ts +11 -0
- package/dist/server.js +30 -1
- package/package.json +3 -3
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,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
|
package/dist/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
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
|
|
2967
|
-
|
|
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
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
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
|
-
|
|
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(
|
|
2988
|
-
|
|
3128
|
+
}, [waitEngine]);
|
|
3129
|
+
const accept = React.useCallback(() => {
|
|
3130
|
+
let pending = null;
|
|
2989
3131
|
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
|
-
}
|
|
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
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
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
|
}
|
package/dist/embedded-host.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
"description": "Beagle
|
|
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.
|
|
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"
|